diff --git a/.agents/skills/android-readme-screenshot-studio/SKILL.md b/.agents/skills/android-readme-screenshot-studio/SKILL.md new file mode 100644 index 00000000..3ee92705 --- /dev/null +++ b/.agents/skills/android-readme-screenshot-studio/SKILL.md @@ -0,0 +1,260 @@ +--- +name: android-readme-screenshot-studio +description: Create or refresh polished, high-resolution screenshots of the Bitchat Android app for README and repository showcase use. Use this skill whenever a user asks for README screenshots, app-store-like repository images, a populated mesh-chat showcase, voice-note or media conversation captures, a geohash globe image, higher-resolution Android emulator captures, or a PR that adds or replaces documentation screenshots. It owns the complete workflow from latest-main isolation and deterministic synthetic fixtures through real app rendering, visual inspection, system-chrome cropping, README asset updates, clean builds, and an optional PR. Do not use it for before/after UI regression evidence, which belongs to android-ui-visual-review, or for physical mesh behavior, which belongs to mesh-lab. +compatibility: Requires git, gh, the Android SDK and emulator, adb, Java/Gradle, Python 3, and image inspection support. FFmpeg is useful for capture-only media preparation. +--- + +# Android README Screenshot Studio + +Create repository screenshots from the real Bitchat Android UI, not from a +drawn mockup. The result should look intentional enough for the top of the +README while remaining reproducible, synthetic, and honest about what a static +emulator capture proves. + +## Resolve the brief + +Extract as much as possible from the conversation before asking questions. +Confirm or infer: + +1. Which surfaces are needed, such as mesh chat and geohash globe. +2. The exact visible state and ordering. Treat phrases like “photo, messages, + three voices, thumbs-up” as a chronological contract rather than a loose + suggestion. +3. Whether existing README images should be preserved, replaced, or added. +4. The target base. Default repository work to the latest `origin/main`. +5. Whether GitHub publication and merge are authorized. A request to “open a + PR and merge it” authorizes both; otherwise do not merge. + +Do not invent the subject of a requested photo. Nicknames, channel names, or +previous fixture copy are not sufficient justification for choosing an outdoor, +urban, political, or personal scene. Reuse an existing rights-safe asset when +the subject should remain stable, or ask for the intended subject. If the user +explicitly approves synthetic imagery, disclose it and keep its source +capture-only unless they request a committed asset. + +Read [references/showcase-recipes.md](references/showcase-recipes.md) for every +run. It contains the concrete mesh-chat and globe recipes, framing guidance, +the verified current-pair fast path, and the final acceptance checklist. When +the request matches the existing README pair and the production UI has not +materially changed, try that fast path first and then validate every visible +result. Fall back to tracing the current implementation when an entry point, +state model, or composition has changed. + +When a populated screen requires a debug fixture, also read +[../android-ui-visual-review/references/fixture-recipes.md](../android-ui-visual-review/references/fixture-recipes.md). + +## Work in a fresh tree + +Protect the user's active checkout: + +1. Inspect `git status` without modifying it. +2. Fetch `origin/main`. +3. Create a new `codex/` branch in a temporary worktree rooted at the current + `origin/main`. +4. Keep screenshots and raw captures in a separate temporary artifact + directory so cleanup or checkout operations cannot remove them. + +Do not switch the user's original checkout, reuse a dirty branch, or mix an +unrelated PR into the screenshot change. If the request continues an existing +screenshot PR, reuse its already-isolated worktree only after verifying its +head and base. + +## Establish the capture contract + +Before building, write a compact local matrix containing: + +- surface and navigation path; +- chronological fixture contents; +- expected visible top and bottom rows; +- emulator profile class, portrait orientation, theme, and locale; +- crop policy and final asset dimensions; +- existing asset path and README reference; +- behaviors the static screenshot does not prove. + +Use the current production UI and latest `main` interaction model. Trace the +screen entry point and state source before adding a fixture. A beautiful capture +of a stale or fake UI is not acceptable. + +## Use a high-resolution Android canvas + +Prefer the newest stable Android runtime installed locally and a large, +high-density portrait emulator profile. Reuse a previously validated capture +profile when it remains available, but verify the guest properties and keep +the resulting profile facts in the local capture contract rather than the +repository or GitHub text. + +After boot, record the guest values with an explicit emulator selector: + +```sh +adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.release +adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.sdk +adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.security_patch +adb -s "$ANDROID_README_SERIAL" shell wm size +adb -s "$ANDROID_README_SERIAL" shell wm density +``` + +Never publish emulator selectors, AVD names, local paths, usernames, IP +addresses, or other machine identifiers. + +## Build a deterministic showcase fixture + +Launch the Activity before injecting process-local state. Prefer existing debug +hooks. If they cannot express the composition, add the smallest temporary +command under: + +```text +app/src/debug/java/com/bitchat/android/testhook/ +``` + +The fixture should: + +- use synthetic names, peer IDs, message IDs, and copy; +- use the real local mesh peer ID for self-authored messages; +- use a fixed epoch so timestamps and ordering are stable; +- insert records in the exact requested chronology; +- report structured counts through the test-hook result file; +- copy capture-only media into the app's cache or files directory; +- populate only the peers and state needed for the header; +- avoid persistence unless persistence itself is the subject. + +For voice notes, route real audio files through the app's waveform extractor. +Use short, distinct, locally synthesized speech clips or other rights-safe +speech audio. Never draw a decorative waveform and call it speech. Wait for +asynchronous decoding before capture, then inspect that pauses and syllable +envelopes look plausibly different between notes. + +For image attachments, use a rights-safe existing asset or an explicitly +approved synthetic source. Keep fixture media outside production source sets +and remove every capture-only hook before committing. Compose image rows may +remember a decoded bitmap by file path. After replacing the bytes at an +unchanged path, relaunch the app or use a new destination path before judging +the revised crop. + +## Capture from the real app + +Build and install the ABI-matching debug APK, satisfy onboarding and +permissions, inject the fixture, and navigate to the intended surface. + +Capture directly: + +```sh +adb -s "$ANDROID_README_SERIAL" exec-out screencap -p > "$ARTIFACT_PATH" +``` + +Inspect the full screenshot immediately. Check message count and order, +nickname ownership, peer count, waveform variety, image visibility, globe +center, grid precision, clipping, and composer placement. + +Inject a complete timeline in one operation and allow at least two seconds of +quiet UI time after the fixture reports success. This avoids capturing entry +animations, incomplete placement, or media that has not finished decoding. + +Crop only Android system chrome. Preserve Bitchat's app header, translucent +overlap, content, and composer. Derive the crop from the observed status and +navigation insets; do not blindly reuse pixel offsets from a different profile. +Because the app renders edge-to-edge, app controls may extend into the reported +navigation inset. Place the bottom crop after the final control outline and +shadow but before the system gesture affordance; removing the entire inset can +clip the app itself. +Keep every final README screenshot in a matched portrait size. + +Use image inspection after the crop. File dimensions and a successful ADB +command do not prove that the desired composition is visible. + +## Update repository assets + +Discover the current README references before writing. Prefer stable paths under +`docs/screenshots/` and replace only the assets the user requested. + +When adding a showcase section: + +- keep the layout readable on GitHub; +- give every image meaningful alt text; +- use relative repository paths; +- avoid machine-generated cache files or capture sources; +- keep paired screenshots at identical dimensions. + +Run the bundled validator for every final asset: + +```sh +python3 \ + .agents/skills/android-readme-screenshot-studio/scripts/validate_readme_screenshots.py \ + --repo-root . \ + --readme README.md \ + --require-same-size \ + --asset docs/screenshots/readme-mesh-chat.png \ + --asset docs/screenshots/readme-geohash-globe.png +``` + +When replacing only one image in an existing pair, pass both the changed and +unchanged assets with `--require-same-size`, and verify the unchanged asset's +checksum. Pass only one asset and omit `--require-same-size` only when the +README has no paired screenshot to preserve. + +## Remove the fixture and verify cleanly + +Before committing: + +1. Remove temporary imports, commands, helpers, resources, and fixture media + with a focused patch. +2. Verify `git diff -- app/src/debug` is empty. +3. Verify `git status --short` lists only the intended README and screenshot + files. +4. Run `git diff --check`. +5. Run `./gradlew assembleDebug` after fixture removal. +6. Re-run the screenshot validator. +7. Confirm the user's original checkout is still untouched. + +The final commit must not contain synthetic peer data, generated photo sources, +audio clips, ADB outputs, emulator configuration, or local capture reports +unless the user separately requested those artifacts in the repository. + +## Record honest evidence + +Use the capture manifest and report format from +`../android-ui-visual-review/` when before/after evidence is useful. For a +README-only change with no production UI delta, identical before/after images +are acceptable when explicitly labeled “no production UI delta.” + +State limitations plainly: + +- a populated mesh timeline proves rendering, not physical message delivery; +- a voice row proves waveform rendering, not audio playback; +- an attachment proves image rendering, not media transfer; +- a globe proves picker state, not live location or relay behavior. + +## Commit, publish, and optionally merge + +GitHub writes require user authorization. When authorized: + +1. Stage only intended files. +2. Commit without overriding author or committer identity. +3. Push the `codex/` branch. +4. Use `gh pr create` or update the existing PR. +5. Describe the exact capture sequence, synthetic fixture disclosure, + repository-safe validation commands, and limitations. Keep emulator, + runtime, hardware, and local-environment facts out of GitHub text whenever + repository privacy rules classify them as machine identifiers. +6. Verify the PR head and checks with `gh pr view` and `gh pr checks`. +7. Merge only when the user explicitly requested it and required checks allow + it. Prefer the repository's normal merge strategy and use `gh`. +8. Verify the merged state and resulting `main` commit. + +When GitHub publication is not authorized, leave the finished commit or local +change in the isolated worktree and hand back its branch and artifact paths. +Do not silently push it. + +Do not place local paths, device selectors, generated-image paths, or personal +machine details in commits, PR text, comments, or merge messages. + +## Final handoff + +Lead with the outcome and include: + +- PR and merge URL or status; +- final screenshot paths and dimensions; +- one-line composition summary per surface; +- build and validator results; +- synthetic media disclosure; +- current CI state or merged commit; +- confirmation that the original checkout was not modified. diff --git a/.agents/skills/android-readme-screenshot-studio/evals/evals.json b/.agents/skills/android-readme-screenshot-studio/evals/evals.json new file mode 100644 index 00000000..9307b8a5 --- /dev/null +++ b/.agents/skills/android-readme-screenshot-studio/evals/evals.json @@ -0,0 +1,59 @@ +{ + "skill_name": "android-readme-screenshot-studio", + "evals": [ + { + "id": 1, + "prompt": "Refresh the Bitchat README with two polished screenshots from the latest main UI: a populated mesh chat and the geohash globe centered on the Middle East without zooming. Use a large high-resolution Android emulator, open a PR, and merge it after checks pass.", + "expected_output": "The agent works in a fresh latest-main worktree, tries the documented current-pair fast path before rediscovering the fixture, captures the real app on a verified large portrait emulator profile, uses deterministic synthetic state, centers the globe without changing whole-Earth scale, removes capture hooks, validates the assets, opens a PR, waits for checks, and merges only because the prompt explicitly authorizes it.", + "files": [], + "expectations": [ + "Uses a fresh worktree rooted at current origin/main and leaves the original checkout untouched.", + "Captures from the real Android UI on a verified high-resolution portrait emulator profile.", + "Uses the verified nine-row chat fixture and synthetic thky globe seed as fast starting points when the current UI still matches, while visually validating the result.", + "Waits for fixture completion and quiet UI time, and invalidates the media path cache when replacing image bytes at the same destination.", + "Keeps the requested Middle East focus while preserving whole-Earth zoom.", + "Removes temporary fixtures and passes a clean debug build before committing.", + "Uses gh for the authorized PR and merge without publishing machine identifiers." + ] + }, + { + "id": 2, + "prompt": "Replace only the README chat image. It should read top to bottom as: a photo, four short messages back and forth, three voice messages back and forth, then one thumbs-up. Keep the current globe exactly as-is and make the speech waveforms look natural.", + "expected_output": "The agent treats the requested order and counts as exact, asks or resolves the photo subject instead of inventing it, uses the real waveform extractor on three distinct speech clips, preserves the globe asset, visually inspects the final crop, and leaves only the chat PNG changed.", + "files": [], + "expectations": [ + "Implements exactly one photo, four alternating texts, three alternating voice notes, and one final thumbs-up.", + "Does not infer a photo subject from nicknames or unrelated fixture copy.", + "Produces waveforms from real rights-safe speech audio through the app extractor rather than drawing bars.", + "Preserves the globe asset and verifies that only the chat screenshot remains in the diff.", + "Removes the debug fixture before the final clean build." + ] + }, + { + "id": 3, + "prompt": "Show me before and after screenshots for the Compose header changes in PR #412 at 320 dp and 411 dp, and post the comparison as a PR comment.", + "expected_output": "The agent recognizes that this is visual regression evidence rather than README marketing capture and routes the task to android-ui-visual-review instead of applying the README screenshot workflow.", + "files": [], + "expectations": [ + "Routes the request to android-ui-visual-review.", + "Does not replace README assets.", + "Uses the PR merge-base and paired before/after capture workflow.", + "Publishes only the requested PR comment rather than opening an unrelated screenshot PR." + ] + }, + { + "id": 4, + "prompt": "The bottom of one or both README showcase screenshots is cropped. Correct the images without changing their content, update the screenshot skill so this does not recur, and open a new PR.", + "expected_output": "The agent inspects both merged assets at full size, identifies every affected screen, recaptures from the real app with the existing deterministic fixtures, and treats the navigation inset as an inspection region rather than an automatic crop. The final crop preserves complete app controls while excluding the system gesture affordance, keeps the pair matched, adds a reusable edge-to-edge guardrail to the skill, validates and visually inspects both images, and opens a sanitized PR.", + "files": [], + "expectations": [ + "Inspects both screenshots and corrects every affected asset rather than assuming only one is cropped.", + "Recaptures the real app with the established deterministic synthetic content instead of padding or compositing the merged PNGs.", + "Uses accessibility bounds and full-size pixel inspection to place the bottom crop after complete app controls and before system gesture chrome.", + "Verifies the entire chat composer border and bottom padding plus every globe action-button corner and shadow are visible.", + "Keeps paired screenshots at identical dimensions and passes the screenshot validator and clean debug build.", + "Updates the skill with a general edge-to-edge crop rule and publishes no device or machine identifiers." + ] + } + ] +} diff --git a/.agents/skills/android-readme-screenshot-studio/references/showcase-recipes.md b/.agents/skills/android-readme-screenshot-studio/references/showcase-recipes.md new file mode 100644 index 00000000..b77979da --- /dev/null +++ b/.agents/skills/android-readme-screenshot-studio/references/showcase-recipes.md @@ -0,0 +1,271 @@ +# README showcase recipes + +Use these recipes as composition guidance, then adapt them to the user's exact +request and the current UI. The requested chronology and framing always win over +the examples. + +## Verified current-pair fast path + +Use this baseline first when refreshing the existing README mesh-chat and +geohash-globe pair without a material production UI change. It records a +known-good capture, not a permanent UI contract: verify entry points, visible +state, insets, and output dimensions on every run. + +### One-pass app preparation + +Build and install the ABI-matching debug APK. A generic `app-debug.apk` may not +exist when the project emits ABI splits, so resolve the installed emulator ABI +and select the matching output before searching for alternate build tasks. + +Use normal onboarding or a temporary debug preparation command to: + +- mark onboarding complete; +- set the synthetic nickname `trailhead`; +- select the production `ChatUiMode.Bubbles` presentation; +- grant only the runtime permissions needed to reach the surface; and +- keep BLE and Wi-Fi Aware debug transport disabled during deterministic + rendering. + +Launch the target Activity before injecting process-local state. A preparation +command may use the existing `PermissionManager` and `AppStateStore` APIs, but +must remain capture-only and be removed before the clean build. + +### Exact chat fixture used for the current pair + +Clear the in-memory showcase state, add four synthetic peers, and use the real +local mesh peer ID only to mark self-authored rows. Use `solace` for the remote +sender and insert these nine rows at one-minute intervals in one operation: + +| Order | Sender | Content | +|---|---|---| +| 1 | `trailhead` | mountain image | +| 2 | `solace` | `That view is unreal.` | +| 3 | `trailhead` | `Worth the climb.` | +| 4 | `solace` | `How's the signal up there?` | +| 5 | `trailhead` | `Still holding strong.` | +| 6 | `trailhead` | voice note | +| 7 | `solace` | voice note | +| 8 | `trailhead` | voice note | +| 9 | `solace` | `👍` | + +A fixed epoch such as `1767258000000` keeps ordering stable. The displayed +clock text is locale- and time-zone-dependent, so validate consistency rather +than promising a specific rendered hour. + +Reuse the existing rights-safe mountain subject when the brief has not changed. +A near-square source crop around 840×800 produced enough image height while +leaving the reaction above the composer. If the crop changes, relaunch the app +or change the cache destination path; the image row can retain the previous +bitmap when the path is reused. + +Generate three local, rights-safe speech clips with visibly different cadence, +then transcode them to the app's normal M4A/AAC path. This known-good synthetic +set used roughly 170, 220, and 145 words per minute: + +1. `The trail is clear. I can hear you.` +2. `Copy that. Sending one back now.` +3. `Perfect. The mesh is still holding strong.` + +Run each file through `AudioWaveformExtractor` and cache its 120-bin result via +`VoiceWaveformCache`. Wait for the fixture's structured success result, then +allow at least two additional seconds for Compose placement and media decoding +before capture. + +### Exact globe path used for the current pair + +Start the production, non-exported `GeohashPickerActivity` through a temporary +in-app debug command. Seed it with the explicitly synthetic geohash `thky`, +wait about three seconds for the camera to settle, then invoke the production +minus control three times with about one second between changes. The verified +result was precision 1 with label `#t`, the whole Earth visible, and the Arabian +Peninsula/Persian Gulf region beneath the center crosshair. + +Treat `thky` as a fast starting point, not a substitute for inspection. Reject +the result if geography, camera distance, grid, label, or controls differ from +the brief. Never source the seed from device location, IP-derived location, or +account data. + +### Capture and crop baseline + +Capture the full screen only after the UI has been still for at least two +seconds. Re-observe the status and navigation insets, then remove only those +bands while preserving all app UI. Reuse prior offsets only when the local +profile and measured insets still match. + +Keep both outputs at identical dimensions. Run the screenshot validator and +inspect both images at full size. Keep raw captures, generated audio, fixture +media, profile facts, and device output local; only the final PNG assets belong +in the repository. + +## Mesh-chat showcase + +### Visual goal + +Make the screen read as a real conversation at a glance: + +1. one strong media anchor near the top; +2. a short text exchange with alternating senders; +3. a compact voice-note exchange with visibly different speech envelopes; +4. a small final reaction or acknowledgement; +5. the app header and composer framing the timeline. + +Avoid stuffing every supported feature into one frame. The screenshot should +show capability through hierarchy, not through maximum item count. + +### Known-good fixture shape + +For a request like “photo, messages, three voices, thumbs-up,” use exactly: + +| Order | Type | Sender | +|---|---|---| +| 1 | Image | self or remote, according to the story | +| 2 | Short text | other sender | +| 3 | Short text | alternating sender | +| 4 | Short text | alternating sender | +| 5 | Short text | alternating sender | +| 6 | Voice note | sender A | +| 7 | Voice note | sender B | +| 8 | Voice note | sender A | +| 9 | `👍` | sender B | + +Keep copy conversational and concise. Use synthetic names and avoid real +locations, contacts, identities, or sensitive content. + +For public mesh rendering, pass the current mesh peer ID as `senderPeerID` on +self-authored messages. This exercises the same ownership and color path as +production. Alternate sender IDs so every back-and-forth row renders its sender +header instead of being grouped away. + +### Photo framing + +A portrait or tall crop can let the latest rows stay visible while the older +photo slides partially behind Bitchat's translucent header. This is visually +useful only when the photo subject remains legible. + +- Preserve the app's own rounded image treatment. +- Do not bake UI chrome into the photo. +- Avoid important content under the header overlap. +- Do not select a photo subject from nicknames alone. +- If generating a synthetic photo, obtain or infer subject approval first, + disclose generation, and keep the source outside the final commit unless + requested. + +### Natural voice rows + +Prepare three short speech clips with different durations, pauses, and cadence. +Locally available offline TTS plus an audio transcoder is sufficient. Use the +same audio format the app normally records or plays, such as M4A. + +After copying the files into an app-readable location, create +`BitchatMessageType.Audio` messages that point to those actual files. Let +`AudioWaveformExtractor` and `VoiceWaveformCache` produce the bars. + +Reject the capture when: + +- all three envelopes look identical; +- the bars are uniform or sinusoidal rather than speech-like; +- duration labels are missing or implausible; +- a waveform is clipped by the screen edge; +- a temporary progress or cancel state is visible. + +### Layout tuning + +Inject the complete fixture in one operation so the list adopts it as history +instead of animating rows during capture. Let the reverse-layout list settle at +the newest message. + +If the oldest photo is not partially visible, prefer changing its aspect ratio +or the number of short text rows over manually scrolling to an unstable offset. +If the newest reaction falls behind the composer, shorten earlier content or +reduce media height. Do not crop app content to solve a fixture problem. + +## Geohash globe showcase + +### Visual goal + +Show: + +- the entire Earth; +- a readable geohash grid; +- the selected coarse cell; +- the requested geographic focus beneath the selection crosshair; +- the picker hint and precision controls. + +Use the current picker Activity and renderer. Do not composite a globe or grid +outside the app. + +### Focus without zoom + +When a user says “focus on the Middle East, no zoom”: + +1. open or seed the picker at a geohash centered on the requested area; +2. allow the globe to center on that location; +3. reduce precision to the same coarsest whole-Earth level used for the + showcase; +4. preserve camera distance while confirming the center moved; +5. capture with the whole globe still visible. + +The exact seed may change with picker implementation. Validate against visible +geography and the selected geohash label rather than assuming the seed worked. + +Reject the capture when: + +- the requested region is off-center; +- Earth is clipped; +- reducing precision also changed the camera distance against the brief; +- the selected cell or crosshair is illegible; +- controls overlap the globe; +- stale system bars remain in the final README asset. + +## Crop and output + +Capture the full physical screen first. Determine the status-bar and +navigation-bar insets from the current profile, then crop those insets only. + +Treat the navigation inset as an inspection region, not an automatic crop +amount. Bitchat uses edge-to-edge layout, so the composer's border, globe-button +corners, or their shadows may occupy part of that region. Use accessibility +bounds plus full-size pixel inspection to place the crop below every app +control and above the system gesture affordance. + +Treat previously observed crop offsets as local evidence, not as a universal +rule or repository documentation. Re-measure when the profile changes. + +Every paired README image should: + +- be a valid PNG; +- share width and height; +- retain the app header and composer or controls; +- exclude Android status and gesture/navigation chrome; +- remain sharp at GitHub's rendered width. + +## Acceptance checklist + +### Chat + +- [ ] Media subject matches the user's brief. +- [ ] Message chronology exactly matches the requested sequence. +- [ ] Sender ownership and alternation are correct. +- [ ] Voice-note count is exact. +- [ ] Waveforms were extracted from real speech audio and look distinct. +- [ ] Final reaction is visible above the composer. +- [ ] Header peer count and nickname are synthetic and intentional. +- [ ] The complete composer border and bottom padding are visible. + +### Globe + +- [ ] Requested region is centered. +- [ ] Zoom level matches the brief. +- [ ] Whole Earth and grid are visible. +- [ ] Selected cell and crosshair are legible. +- [ ] Hint and precision controls are unobstructed. +- [ ] Every action-button corner and shadow is fully visible. + +### Repository + +- [ ] Only requested README and PNG assets remain in the diff. +- [ ] Temporary debug fixture and media are removed. +- [ ] Final clean debug build passes. +- [ ] Screenshot validator passes. +- [ ] PR text contains no machine or personal identifiers. +- [ ] Static-capture limitations are disclosed. diff --git a/.agents/skills/android-readme-screenshot-studio/scripts/validate_readme_screenshots.py b/.agents/skills/android-readme-screenshot-studio/scripts/validate_readme_screenshots.py new file mode 100755 index 00000000..f5846f1b --- /dev/null +++ b/.agents/skills/android-readme-screenshot-studio/scripts/validate_readme_screenshots.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Validate high-resolution README screenshot assets without external packages.""" + +from __future__ import annotations + +import argparse +import json +import struct +import sys +from pathlib import Path + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def fail(message: str) -> None: + raise ValueError(message) + + +def png_dimensions(path: Path) -> tuple[int, int]: + with path.open("rb") as handle: + header = handle.read(24) + if len(header) < 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR": + fail(f"{path.name} is not a valid PNG with an IHDR header") + width, height = struct.unpack(">II", header[16:24]) + if width <= 0 or height <= 0: + fail(f"{path.name} has invalid dimensions: {width}x{height}") + return width, height + + +def resolve_inside(root: Path, value: Path, label: str) -> tuple[Path, Path]: + candidate = value if value.is_absolute() else root / value + resolved = candidate.resolve() + try: + relative = resolved.relative_to(root) + except ValueError as exc: + raise ValueError(f"{label} must stay inside the repository") from exc + if not resolved.is_file(): + fail(f"{label} does not exist: {relative.as_posix()}") + return resolved, relative + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Validate README PNG references, dimensions, and repository-safe paths." + ) + parser.add_argument("--repo-root", type=Path, default=Path(".")) + parser.add_argument("--readme", type=Path, default=Path("README.md")) + parser.add_argument("--asset", action="append", type=Path, required=True) + parser.add_argument("--min-width", type=int, default=1080) + parser.add_argument("--min-height", type=int, default=1920) + parser.add_argument("--require-same-size", action="store_true") + args = parser.parse_args() + + root = args.repo_root.resolve() + if not root.is_dir(): + fail("repository root does not exist") + if args.min_width <= 0 or args.min_height <= 0: + fail("minimum dimensions must be positive") + + readme_path, readme_relative = resolve_inside(root, args.readme, "README") + readme_text = readme_path.read_text(encoding="utf-8") + + assets: list[dict[str, object]] = [] + seen: set[Path] = set() + dimensions: set[tuple[int, int]] = set() + + for index, value in enumerate(args.asset): + asset_path, relative = resolve_inside(root, value, f"asset[{index}]") + if asset_path in seen: + fail(f"duplicate asset: {relative.as_posix()}") + seen.add(asset_path) + + if asset_path.suffix.lower() != ".png": + fail(f"README screenshot must be a PNG: {relative.as_posix()}") + if relative.parts[:2] != ("docs", "screenshots"): + fail( + "README screenshots must live under docs/screenshots: " + f"{relative.as_posix()}" + ) + + reference = relative.as_posix() + if reference not in readme_text and f"./{reference}" not in readme_text: + fail(f"README does not reference asset: {reference}") + + width, height = png_dimensions(asset_path) + if width < args.min_width or height < args.min_height: + fail( + f"{reference} is below the high-resolution minimum: " + f"{width}x{height} < {args.min_width}x{args.min_height}" + ) + dimensions.add((width, height)) + assets.append({"path": reference, "width": width, "height": height}) + + if args.require_same_size and len(dimensions) != 1: + rendered = ", ".join(f"{width}x{height}" for width, height in sorted(dimensions)) + fail(f"README screenshots do not share one size: {rendered}") + + print( + json.dumps( + { + "status": "ok", + "readme": readme_relative.as_posix(), + "assets": assets, + "same_size": len(dimensions) == 1, + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, UnicodeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) + diff --git a/.agents/skills/android-ui-visual-review/SKILL.md b/.agents/skills/android-ui-visual-review/SKILL.md new file mode 100644 index 00000000..5468f120 --- /dev/null +++ b/.agents/skills/android-ui-visual-review/SKILL.md @@ -0,0 +1,255 @@ +--- +name: android-ui-visual-review +description: Analyze an Android pull request, branch, commit, or patch for user-visible changes and produce reproducible before/after screenshots from isolated builds. Use this skill whenever a user asks for PR screenshots, branch UI comparisons, visual regression evidence, Compose before/after captures, populated message-state screenshots, responsive/theme/locale comparisons, or GitHub comments containing Android UI evidence—even if they only say “show me what changed.” Also use it to determine and document that a suspected UI PR has no visual delta. Do not use it for implementing a new UI, ordinary code review without visual evidence, or physical mesh validation. +compatibility: Requires git, the Android SDK and emulator, adb, Java/Gradle, Python 3, and gh for pull-request resolution or publishing. +--- + +# Android UI Visual Review + +Turn a PR or branch into trustworthy visual evidence. The comparison is useful +only when the before and after builds use the correct commits, Android runtime, +viewport, app state, and navigation path. + +## Collect the two user choices + +Before starting, resolve: + +1. **Target** — a PR URL/number or a branch/commit. If it is missing, ask for it. + For a branch, also ask for the intended base when it cannot be inferred + safely; otherwise default to the repository's `main`. +2. **Publishing** — for a PR target, ask whether the final screenshots and + findings should stay local or be posted as a PR comment. Do not perform any + GitHub write unless the user explicitly chooses publishing. A prior explicit + request such as “post these to the PR” already answers this question. + +Do not block on publishing preference while doing read-only analysis if the user +has not answered yet. Keep the local workflow useful on its own. + +## Read the routed guidance + +- Read [references/analysis-playbook.md](references/analysis-playbook.md) for + every run. It explains how to map a diff to screens, states, and a capture + matrix. +- Read [references/fixture-recipes.md](references/fixture-recipes.md) whenever + the affected screen needs messages, peers, channels, nicknames, settings, + permissions, locale, theme, onboarding state, or another non-empty fixture. +- Read [references/github-publishing.md](references/github-publishing.md) only + when the user has opted into a PR comment. + +## Create an isolated review session + +Never switch the user's active checkout between before and after revisions. +Run from the repository root: + +```sh +.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \ + --target "" +``` + +For a branch with a non-default base: + +```sh +.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \ + --target "" \ + --base "" +``` + +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----.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 \ + "/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 + diff --git a/.agents/skills/android-ui-visual-review/assets/capture-manifest.example.json b/.agents/skills/android-ui-visual-review/assets/capture-manifest.example.json new file mode 100644 index 00000000..3d8c48d3 --- /dev/null +++ b/.agents/skills/android-ui-visual-review/assets/capture-manifest.example.json @@ -0,0 +1,32 @@ +{ + "target": "PR #000 or branch-name", + "before_sha": "40-character merge-base SHA", + "after_sha": "40-character target SHA", + "environment": { + "android_release": "stable release number", + "android_sdk": "API number", + "security_patch": "YYYY-MM-DD", + "resolution_px": "1080x1920", + "default_density_dpi": 420 + }, + "captures": [ + { + "id": "surface-state-411dp-light", + "surface": "Human-readable surface", + "state": "Synthetic fixture and navigation state", + "expected_change": "Specific visual hypothesis", + "before": "before-surface-state-411dp-light.png", + "after": "after-surface-state-411dp-light.png" + } + ], + "extras": [ + { + "id": "after-expanded-menu", + "role": "after-detail", + "file": "after-expanded-menu.png" + } + ], + "limitations": [ + "Static screenshots do not prove hardware behavior." + ] +} diff --git a/.agents/skills/android-ui-visual-review/assets/pr-comment-template.md b/.agents/skills/android-ui-visual-review/assets/pr-comment-template.md new file mode 100644 index 00000000..5aa9219d --- /dev/null +++ b/.agents/skills/android-ui-visual-review/assets/pr-comment-template.md @@ -0,0 +1,20 @@ + +## 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. + diff --git a/.agents/skills/android-ui-visual-review/evals/evals.json b/.agents/skills/android-ui-visual-review/evals/evals.json new file mode 100644 index 00000000..d617b71b --- /dev/null +++ b/.agents/skills/android-ui-visual-review/evals/evals.json @@ -0,0 +1,41 @@ +{ + "skill_name": "android-ui-visual-review", + "evals": [ + { + "id": 1, + "prompt": "Review PR #813 and make before/after screenshots of its Android UI changes. Keep everything local; do not comment on GitHub.", + "expected_output": "The agent creates an isolated worktree, compares the PR head with its actual merge-base, discovers the header width thresholds, captures a wide control plus the changed compact widths on the newest stable Android emulator, and produces a validated local report without any GitHub write.", + "files": [], + "expectations": [ + "Uses the actual PR merge-base rather than current main or head~1.", + "Creates a capture matrix that includes a wide control and every changed width threshold.", + "Populates joined-channel state so the conditional count is visible before testing degradation.", + "Keeps artifacts local and performs no GitHub write." + ] + }, + { + "id": 2, + "prompt": "Compare branch feature/message-redesign against main. The empty chat will not show the change, so give me populated light and dark screenshots and also show any fresh-install nickname change.", + "expected_output": "The agent traces message rendering and nickname generation, adds a temporary deterministic debug-only fixture with received/self short/wrapped messages, applies equivalent state to both builds, captures light/dark and fresh nickname states, removes the fixture, and validates paired PNGs.", + "files": [], + "expectations": [ + "Uses synthetic deterministic messages with the real local peer ID for self classification.", + "Uses one fixed fixture epoch and identical logical data in both builds.", + "Captures both light and dark populated message states plus the fresh nickname state.", + "Removes temporary debug fixture changes before handoff." + ] + }, + { + "id": 3, + "prompt": "This hotspot PR looks UI-related, but inspect it rather than assuming. Capture before/after and, after you show the evidence, post the screenshots and findings to the PR.", + "expected_output": "The agent determines whether the diff actually changes UI, captures the nearest affected hotspot surface even if equality is expected, labels behavioral limitations, asks or recognizes explicit publishing authorization, uploads safe screenshots without a source branch, posts through gh, and verifies the comment.", + "files": [], + "expectations": [ + "Does not invent a visual delta when the diff is backend-only.", + "Labels the screenshots as an expected no-visual-change comparison.", + "Does not claim that a static screenshot proves the hotspot ownership race.", + "Posts only after explicit authorization and verifies image count and comment URL." + ] + } + ] +} diff --git a/.agents/skills/android-ui-visual-review/references/analysis-playbook.md b/.agents/skills/android-ui-visual-review/references/analysis-playbook.md new file mode 100644 index 00000000..c70c6b59 --- /dev/null +++ b/.agents/skills/android-ui-visual-review/references/analysis-playbook.md @@ -0,0 +1,152 @@ +# Diff-to-screen analysis playbook + +Use this reference to turn a code diff into a minimal but complete screenshot +matrix. + +## Resolve the correct comparison + +For a PR, “before” is the merge-base of the fetched PR head and its configured +base branch. It is not necessarily the PR's current `baseRefOid`, the local +`main`, or the commit immediately preceding the head. + +For a branch or commit, confirm the intended base. Compute the merge-base after +fetching both sides. + +Record both SHAs before any checkout. Build each SHA rather than attempting to +reverse selected files on a single build. + +## Triage the diff in layers + +Start broad: + +```sh +git diff --stat "$BEFORE_SHA" "$AFTER_SHA" +git diff --name-status "$BEFORE_SHA" "$AFTER_SHA" +git diff "$BEFORE_SHA" "$AFTER_SHA" -- app/src/main app/src/debug +``` + +Then classify changed files. + +| Diff area | Likely visual impact | +|---|---| +| `ui/*.kt`, composables, modifiers, layouts | Direct screen/layout change | +| `ui/theme/*`, colors, shapes, typography | Cross-screen theme change | +| `res/values*`, drawables, mipmaps | Text, locale, icon, launcher, or palette | +| Manifest locale/theme/activity metadata | System or activity presentation | +| `DataManager`, preferences, defaults | Fresh-install/default-state change | +| `AppStateStore`, ViewModel/state flows | UI changes only after specific state | +| Service/transport/backend only | Usually no static UI delta; trace exposed state | +| `src/debug` or Android tests | Fixture/test mechanism, not production UI | + +Do not stop at filenames. Search every changed public symbol and resource: + +```sh +rg -n "" 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. + diff --git a/.agents/skills/android-ui-visual-review/references/fixture-recipes.md b/.agents/skills/android-ui-visual-review/references/fixture-recipes.md new file mode 100644 index 00000000..bfe82cbc --- /dev/null +++ b/.agents/skills/android-ui-visual-review/references/fixture-recipes.md @@ -0,0 +1,209 @@ +# Deterministic UI fixture recipes + +Read current code before applying any recipe. These patterns intentionally use +the debug-only test-hook path and should be adapted to the APIs present at both +comparison SHAs. + +## Fixture principles + +1. Exercise the UI's real state classification. A self message must carry the + peer identity that production code uses to decide `isSelf`; changing only the + displayed sender nickname can produce a false layout. +2. Use the same logical records in both builds: stable IDs, sender IDs, content, + ordering, and one fixed epoch. +3. Keep content synthetic and review-safe. Do not use real messages, contacts, + peer IDs, channel memberships, device information, or locations. +4. Return structured success data and verify it before capturing. +5. Keep additions under `app/src/debug`; remove them after capture. + +## Prefer the existing test hook + +Inspect: + +- `app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt` +- `app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt` +- `app/src/debug/AndroidManifest.xml` + +The receiver accepts: + +```sh +adb -s "$ANDROID_REVIEW_SERIAL" shell am broadcast \ + -n com.bitchat.droid/com.bitchat.android.testhook.TestHookReceiver \ + -a com.bitchat.droid.TEST_HOOK \ + --es cmd "" \ + --es 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/.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. + diff --git a/.agents/skills/android-ui-visual-review/references/github-publishing.md b/.agents/skills/android-ui-visual-review/references/github-publishing.md new file mode 100644 index 00000000..5eacd2e0 --- /dev/null +++ b/.agents/skills/android-ui-visual-review/references/github-publishing.md @@ -0,0 +1,97 @@ +# Publishing screenshots to a pull request + +Follow this only after the user explicitly opts into a GitHub comment. + +## Prepare a safe comment + +The comment should contain: + +- a unique hidden marker for idempotent verification +- verified Android release/API +- statement that before is the target's actual merge-base +- concise visual findings +- paired before/after image tables +- fixture disclosure when artificial state was used +- limitations and intentional non-changes + +Do not include: + +- local paths or usernames +- ADB serials, device names, peer IDs, addresses, or IPs +- real messages, contacts, locations, or account information +- build logs +- claims about physical behavior that screenshots do not prove + +Scan the body locally for machine paths and selectors before posting. +Use [../assets/pr-comment-template.md](../assets/pr-comment-template.md) as the +starting structure, replacing every uppercase placeholder and adding one table +per comparison state. + +## Upload image bytes without changing a source branch + +Native `gh pr comment` accepts Markdown but not binary files. This skill bundles +an uploader that uses `gh api` to place images on a PR-scoped custom Git ref: + +```sh +python3 \ + .agents/skills/android-ui-visual-review/scripts/upload_pr_images.py \ + --repo OWNER/REPO \ + --pr NUMBER \ + +``` + +The helper: + +- uploads only image bytes and basenames +- writes to `refs/uploads/issues/`, 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 \ + +``` + +## 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 "" +``` + +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. diff --git a/.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh b/.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh new file mode 100755 index 00000000..e0cdb4d8 --- /dev/null +++ b/.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Create an isolated Android UI review worktree. + +Usage: + create_review_worktree.sh --target [options] + +Options: + --base Base for a branch/commit target (default: main) + --repo-root 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" diff --git a/.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py b/.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py new file mode 100755 index 00000000..3289e7bc --- /dev/null +++ b/.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Upload PR screenshots through gh api without modifying a source branch.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import re +import subprocess +import sys +from pathlib import Path +from urllib.parse import quote + +REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + + +def gh_api( + endpoint: str, + *, + method: str = "GET", + payload: dict[str, object] | None = None, + allow_not_found: bool = False, +) -> dict[str, object] | None: + command = ["gh", "api"] + if method != "GET": + command.extend(["-X", method]) + command.append(endpoint) + input_text = None + if payload is not None: + command.extend(["--input", "-"]) + input_text = json.dumps(payload) + + result = subprocess.run( + command, + input=input_text, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + if allow_not_found and "HTTP 404" in result.stderr: + return None + raise RuntimeError(result.stderr.strip() or f"gh api failed for {endpoint}") + if not result.stdout.strip(): + return {} + parsed = json.loads(result.stdout) + if not isinstance(parsed, dict): + raise RuntimeError(f"unexpected gh api response for {endpoint}") + return parsed + + +def validate_files(paths: list[Path]) -> list[tuple[Path, str, int]]: + if not paths: + raise ValueError("at least one image file is required") + files: list[tuple[Path, str, int]] = [] + seen: set[str] = set() + for path in paths: + resolved = path.resolve() + if not resolved.is_file(): + raise ValueError(f"file not found: {path}") + name = resolved.name + if name in seen: + raise ValueError(f"duplicate basename would collide in upload: {name}") + seen.add(name) + if resolved.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: + raise ValueError(f"unsupported image type: {name}") + files.append((resolved, name, resolved.stat().st_size)) + return files + + +def image_markdown(files: list[dict[str, str]]) -> str: + sections: list[str] = [] + for index in range(0, len(files), 2): + pair = files[index : index + 2] + labels = " | ".join(item["name"] for item in pair) + separators = " | ".join("---" for _ in pair) + images = " | ".join( + f'![{item["name"]}]({item["url"]})' for item in pair + ) + sections.append(f"| {labels} |\n| {separators} |\n| {images} |") + return "\n\n".join(sections) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True, help="OWNER/REPO") + parser.add_argument("--pr", required=True, type=int) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("files", nargs="+", type=Path) + args = parser.parse_args() + + if not REPO_RE.fullmatch(args.repo): + raise ValueError("--repo must use OWNER/REPO") + if args.pr <= 0: + raise ValueError("--pr must be a positive integer") + files = validate_files(args.files) + + if args.dry_run: + print( + json.dumps( + { + "status": "dry-run", + "repo": args.repo, + "pr": args.pr, + "ref": f"refs/uploads/issues/{args.pr}", + "files": [ + {"name": name, "size": size} for _, name, size in files + ], + }, + indent=2, + ) + ) + return 0 + + api_prefix = f"repos/{args.repo}" + ref_path = f"uploads/issues/{args.pr}" + ref_response = gh_api( + f"{api_prefix}/git/ref/{ref_path}", + allow_not_found=True, + ) + + parent_sha = "" + base_tree_sha = "" + if ref_response is not None: + ref_object = ref_response.get("object") + if not isinstance(ref_object, dict) or not isinstance(ref_object.get("sha"), str): + raise RuntimeError("existing upload ref response did not contain a commit SHA") + parent_sha = str(ref_object["sha"]) + parent_commit = gh_api(f"{api_prefix}/git/commits/{parent_sha}") + tree = parent_commit.get("tree") if parent_commit else None + if not isinstance(tree, dict) or not isinstance(tree.get("sha"), str): + raise RuntimeError("existing upload commit did not contain a tree SHA") + base_tree_sha = str(tree["sha"]) + + entries: list[dict[str, str]] = [] + for path, name, _ in files: + content = base64.b64encode(path.read_bytes()).decode("ascii") + blob = gh_api( + f"{api_prefix}/git/blobs", + method="POST", + payload={"content": content, "encoding": "base64"}, + ) + blob_sha = blob.get("sha") if blob else None + if not isinstance(blob_sha, str): + raise RuntimeError(f"blob upload did not return a SHA for {name}") + entries.append( + {"path": name, "mode": "100644", "type": "blob", "sha": blob_sha} + ) + + tree_payload: dict[str, object] = {"tree": entries} + if base_tree_sha: + tree_payload["base_tree"] = base_tree_sha + tree_response = gh_api( + f"{api_prefix}/git/trees", + method="POST", + payload=tree_payload, + ) + tree_sha = tree_response.get("sha") if tree_response else None + if not isinstance(tree_sha, str): + raise RuntimeError("tree creation did not return a SHA") + + commit_response = gh_api( + f"{api_prefix}/git/commits", + method="POST", + payload={ + "message": f"Add visual comparison screenshots for PR #{args.pr}", + "tree": tree_sha, + "parents": [parent_sha] if parent_sha else [], + }, + ) + commit_sha = commit_response.get("sha") if commit_response else None + if not isinstance(commit_sha, str): + raise RuntimeError("commit creation did not return a SHA") + + if parent_sha: + gh_api( + f"{api_prefix}/git/refs/{ref_path}", + method="PATCH", + payload={"sha": commit_sha, "force": False}, + ) + else: + gh_api( + f"{api_prefix}/git/refs", + method="POST", + payload={"ref": f"refs/{ref_path}", "sha": commit_sha}, + ) + + uploaded: list[dict[str, str]] = [] + for _, name, _ in files: + url = f"https://github.com/{args.repo}/blob/{commit_sha}/{quote(name)}?raw=true" + uploaded.append({"name": name, "url": url}) + + print( + json.dumps( + { + "status": "ok", + "repo": args.repo, + "pr": args.pr, + "ref": f"refs/{ref_path}", + "sha": commit_sha, + "files": uploaded, + "markdown": image_markdown(uploaded), + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) + diff --git a/.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py b/.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py new file mode 100755 index 00000000..6badd0c6 --- /dev/null +++ b/.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Validate a visual-review capture manifest and its PNG evidence.""" + +from __future__ import annotations + +import argparse +import json +import re +import struct +import sys +from pathlib import Path + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def fail(message: str) -> None: + raise ValueError(message) + + +def relative_file(root: Path, value: object, field: str) -> Path: + if not isinstance(value, str) or not value: + fail(f"{field} must be a non-empty relative path") + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + fail(f"{field} must stay relative to the artifact directory: {value!r}") + resolved = (root / candidate).resolve() + try: + resolved.relative_to(root.resolve()) + except ValueError as exc: + raise ValueError(f"{field} escapes the artifact directory: {value!r}") from exc + if not resolved.is_file(): + fail(f"{field} does not exist: {value}") + return resolved + + +def png_dimensions(path: Path) -> tuple[int, int]: + with path.open("rb") as handle: + header = handle.read(24) + if len(header) < 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR": + fail(f"not a valid PNG with an IHDR header: {path.name}") + width, height = struct.unpack(">II", header[16:24]) + if width <= 0 or height <= 0: + fail(f"invalid PNG dimensions in {path.name}: {width}x{height}") + return width, height + + +def require_string(data: dict[str, object], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + fail(f"{key} must be a non-empty string") + return value + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + args = parser.parse_args() + + manifest_path = args.manifest.resolve() + if not manifest_path.is_file(): + fail(f"manifest not found: {manifest_path}") + root = manifest_path.parent + + with manifest_path.open(encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + fail("manifest root must be an object") + + require_string(data, "target") + before_sha = require_string(data, "before_sha") + after_sha = require_string(data, "after_sha") + if not SHA_RE.fullmatch(before_sha) or not SHA_RE.fullmatch(after_sha): + fail("before_sha and after_sha must be lowercase 40-character Git SHAs") + if before_sha == after_sha: + fail("before_sha and after_sha must differ") + + environment = data.get("environment") + if not isinstance(environment, dict): + fail("environment must be an object") + for key in ("android_release", "android_sdk", "security_patch", "resolution_px"): + require_string(environment, key) + density = environment.get("default_density_dpi") + if not isinstance(density, int) or density <= 0: + fail("environment.default_density_dpi must be a positive integer") + + captures = data.get("captures") + if not isinstance(captures, list) or not captures: + fail("captures must contain at least one before/after pair") + + used_paths: set[Path] = set() + pair_summaries: list[dict[str, object]] = [] + seen_ids: set[str] = set() + + for index, item in enumerate(captures): + if not isinstance(item, dict): + fail(f"captures[{index}] must be an object") + capture_id = require_string(item, "id") + if capture_id in seen_ids: + fail(f"duplicate capture id: {capture_id}") + seen_ids.add(capture_id) + require_string(item, "surface") + require_string(item, "state") + require_string(item, "expected_change") + + before_path = relative_file(root, item.get("before"), f"captures[{index}].before") + after_path = relative_file(root, item.get("after"), f"captures[{index}].after") + for path in (before_path, after_path): + if path in used_paths: + fail(f"screenshot reused by multiple manifest entries: {path.name}") + used_paths.add(path) + + before_size = png_dimensions(before_path) + after_size = png_dimensions(after_path) + if before_size != after_size: + fail( + f"{capture_id} dimensions differ: before={before_size[0]}x{before_size[1]}, " + f"after={after_size[0]}x{after_size[1]}" + ) + pair_summaries.append( + { + "id": capture_id, + "width": before_size[0], + "height": before_size[1], + } + ) + + extras = data.get("extras", []) + if not isinstance(extras, list): + fail("extras must be an array") + for index, item in enumerate(extras): + if not isinstance(item, dict): + fail(f"extras[{index}] must be an object") + extra_id = require_string(item, "id") + if extra_id in seen_ids: + fail(f"duplicate capture/extra id: {extra_id}") + seen_ids.add(extra_id) + require_string(item, "role") + path = relative_file(root, item.get("file"), f"extras[{index}].file") + if path in used_paths: + fail(f"screenshot reused by multiple manifest entries: {path.name}") + used_paths.add(path) + png_dimensions(path) + + limitations = data.get("limitations", []) + if not isinstance(limitations, list) or not all( + isinstance(item, str) and item.strip() for item in limitations + ): + fail("limitations must be an array of non-empty strings") + + result = { + "status": "ok", + "pairs": len(captures), + "extras": len(extras), + "png_files": len(used_paths), + "dimensions": pair_summaries, + } + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, json.JSONDecodeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) + diff --git a/.agents/skills/mesh-lab/SKILL.md b/.agents/skills/mesh-lab/SKILL.md new file mode 100644 index 00000000..40e14de3 --- /dev/null +++ b/.agents/skills/mesh-lab/SKILL.md @@ -0,0 +1,224 @@ +--- +name: mesh-lab +description: Run, diagnose, and extend bitchat Android Mesh Lab physical-device tests. Use this skill whenever work capable of changing physical peer behavior touches mesh discovery or routing, BLE or Wi-Fi transport, Noise/crypto/identity, foreground-service or power behavior, public or private messaging, file/media transfer, protocol packets, or fragmentation; whenever a user asks for physical-device validation, ADB test hooks, hardware regression reproduction, or a new Mesh Lab scenario; and before claiming that such changes work on real devices, even if the user does not name Mesh Lab explicitly. Do not use it for docs-only, unit-test-only, or pure UI changes that cannot affect mesh or service behavior. +--- + +# Mesh Lab + +Use the repository's debug-only ADB harness to validate mesh behavior on physical +devices. Treat it as a development integration test, not as the privacy-checked +release gate and not as proof about a release APK. + +## Establish current ground truth + +Run from the repository root. + +Before choosing commands or changing a scenario: + +1. Read the Mesh Lab section of `AGENTS.md`. +2. Read the "mesh lab" appendix in `docs/release-gate-runbook.md`. +3. Use `python3 tools/release_gate/mesh_lab.py --help` and the relevant + subcommand help. +4. Inspect the selected scenario function in `tools/release_gate/mesh_lab.py`; + its current CLI and assertions are authoritative if documentation has drifted. +5. Inspect + `app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt` before + using an ad-hoc command, diagnosing hook behavior, or extending coverage. + +Do not infer a physical pass from unit tests, compilation, old evidence, or a +successful local send call. + +## Decide the physical coverage + +Inspect the change or requested behavior first. Select the smallest scenario set +that exercises the affected physical contract, expanding to `all` for broad, +cross-cutting, or release-sensitive changes. + +| Affected behavior | Start with | +|---|---| +| Discovery, connection management, routing, foreground-service lifecycle | `broadcast`, `dm`, `session_recovery` | +| Background, doze, or power-duty-cycle behavior | Existing setup keeps devices awake and foregrounded; add a focused workflow or use the full release gate | +| Wi-Fi Aware or transport-selection behavior | Existing setup enables BLE and does not pin Wi-Fi; add a transport-specific control/assertion or use the full release gate | +| Noise, crypto, authenticated peer state, identity persistence | `dm`, `file_private`, `session_recovery`, `identity_reset` | +| Public messaging or message delivery | `broadcast`, then `dm` if shared routing changed | +| File/media encoding, transfer, fragmentation, admission limits | `file`, `file_private`, `file_oversize` | +| Packet parsing, bridge/routing, TTL, or protocol changes | `raw`, plus a receiving scenario such as `broadcast` or `dm` | +| Broad mesh or transport refactor | `all` | +| UI-only work with no service, state, or delivery effect | Usually no Mesh Lab run; explain why | + +When the selected scenarios do not exercise the new contract, add a focused +scenario instead of treating unrelated green tests as coverage. + +## Protect devices and evidence + +Mesh Lab setup is destructive to the app's local data. It force-stops the app, +clears package data, regenerates identity, cycles Bluetooth, grants permissions, +and changes wake/lock-screen timeout settings without restoring them. + +- Use only designated disposable lab app data and deterministic test content. +- Use two authorized physical Android BLE devices on API 26 or newer. Emulators + do not exercise the required BLE mesh behavior. +- Before `setup`, `identity_reset`, or `all`, confirm that the selected devices + may have bitchat app data cleared. `identity_reset` clears device B even when + setup was skipped. If the user has not already established authorization, ask. +- Never attempt to defeat a secure lock screen. Ask the operator to unlock it. +- Keep every device unlocked, awake, foregrounded, and preferably charging. +- Treat ADB selectors as ephemeral secrets. Do not put serials, device names, + peer IDs, addresses, fingerprints, local home paths, or raw logcat in commits, + pull requests, issues, or published artifacts. +- Write raw evidence under `/tmp`, keep it local, and never commit it. Failure + evidence can include unsanitized logcat and lab identifiers. +- Use debug APKs only. The exported test-hook receiver intentionally has no + production security boundary and must never be moved into `src/main`. +- Use an authorized, controlled lab area. After setup, inspect peer state + locally and stop if an unexpected peer is present before sending broadcasts, + files, or raw packets. +- On a non-dedicated device, record the prior Bluetooth, stay-awake, screen + timeout, and lock-screen-disabled settings locally. Restore only those + recorded values after the run, or tell the operator exactly what remains + changed. + +If the hardware, operator confirmation, or prerequisites are unavailable, +report the physical result as `blocked (not run)` and provide the exact handoff +command. Never soften this to "pass" or "probably works." + +## Run a two-phone batch + +Preflight the environment without copying device selectors into durable output: + +```sh +python3 --version +adb devices +./gradlew assembleDebug +``` + +Set up the disposable pair: + +```sh +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a "$MESH_SERIAL_A" \ + --serial-b "$MESH_SERIAL_B" \ + --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk +``` + +Build and pass the current-tree debug APK during normal use. If setup +intentionally omits `--apk`, first verify on both devices that the installed +package is debuggable via `run-as` and that its package dump contains +`TestHookReceiver`; do this before any command that clears data. + +Inspect `peers` on both devices after setup. Continue only when every discovered +participant belongs to the controlled lab. + +Run either the selected scenario or the full suite. Run this entire block in one +shell invocation so the temporary-directory variable cannot disappear between +agent shell calls. Abort the block if the directory is empty or missing before +passing it to `--out`; otherwise an empty path can put private evidence in the +repository. Replace `dm` with +`all` only when full-suite data clearing has been authorized. + +```sh +MESH_EVIDENCE_DIR="$(mktemp -d /tmp/meshlab-evidence.XXXXXX)" +if [ -z "$MESH_EVIDENCE_DIR" ] || [ ! -d "$MESH_EVIDENCE_DIR" ]; then + echo "mktemp failed; aborting so evidence cannot land in the repository" >&2 + exit 1 +fi +chmod 700 "$MESH_EVIDENCE_DIR" + +python3 tools/release_gate/mesh_lab.py scenario dm \ + --serial-a "$MESH_SERIAL_A" \ + --serial-b "$MESH_SERIAL_B" \ + --out "$MESH_EVIDENCE_DIR" +``` + +Rerun `setup` before a fresh scenario batch when prior churn, stale identities, +or zombie GATT links could contaminate the result. + +Keep the private evidence only as long as the active investigation needs it. +Do not delete failure evidence that the user still needs; when it is no longer +needed, remove it or move it to an explicitly approved protected location. + +## Interpret results precisely + +- `dm`, `broadcast`, `file`, and `file_private` include receiver-side assertions. +- `file` and `file_private` currently validate a 1 KB deterministic fixture and + SHA-256 integrity, not sustained or boundary-sized transfer performance. +- `file_oversize` validates sender rejection and receiver absence for a 512 KB + broadcast, not the exact 256/257-fragment boundary. +- `raw` proves that the local transport bridge accepted the injected packet. It + does not prove that another device received or accepted it. +- `session_recovery` proves recoverability after process death, but may fall + back to an explicit handshake; it does not prove a fully automatic recovery. +- `identity_reset` proves new-identity recovery after `pm clear`; stale state for + the old identity is diagnostic evidence rather than a purge assertion. +- The current CLI drives exactly two phones. It does not validate a three-hop + topology, transport-specific Wi-Fi Aware behavior, permission denial, doze, + endurance, resource bounds, transfer cancellation, release builds, or + cross-client compatibility. +- For ad-hoc commands, `status: ok` can mean the command completed without + satisfying the requested state. Inspect fields such as `reached_min_peers`, + `direct`, `established`, or `cancelled`. +- `all` runs scenarios sequentially on evolving device state. In the current + runner it writes combined evidence only, and a failed sub-scenario can abort + aggregation without clean structured evidence. Run selected scenarios + individually first when durable per-scenario evidence matters, then use + `all` as broader regression coverage. + +A scenario exits zero on pass and non-zero on failure. On failure, preserve the +local evidence, inspect its error first, then use state dumps and filtered logcat: + +```sh +python3 tools/release_gate/mesh_lab.py cmd \ + --serial "$MESH_SERIAL_A" state + +adb -s "$MESH_SERIAL_A" logcat -d -t 200 -s \ + TestHook MessageHandler FragmentManager BitchatFilePacket +``` + +Common first checks are screen/foreground state, mutual discovery, direct-peer +state, Noise session state, and stale Bluetooth connections. Rerun `setup` only +after preserving useful diagnostics. + +The generic `cmd --extra` wrapper does not encode every Android extra type +correctly: Boolean `enabled` and integer `min_peers`/`ttl` are notable cases. +Use a direct `adb shell am broadcast` with `--ez` or `--ei`, after reading the +driver, when exact types matter. Use the top-level `--timeout-ms 30000` option +for command timeouts; never pass `--extra timeout_ms=...`, because it duplicates +the runner's `timeout_ms` keyword and fails before dispatch. + +## Add a physical-device scenario + +Prefer extending `tools/release_gate/mesh_lab.py` with existing hook commands. +Add or change an Android hook only when the public mesh API cannot express the +required action or observation. + +Design the scenario around an observable remote contract: + +1. Generate a unique token or deterministic fixture so stale state cannot pass. +2. Start the receiver wait before sending. +3. Assert remote sender identity, content, session state, digest, or expected + absence—not merely that the sender accepted a call. +4. Use bounded timeouts and return structured JSON evidence. +5. For negative tests, assert both the expected sender error and that the + receiver did not observe the artifact. +6. Keep hook code and manifest registration under `src/debug`. +7. Update the runbook scenario table and troubleshooting guidance. +8. Run the new scenario individually, then run relevant neighboring scenarios + or `all` to detect state contamination. + +Do not add test-only branches to production mesh code merely to make a scenario +easy to drive. + +## Report the outcome + +End with a compact physical-test report: + +- Change or contract tested +- Device topology: logical roles only, such as phone A to phone B +- Build and scenario names +- Result: `pass`, `fail`, or `blocked (not run)` +- Local evidence directory, clearly marked private and uncommitted +- On failure: the exact violated invariant and the next diagnostic +- Coverage limits and any scenario fallback that weakens the claim + +Keep device selectors and raw evidence out of the report, commit, and pull +request. diff --git a/.agents/skills/mesh-lab/evals/evals.json b/.agents/skills/mesh-lab/evals/evals.json new file mode 100644 index 00000000..eb44aeed --- /dev/null +++ b/.agents/skills/mesh-lab/evals/evals.json @@ -0,0 +1,41 @@ +{ + "skill_name": "mesh-lab", + "evals": [ + { + "id": 1, + "prompt": "I changed Noise session recovery and authenticated peer-state handling. Validate it on real Android devices before we merge. I have not told you whether any disposable phones are connected.", + "expected_output": "The agent inspects the current Mesh Lab implementation, selects the Noise and churn scenarios, checks hardware and destructive-setup authorization, and reports blocked rather than inventing a pass when devices are unavailable.", + "files": [], + "expectations": [ + "Selects dm, file_private, session_recovery, and identity_reset, with any omission explicitly tied to the inspected change.", + "Recognizes that setup clears app data and requires disposable lab devices.", + "Does not run setup, identity_reset, or all without destructive authorization and reports blocked (not run) when hardware is unavailable.", + "Keeps device selectors and raw evidence out of durable artifacts." + ] + }, + { + "id": 2, + "prompt": "The broadcast fragment cap changed. Two unlocked disposable phones are attached over ADB. Run the relevant Mesh Lab coverage and give me a PR-safe summary.", + "expected_output": "The agent recognizes that the stock fixtures do not prove the exact fragment boundary, adds focused below/at/above-cap receiver-side coverage when the changed contract requires it, runs relevant existing file scenarios, saves private evidence in a protected temporary directory, and produces a sanitized summary.", + "files": [], + "expectations": [ + "Uses the debug APK and the repository Mesh Lab runner.", + "Runs file and file_oversize, and runs file_private only if the inspected cap is shared with private transfers.", + "Adds or requires below/at/above-cap receiver-side assertions instead of treating the stock 1 KB and 512 KB fixtures as exact-boundary proof.", + "Stores unsanitized evidence in a unique mode-0700 temporary directory and does not expose ADB selectors." + ] + }, + { + "id": 3, + "prompt": "I added a new routed acknowledgement packet. Existing mesh_lab scenarios do not prove that the second phone receives and processes it. Add appropriate physical-device coverage.", + "expected_output": "The agent extends the host scenario and only the minimum necessary debug hook, asserting a unique remote observation with bounded timeouts and structured evidence while keeping test code out of production sources.", + "files": [], + "expectations": [ + "Uses a unique receiver-side acknowledgement observation rather than treating raw_send success as end-to-end proof.", + "Starts the receiver wait before send and uses bounded timeouts with structured JSON evidence.", + "Keeps any hook and manifest changes under app/src/debug and updates scenario registration and documentation.", + "Handles unsupported device types or topologies explicitly rather than assuming parity." + ] + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..192d1390 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gradle +.reproducible-build +**/build +local.properties +tools/arti-build/.arti-source diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index b174f6f5..211e851b 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,29 +1,142 @@ -name: Android Build +name: Android CI on: + workflow_dispatch: push: - branches: - - '**' + branches: [main, develop] pull_request: - branches: - - '**' + branches: [main, develop] + +permissions: + contents: read + +env: + SETUP_JAVA_VERSION: 21.0.11+10.0.LTS jobs: - build: - runs-on: ubuntu-latest + verify: + name: Test and lint + runs-on: ubuntu-24.04 steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - name: Set up JDK 17 - uses: actions/setup-java@v3 + - name: Set up pinned JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: - java-version: '17' - distribution: 'temurin' + distribution: temurin + java-version: ${{ env.SETUP_JAVA_VERSION }} - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 + - name: Set up and validate Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - - name: Build with Gradle - run: ./gradlew build + - name: Verify native library inputs + run: tools/arti-build/verify-checksums.sh + + - name: Run unit tests + run: ./gradlew testDebugUnitTest + + - name: Run lint + run: ./gradlew lintDebug + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: test-reports + path: | + **/build/test-results/ + **/build/reports/tests/ + + - name: Upload lint results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: lint-results + path: "**/build/reports/lint-results-*.html" + + build-debug: + name: Build debug APK + runs-on: ubuntu-24.04 + needs: verify + + steps: + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up pinned JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: ${{ env.SETUP_JAVA_VERSION }} + + - name: Set up and validate Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Build debug APK + run: ./gradlew assembleDebug + + - name: Upload debug APK + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: Debug-apk + path: app/build/outputs/apk/debug/*.apk + + reproducible-build: + name: Reproducible release build ${{ matrix.replica }} + runs-on: ubuntu-24.04 + needs: verify + strategy: + fail-fast: false + matrix: + replica: [a, b] + + steps: + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up pinned JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: ${{ env.SETUP_JAVA_VERSION }} + + - name: Set up and validate Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-disabled: true + + - name: Build canonical unsigned release + run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}" + + - name: Upload build replica + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: reproducible-release-${{ matrix.replica }} + path: ${{ runner.temp }}/release-${{ matrix.replica }}/* + if-no-files-found: error + + compare-reproducible-builds: + name: Compare release bytes + runs-on: ubuntu-24.04 + needs: reproducible-build + + steps: + - name: Checkout verification script + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Download replica A + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: reproducible-release-a + path: ${{ runner.temp }}/release-a + + - name: Download replica B + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: reproducible-release-b + path: ${{ runner.temp }}/release-b + + - name: Compare every canonical byte + run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b" diff --git a/.github/workflows/fetch-georelays.yml b/.github/workflows/fetch-georelays.yml new file mode 100644 index 00000000..8f91c53d --- /dev/null +++ b/.github/workflows/fetch-georelays.yml @@ -0,0 +1,40 @@ +name: Fetch GeoRelays Data + +on: + schedule: + - cron: '0 6 * * 0' + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-relay-data: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Fetch GeoRelays + run: | + wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv + mv nostr_relays.csv ./app/src/main/assets/nostr_relays.csv + + - name: Check for changes + id: git-check + run: | + git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT + + - name: Commit and push changes + if: steps.git-check.outputs.changes == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add app/src/main/assets/nostr_relays.csv + git commit -m "Automated update of relay data - $(date -u)" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b3b8b7b..75c7ef31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,99 +2,118 @@ name: Release on: workflow_dispatch: + inputs: + tag: + description: Existing vX.Y.Z tag to build and attest + required: true + type: string push: tags: - - 'v*.*.*' # Triggers for tags like v1.0.0 + - "v*.*.*" + +permissions: + contents: read + +env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} jobs: build: - runs-on: ubuntu-latest + name: Build unsigned release ${{ matrix.replica }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + replica: [a, b] steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 17 - - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - with: - gradle-version: wrapper - - - name: Cache Gradle files - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Grant execute permission for Gradlew - run: chmod +x ./gradlew - - - name: Build APK - run: ./gradlew assembleRelease --no-daemon --stacktrace - - - name: List APK files + - name: Validate release tag run: | - echo "APK files built:" - find app/build/outputs/apk/release -name "*.apk" -type f + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "error: release tag must look like vX.Y.Z" >&2 + exit 1 + fi - - name: Rename APK - run: | - mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk - - - name: DEBUG - run: | - set -x - - pwd - - ls -all - - cd app/build/outputs/ - ls -all - tree - - # Optional: Sign APK (requires secrets) - # - name: Sign APK - # uses: r0adkll/sign-android-release@v1 - # with: - # releaseDirectory: app/build/outputs/apk/release - # signingKeyBase64: ${{ secrets.SIGNING_KEY }} - # alias: ${{ secrets.ALIAS }} - # keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} - # keyPassword: ${{ secrets.KEY_PASSWORD }} - - - name: Upload APK as artifact - uses: actions/upload-artifact@v4 + - name: Checkout tagged source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - name: bitchat-release-apk-${{ github.ref_name }} - path: app/build/outputs/apk/release/*.apk + ref: ${{ env.RELEASE_TAG }} + + - name: Verify workflow provenance ref + run: | + checked_out_commit="$(git rev-parse HEAD)" + if [ "$checked_out_commit" != "$GITHUB_SHA" ]; then + echo "error: run the workflow from the $RELEASE_TAG tag ref so provenance identifies the built commit" >&2 + exit 1 + fi + + - name: Validate the Gradle wrapper + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-disabled: true + + - name: Build canonical unsigned APKs and AAB + run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}" + + - name: Upload unsigned replica + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: unsigned-release-${{ matrix.replica }} + path: ${{ runner.temp }}/release-${{ matrix.replica }}/* retention-days: 30 if-no-files-found: error - release: + compare: + name: Verify reproducibility + runs-on: ubuntu-24.04 needs: build - runs-on: ubuntu-latest + permissions: + attestations: write + contents: read + id-token: write steps: - - name: Download APK artifact - uses: actions/download-artifact@v4 + - name: Checkout verification script + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - name: bitchat-release-apk-${{ github.ref_name }} - path: . + ref: ${{ env.RELEASE_TAG }} - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - name: Download replica A + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - files: bitchat.apk - name: Release ${{ github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + name: unsigned-release-a + path: ${{ runner.temp }}/release-a + + - name: Download replica B + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: unsigned-release-b + path: ${{ runner.temp }}/release-b + + - name: Compare every canonical byte + run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b" + + - name: Prepare public attestation subjects + run: | + mkdir "$RUNNER_TEMP/attestation-subjects" + cp "$RUNNER_TEMP/release-a/BUILDINFO.json" \ + "$RUNNER_TEMP/attestation-subjects/BITCHAT_BUILDINFO.json" + cp "$RUNNER_TEMP/release-a/SHA256SUMS.unsigned" \ + "$RUNNER_TEMP/attestation-subjects/BITCHAT_SHA256SUMS.unsigned" + cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.apk \ + "$RUNNER_TEMP/attestation-subjects/" + cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.aab \ + "$RUNNER_TEMP/attestation-subjects/" + + - name: Attest verified unsigned release + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: ${{ runner.temp }}/attestation-subjects/* + + - name: Upload verified unsigned release + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verified-unsigned-release + path: ${{ runner.temp }}/release-a/* + retention-days: 30 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index fcd3e1db..71a4f9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,13 @@ build/ !*/build/intermediates/ local.properties .gradle/ +.kotlin/ captures/ .externalNativeBuild/ debug_keystore/ *.keystore debug.keystore +app/release # Gradle /build/ @@ -39,15 +41,24 @@ dependency-reduced-pom.xml # Linters .lint/ +# Python test tooling +**/__pycache__/ +*.py[cod] +release-gate-results/ + # Other *.log .cxx/ -*build/ +# Gradle/Android build directories (but not tools/arti-build/) +**/build/ +!tools/arti-build/ out/ gen/ *~ *.swp *.lock +!tools/arti-build/Cargo.lock +.goosehints # Google services google-services.json @@ -55,3 +66,11 @@ google-services.json # Keystore files *.jks *.keystore + +# Arti build artifacts (cloned repo and Rust build cache) +tools/arti-build/.arti-source/ +tools/arti-build/target/ +.reproducible-build/ + +# JVM heap dumps (a Gradle daemon OOM drops these in the repo root) +*.hprof diff --git a/.java-version b/.java-version new file mode 100644 index 00000000..5e5d5c43 --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21.0.11 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8d9a2e44 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# Repository Guidelines + +## Privacy & External Disclosure (Mandatory) + +Protect the developer's privacy without exception. Treat all data from the +developer's computer, accounts, devices, workspace, and communications as +private unless the developer explicitly authorizes sharing a specific item +with a specific destination. + +- Never expose, publish, transmit, upload, paste, commit, or otherwise disclose + personally identifying or potentially identifying information. This includes + names, usernames, email addresses, account handles, home or workplace details, + local or absolute paths, drive or volume names, hostnames, device names and + serials, IP or MAC addresses, network names, peer IDs, messages, contacts, + tokens, keys, credentials, logs, metadata, and unique environment details. +- Never post any repository, workspace, device, account, conversation, log, or + developer data to GitHub, issue trackers, pull requests, comments, gists, + paste sites, analytics services, AI services, remote APIs, chat systems, or + any other external destination without the developer's explicit permission + for that exact data and destination. A general request to work on the + repository is not permission to disclose data. +- Keep local filesystem layout and computer information private. Do not include + absolute paths, usernames, home-directory names, mounted drives, shell + prompts, environment variables, installed-software inventories, hardware + details, or similar machine fingerprints in commits, patches, documentation, + screenshots, test fixtures, examples, issue text, PR text, or comments. +- Before any authorized external action, inspect and sanitize the exact content + being sent. Use repository-relative paths and neutral placeholders; remove + hidden metadata and redact unrelated or identifying content. When safe + sanitization cannot be guaranteed, do not send the data and ask the developer + how to proceed. +- Use synthetic, non-identifying test data only. Never use real messages, + contacts, account data, device identifiers, network information, keys, or + production logs in tests, examples, screenshots, fixtures, or bug reports. +- For geohash, GPS, map, location, proximity, or geofencing work, use only + clearly synthetic coordinates and geohashes that cannot reveal the + developer's location or routines. Never request, read, derive, record, use, + display, transmit, or retain live or historical location data from any + physical phone, watch, emulator image, computer, browser, account, or other + device connected to or accessible from the developer's computer. Never infer + location from IP addresses, networks, photos, logs, timestamps, nearby peers, + device metadata, or test output. Treat all location and movement data as + highly sensitive personal information and keep it out of commits, tests, + fixtures, screenshots, documentation, issues, pull requests, and external + services without exception. +- Keep command output, raw logs, crash dumps, screenshots, recordings, build + artifacts, and Mesh Lab evidence local. Review any derived summary for + identifying details before sharing it, even when external sharing has been + authorized. +- Do not weaken these protections for convenience, debugging, automation, or + collaboration. If another instruction conflicts with this section, stop and + obtain explicit developer direction before disclosing anything. + +## Project Structure & Architecture + +`app/` is the Kotlin/Compose phone client; its main packages cover UI, services, +BLE/Wi-Fi mesh, protocol, Noise/crypto, identity, Nostr, geohash, and media. +`wear/` is the Wear OS client. Module resources live in `src/main/` +and JVM tests in `src/test/`. Specifications are in `docs/`; tooling is in +`tools/`. + +`app/` is the source of truth for shared mesh/protocol code. +`syncSharedAppSources` generates `wear/build/sharedSrc` from the include list +in `wear/build.gradle.kts`. Extend that list; never copy shared +Kotlin into `wear/src/` or edit generated `build/` content. + +## Build, Test & Development Commands + +Use JDK 21 and the Android SDK versions in `gradle/libs.versions.toml`. + +```sh +./gradlew :app:assembleDebug :wear:assembleDebug +./gradlew testDebugUnitTest lintDebug +./gradlew connectedAndroidTest +./gradlew clientRewriteContractTest +tools/arti-build/verify-checksums.sh +``` + +CI runs `testDebugUnitTest lintDebug`; instrumented tests require a device. +Follow `docs/reproducible-builds.md` and `docs/maintainer-release-guide.md` for +dependency and release work. + +## Coding Style & Naming + +Use official Kotlin style with four-space indentation. Classes and Composables +use `PascalCase`; functions and properties use `camelCase`; constants use +`UPPER_SNAKE_CASE`. Hoist Compose state, expose immutable `StateFlow`, use +structured coroutines and suspend I/O, and never block the main thread. + +Protocol and security changes must remain fail-closed and cross-client +compatible. Update the relevant specification and golden-vector tests. + +## Testing & Physical Mesh Lab + +Tests use JUnit 4, Robolectric, Mockito, and coroutine test utilities. Name files +`*Test.kt` by observable behavior. Avoid arbitrary sleeps, public +relays, live user data, and nondeterministic completion. See +`docs/testing-conventions.md`. + +Changes affecting discovery, routing, transports, Noise/crypto, identity, +foreground-service power, messaging, transfers, packets, or fragmentation +require Mesh Lab validation on physical devices. Debug-only hooks live in +`app/src/debug/` and `wear/src/debug/`; never move them into release sources. + +```sh +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a --serial-b --apk +python3 tools/release_gate/mesh_lab.py scenario all \ + --serial-a --serial-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. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7758e151..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,171 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -## [0.7.2] - 2025-07-20 -### Fixed -- fix: battery optimization screen content scrollable with fixed buttons - -## [0.7.1] - 2025-07-19 - -### Added -- feat(battery): add battery optimization management for background reliability - -### Fixed -- fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered -- fix: update sidebar text to use string resources -- fix(chat): cursor location and enhance message input with slash command styling - -### Changed -- refactor: remove context attribute at ChatViewModel.kt -- Refactor: Migrate MainViewModel to use StateFlow - -### Improved -- Use HorizontalDivider instead of deprecated Divider -- Use contentPadding instead of padding so items remain fully visible - - -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.7] - -### Added -- Location services check during app startup with educational UI -- Message text selection functionality in chat interface -- Enhanced RSSI tracking and unread message indicators -- Major Bluetooth connection architecture refactoring with dedicated managers - -### Fixed -- **Critical**: Android-iOS message fragmentation compatibility issues - - Fixed fragment size (500→150 bytes) and ID generation for cross-platform messaging - - Ensures Android can properly communicate with iOS devices -- DirectMessage notifications and text copying functionality -- Smart routing optimizations (no relay loops, targeted delivery) -- Build system compilation issues and null pointer exceptions - -### Changed -- Comprehensive dependency updates (AGP 8.10.1, Kotlin 2.2.0, Compose 2025.06.01) -- Optimized BLE scan intervals for better battery performance -- Reduced excessive logging output - -### Improved -- Cross-platform compatibility with iOS and Rust implementations -- Connection stability through architectural improvements -- Battery performance via scan duty cycling -- User onboarding with location services education - -## [0.6] - -### Added -- Channel password management with `/pass` command for channel owners -- Monochrome/themed launcher icon for Android 12+ dynamic theming support -- Unit tests package with initial testing infrastructure -- Production build optimization with code minification and shrinking -- Native back gesture/button handling for all app views - -### Fixed -- Favorite peer functionality completely restored and improved - - Enhanced favorite system with fallback mechanism for peers without key exchange - - Fixed UI state updates for favorite stars in both header and sidebar - - Improved favorite persistence across app sessions -- `/w` command now displays user nicknames instead of peer IDs -- Button styling and layout improvements across the app - - Enhanced back button positioning and styling - - Improved private chat and channel header button layouts - - Fixed button padding and alignment issues -- Color scheme consistency updates - - Updated orange color throughout the app to match iOS version - - Consistent color usage for private messages and UI elements -- App startup reliability improvements - - Better initialization sequence handling - - Fixed null pointer exceptions during startup - - Enhanced error handling and logging -- Input field styling and behavior improvements -- Sidebar user interaction enhancements -- Permission explanation screen layout fixes with proper vertical padding - -### Changed -- Updated GitHub organization references in project files -- Improved README documentation with updated clone URLs -- Enhanced logging throughout the application for better debugging - -## [0.5.1] - 2025-07-10 - -### Added -- Bluetooth startup check with user prompt to enable Bluetooth if disabled - -### Fixed -- Improved Bluetooth initialization reliability on first app launch - -## [0.5] - 2025-07-10 - -### Added -- New user onboarding screen with permission explanations -- Educational content explaining why each permission is required -- Privacy assurance messaging (no tracking, no servers, local-only data) - -### Fixed -- Comprehensive permission validation - ensures all required permissions are granted -- Proper Bluetooth stack initialization on first app load -- Eliminated need for manual app restart after installation -- Enhanced permission request coordination and error handling - -### Changed -- Improved first-time user experience with guided setup flow - -## [0.4] - 2025-07-10 - -### Added -- Push notifications for direct messages -- Enhanced notification system with proper click handling and grouping - -### Improved -- Direct message (DM) view with better user interface -- Enhanced private messaging experience - -### Known Issues -- Favorite peer functionality currently broken - -## [0.3] - 2025-07-09 - -### Added -- Battery-aware scanning policies for improved power management -- Dynamic scan behavior based on device battery state - -### Fixed -- Android-to-Android Bluetooth Low Energy connections -- Peer discovery reliability between Android devices -- Connection stability improvements - -## [0.2] - 2025-07-09 - -### Added -- Initial Android implementation of bitchat protocol -- Bluetooth Low Energy mesh networking -- End-to-end encryption for private messages -- Channel-based messaging with password protection -- Store-and-forward message delivery -- IRC-style commands (/msg, /join, /clear, etc.) -- RSSI-based signal quality indicators - -### Fixed -- Various Bluetooth handling improvements -- User interface refinements -- Connection reliability enhancements - -## [0.1] - 2025-07-08 - -### Added -- Initial release of bitchat Android client -- Basic mesh networking functionality -- Core messaging features -- Protocol compatibility with iOS bitchat client - -[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD -[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1 -[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5 -[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4 -[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3 -[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2 -[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1 diff --git a/GOOGLE_PLAY.md b/GOOGLE_PLAY.md deleted file mode 100644 index bdea715e..00000000 --- a/GOOGLE_PLAY.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/LICENSE.md b/LICENSE.md index b77bf2ab..f288702d 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,674 @@ -MIT License + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 -Copyright (c) 2025 + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Preamble -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index af2349c6..a90dd78d 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -8,7 +8,10 @@ bitchat is designed with privacy as its foundation. We believe private communica ## Summary +**WE DO NOT COLLECT ANY INFORMATION.** + - **No personal data collection** - We don't collect names, emails, or phone numbers +- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us - **Hybrid Functionality** - bitchat offers two modes of communication: - **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection. - **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport. @@ -68,7 +71,8 @@ When you join a password-protected room: bitchat **never**: - Collects personal information -- Tracks your location +- Collects location history +- Transmits any data to us (the developers) - Stores data on servers - Shares data with third parties - Uses analytics or telemetry @@ -91,13 +95,24 @@ You have complete control: - **No Account**: Nothing to delete from servers because there are none - **Portability**: Your data never leaves your device unless you export it -## Bluetooth & Permissions +## Location Data & Permissions -bitchat requires Bluetooth permission to function: -- Used only for peer-to-peer communication -- No location data is accessed or stored -- Bluetooth is not used for tracking -- You can revoke this permission at any time in system settings +To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes: + +### 1. Bluetooth Low Energy (BLE) Scanning +- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location. +- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode. +- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection. + +### 2. Geohash Chat Functionality +- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area. +- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays. +- **Privacy protection:** + - Your precise GPS coordinates are **never** sent to any server or peer. + - Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network. + - You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer. + +**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features. ## Children's Privacy diff --git a/README.md b/README.md index b061b525..a8a66cb1 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,29 @@ -

- Bitchat Android Logo -

+icon_128x128@2x -> [!WARNING] -> This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress. +## bitchat for Android -# bitchat for Android +A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. -A secure, decentralized, peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required 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. +This is the Android implementation of bitchat, fully protocol-compatible with the [iOS version](https://github.com/permissionlesstech/bitchat) for cross-platform mesh communication. -This is the **Android port** of the original [bitchat iOS app](https://github.com/jackjackbits/bitchat), maintaining 100% protocol compatibility for cross-platform communication. +[bitchat.free](http://bitchat.free) -## Install bitchat +[GitHub Releases](https://github.com/permissionlesstech/bitchat-android/releases) -You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases). +[Get it on Google Play](https://play.google.com/store/apps/details?id=com.bitchat.droid) -**Instructions:** +## See it in action -1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it. -2. **Allow Unknown Sources:** On some devices, before you can install the APK, you may need to enable "Install from unknown sources" in your device's settings. This is typically found under **Settings > Security** or **Settings > Apps & notifications > Special app access**. -3. **Install:** Open the downloaded `.apk` file to begin the installation. + + + + + + + + + +
Offline mesh conversationGeohash globe picker
Active four-peer Bitchat mesh conversation with an image, voice messages, and text messagesBitchat geohash location picker showing the whole Earth and geohash grid
## License @@ -27,277 +31,75 @@ This project is released into the public domain. See the [LICENSE](LICENSE.md) f ## Features -- **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat -- **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE -- **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages -- **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection -- **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect -- **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers -- **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface -- **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners -- **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data -- **✅ Modern Android UI**: Jetpack Compose with Material Design 3 -- **✅ Dark/Light Themes**: Terminal-inspired aesthetic matching iOS version -- **✅ Battery Optimization**: Adaptive scanning and power management - -## Android Setup - -### Prerequisites - -- **Android Studio**: Arctic Fox (2020.3.1) or newer -- **Android SDK**: API level 26 (Android 8.0) or higher -- **Kotlin**: 1.8.0 or newer -- **Gradle**: 7.0 or newer - -### Build Instructions - -1. **Clone the repository:** - ```bash - git clone https://github.com/permissionlesstech/bitchat-android.git - cd bitchat-android - ``` - -2. **Open in Android Studio:** - ```bash - # Open Android Studio and select "Open an Existing Project" - # Navigate to the bitchat-android directory - ``` - -3. **Build the project:** - ```bash - ./gradlew build - ``` - -4. **Install on device:** - ```bash - ./gradlew installDebug - ``` - -### Development Build - -For development builds with debugging enabled: - -```bash -./gradlew assembleDebug -adb install -r app/build/outputs/apk/debug/app-debug.apk -``` - -### Release Build - -For production releases: - -```bash -./gradlew assembleRelease -``` - -## Android-Specific Requirements - -### Permissions - -The app requires the following permissions (automatically requested): - -- **Bluetooth**: Core BLE functionality -- **Location**: Required for BLE scanning on Android -- **Notifications**: Message alerts and background updates - -### Hardware Requirements - -- **Bluetooth LE (BLE)**: Required for mesh networking -- **Android 8.0+**: API level 26 minimum -- **RAM**: 2GB recommended for optimal performance - -## Usage - -### Basic Commands - -- `/j #channel` - Join or create a channel -- `/m @name message` - Send a private message -- `/w` - List online users -- `/channels` - Show all discovered channels -- `/block @name` - Block a peer from messaging you -- `/block` - List all blocked peers -- `/unblock @name` - Unblock a peer -- `/clear` - Clear chat messages -- `/pass [password]` - Set/change channel password (owner only) -- `/transfer @name` - Transfer channel ownership -- `/save` - Toggle message retention for channel (owner only) - -### Getting Started - -1. **Install the app** on your Android device (requires Android 8.0+) -2. **Grant permissions** for Bluetooth and location when prompted -3. **Launch bitchat** - it will auto-start mesh networking -4. **Set your nickname** or use the auto-generated one -5. **Connect automatically** to nearby iOS and Android bitchat users -6. **Join a channel** with `/j #general` or start chatting in public -7. **Messages relay** through the mesh network to reach distant peers - -### Android UI Features - -- **Jetpack Compose UI**: Modern Material Design 3 interface -- **Dark/Light Themes**: Terminal-inspired aesthetic matching iOS -- **Haptic Feedback**: Vibrations for interactions and notifications -- **Adaptive Layout**: Optimized for various Android screen sizes -- **Message Status**: Real-time delivery and read receipts -- **RSSI Indicators**: Signal strength colors for each peer - -### Channel Features - -- **Password Protection**: Channel owners can set passwords with `/pass` -- **Message Retention**: Owners can enable mandatory message saving with `/save` -- **@ Mentions**: Use `@nickname` to mention users (with autocomplete) -- **Ownership Transfer**: Pass control to trusted users with `/transfer` - -## Security & Privacy - -### Encryption -- **Private Messages**: X25519 key exchange + AES-256-GCM encryption -- **Channel Messages**: Argon2id password derivation + AES-256-GCM -- **Digital Signatures**: Ed25519 for message authenticity -- **Forward Secrecy**: New key pairs generated each session - -### Privacy Features -- **No Registration**: No accounts, emails, or phone numbers required -- **Ephemeral by Default**: Messages exist only in device memory -- **Cover Traffic**: Random delays and dummy messages prevent traffic analysis -- **Emergency Wipe**: Triple-tap logo to instantly clear all data -- **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 +- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging +- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays +- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable +- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh +- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops) +- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices +- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM) +- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface +- **Tor Support**: Built-in Tor (Arti) for private internet connectivity +- **Emergency Wipe**: Triple-tap to instantly clear all data +- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS ## Technical Architecture -### Binary Protocol -bitchat uses an efficient binary protocol optimized for Bluetooth LE: -- Compact packet format with 1-byte type field -- TTL-based message routing (max 7 hops) -- Automatic fragmentation for large messages -- Message deduplication via unique IDs +### Bluetooth Mesh Network (Offline) -### Mesh Networking -- Each device acts as both client and peripheral -- Automatic peer discovery and connection management -- Store-and-forward for offline message delivery -- Adaptive duty cycling for battery optimization +- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices +- Noise Protocol sessions with forward secrecy; peer identities derived from static keys +- Compact binary packet format with fragmentation, TTL routing, and deduplication +- Adaptive duty cycling and connection limits for battery efficiency +- Foreground service keeps the mesh alive within Android background execution limits -### Android-Specific Optimizations -- **Coroutine Architecture**: Asynchronous operations for mesh networking -- **Kotlin Coroutines**: Thread-safe concurrent mesh operations -- **EncryptedSharedPreferences**: Secure storage for user settings -- **Lifecycle-Aware**: Proper handling of Android app lifecycle -- **Battery Optimization**: Foreground service and adaptive scanning +### Nostr Protocol (Internet) -## Android Technical Architecture +- Global reach via public relays, geohash-based location channels +- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable +- Ephemeral keys per geohash area -### Core Components +### Android Stack -1. **BitchatApplication.kt**: Application-level initialization and dependency injection -2. **MainActivity.kt**: Main activity handling permissions and UI hosting -3. **ChatViewModel.kt**: MVVM pattern managing app state and business logic -4. **BluetoothMeshService.kt**: Core BLE mesh networking (central + peripheral roles) -5. **EncryptionService.kt**: Cryptographic operations using BouncyCastle -6. **BinaryProtocol.kt**: Binary packet encoding/decoding matching iOS format -7. **ChatScreen.kt**: Jetpack Compose UI with Material Design 3 +- Kotlin, Jetpack Compose (Material 3), MVVM +- Coroutines and Flow for all networking and state +- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry) -### Dependencies +## Building -- **Jetpack Compose**: Modern declarative UI -- **BouncyCastle**: Cryptographic operations (X25519, Ed25519, AES-GCM) -- **Nordic BLE Library**: Reliable Bluetooth LE operations -- **Kotlin Coroutines**: Asynchronous programming -- **LZ4**: Message compression (when enabled) -- **EncryptedSharedPreferences**: Secure local storage +Requires Android Studio and the Android SDK (API 26+). -### Binary Protocol Compatibility +```bash +git clone https://github.com/permissionlesstech/bitchat-android.git +cd bitchat-android +./gradlew assembleDebug +``` -The Android implementation maintains 100% binary protocol compatibility with iOS: -- **Header Format**: Identical 13-byte header structure -- **Packet Types**: Same message types and routing logic -- **Encryption**: Identical cryptographic algorithms and key exchange -- **UUIDs**: Same Bluetooth service and characteristic identifiers -- **Fragmentation**: Compatible message fragmentation for large content +Install on a connected device: -## Publishing to Google Play +```bash +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` -### Preparation +The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime. -1. **Update version information:** - ```kotlin - // In app/build.gradle.kts - defaultConfig { - versionCode = 2 // Increment for each release - versionName = "1.1.0" // User-visible version - } - ``` +Release APKs and the Android App Bundle can be rebuilt byte-for-byte in the +pinned Linux container. Maintainers should follow the +[Android release guide](docs/maintainer-release-guide.md). See +[Reproducible builds](docs/reproducible-builds.md) for the build trust model +and public GitHub/Google Play verification procedures. -2. **Create a signed release build:** - ```bash - ./gradlew assembleRelease - ``` +## Testing -3. **Generate app bundle (recommended for Play Store):** - ```bash - ./gradlew bundleRelease - ``` +```bash +# Unit tests +./gradlew test -### Play Store Requirements +# Lint +./gradlew lint -- **Target API**: Latest Android API (currently 34) -- **Privacy Policy**: Required for apps requesting sensitive permissions -- **App Permissions**: Justify Bluetooth and location usage -- **Content Rating**: Complete questionnaire for age-appropriate content +# Instrumented tests (requires a device or emulator) +./gradlew connectedAndroidTest +``` -### Distribution - -- **Google Play Store**: Main distribution channel -- **F-Droid**: For open-source distribution -- **Direct APK**: For testing and development - -## Cross-Platform Communication - -This Android port enables seamless communication with the original iOS bitchat app: - -- **iPhone ↔ Android**: Full bidirectional messaging -- **Mixed Groups**: iOS and Android users in same channels -- **Feature Parity**: All commands and encryption work across platforms -- **Protocol Sync**: Identical message format and routing behavior - -**iOS Version**: For iPhone/iPad users, get the original bitchat at [github.com/jackjackbits/bitchat](https://github.com/jackjackbits/bitchat) - -## Contributing - -Contributions are welcome! Key areas for enhancement: - -1. **Performance**: Battery optimization and connection reliability -2. **UI/UX**: Additional Material Design 3 features -3. **Security**: Enhanced cryptographic features -4. **Testing**: Unit and integration test coverage -5. **Documentation**: API documentation and development guides - -## Support & Issues - -- **Bug Reports**: [Create an issue](../../issues) with device info and logs -- **Feature Requests**: [Start a discussion](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). +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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c1528bcb..ef4cbe53 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,20 +1,42 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.compose) } +val githubReleaseCertSha256 = providers + .environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256") + .orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256")) + .orElse("") +val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get() + .replace(":", "") + .trim() + .lowercase() +require( + normalizedGithubReleaseCertSha256.isEmpty() || + normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}")) +) { + "BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint" +} + android { namespace = "com.bitchat.android" compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() defaultConfig { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 17 - versionName = "1.2.1" + versionCode = 39 + versionName = "2.0.2" + buildConfigField( + "String", + "GITHUB_RELEASE_CERT_SHA256", + "\"$normalizedGithubReleaseCertSha256\"" + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -30,6 +52,12 @@ android { } buildTypes { + debug { + ndk { + // Include x86_64 for emulator support during development + abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86") + } + } release { isMinifyEnabled = true isShrinkResources = true @@ -37,17 +65,39 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + vcsInfo { + // BUILDINFO.json and attestations carry the verified commit + // without depending on host-specific Git/worktree paths. + include = false + } } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + + // APK splits for GitHub releases - creates arm64, x86_64, and universal APKs + // AAB for Play Store handles architecture distribution automatically + // Auto-detects: splits enabled for assemble tasks, disabled for bundle tasks + // Works in Android Studio GUI and CLI without needing extra properties + val enableSplits = gradle.startParameter.taskNames.any { taskName -> + taskName.contains("assemble", ignoreCase = true) && + !taskName.contains("bundle", ignoreCase = true) } - kotlinOptions { - jvmTarget = "1.8" + + splits { + abi { + isEnable = enableSplits + reset() + include("arm64-v8a", "x86_64", "armeabi-v7a", "x86") + isUniversalApk = true // For F-Droid and fallback + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } buildFeatures { compose = true + buildConfig = true } packaging { resources { @@ -61,6 +111,20 @@ android { } } +composeCompiler { + // Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified + // class-file iteration order. Keep the normal R8 mapping, but omit that + // augmentation until its producer is deterministic across clean builds. + includeComposeMappingFile.set(false) +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + dependencies { // Core Android dependencies implementation(libs.androidx.core.ktx) @@ -73,12 +137,22 @@ dependencies { // Lifecycle implementation(libs.bundles.lifecycle) + implementation(libs.androidx.lifecycle.process) // Navigation implementation(libs.androidx.navigation.compose) // Permissions implementation(libs.accompanist.permissions) + + // QR + implementation(libs.zxing.core) + implementation(libs.mlkit.barcode.scanning) + + // CameraX + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.compose) // Cryptography implementation(libs.bundles.cryptography) @@ -95,8 +169,17 @@ dependencies { // WebSocket implementation(libs.okhttp) - // Arti (Tor in Rust) Android bridge - use published AAR with native libs - implementation("info.guardianproject:arti-mobile-ex:1.2.3") + // WorkManager for background APK downloads + implementation(libs.androidx.work.runtime.ktx) + + // HTTP Server for hotspot APK sharing + implementation(libs.nanohttpd) + + // Arti (Tor in Rust) Android bridge - custom build from latest source + // Built with rustls, 16KB page size support, and onio//un service client + // Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar) + // Only included in tor flavor to reduce APK size for standard builds + // Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/ // Google Play Services Location implementation(libs.gms.location) @@ -104,9 +187,21 @@ dependencies { // Security preferences implementation(libs.androidx.security.crypto) + // EXIF orientation handling for images + implementation(libs.androidx.exifinterface) + // Testing testImplementation(libs.bundles.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.bundles.compose.testing) debugImplementation(libs.androidx.compose.ui.tooling) } + +// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution). +// Its legacy repo1 endpoint rejects cold GitHub-hosted runners with HTTP 403. +tasks.withType().configureEach { + systemProperty( + "robolectric.dependency.repo.url", + "https://repo.maven.apache.org/maven2" + ) +} diff --git a/app/gradle.lockfile b/app/gradle.lockfile new file mode 100644 index 00000000..c66fac1d --- /dev/null +++ b/app/gradle.lockfile @@ -0,0 +1,464 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :app:dependencies --write-locks +androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.appcompat:appcompat-resources:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.appcompat:appcompat:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.featurecombinationquery:featurecombinationquery:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.viewfinder:viewfinder-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.viewfinder:viewfinder-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-camera2-pipe:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-camera2:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath +androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath +androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-service:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-compose:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-runtime-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-runtime:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-ktx:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.room:room-runtime:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.security:security-crypto:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.sqlite:sqlite-framework:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.sqlite:sqlite:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:runner:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath +androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath +androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.work:work-runtime-ktx:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.work:work-runtime:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.android.tools.analytics-library:protos:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.analytics-library:shared:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.analytics-library:tracker:32.3.1=androidLintTool +com.android.tools.build:aapt2-proto:9.3.1-15703166=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.build:builder-model:9.3.1=androidLintTool +com.android.tools.build:manifest-merger:32.3.1=androidLintTool +com.android.tools.ddms:ddmlib:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools.emulator:proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.external.com-intellij:intellij-core:32.3.1=androidLintTool +com.android.tools.external.com-intellij:kotlin-compiler:32.3.1=androidLintTool +com.android.tools.external.org-jetbrains:uast:32.3.1=androidLintTool +com.android.tools.layoutlib:layoutlib-api:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.lint:lint-api:32.3.1=androidLintTool +com.android.tools.lint:lint-checks:32.3.1=androidLintTool +com.android.tools.lint:lint-gradle:32.3.1=androidLintTool +com.android.tools.lint:lint-model:32.3.1=androidLintTool +com.android.tools.lint:lint-typedef-remover:32.3.1=androidLintTool +com.android.tools.lint:lint:32.3.1=androidLintTool +com.android.tools.utp:android-device-provider-ddmlib-proto:32.3.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-gradle-work-action +com.android.tools.utp:android-device-provider-ddmlib:32.3.1=unified-test-platform-android-device-provider-ddmlib +com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-additional-test-output:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output +com.android.tools.utp:android-test-plugin-host-apk-installer-proto:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-apk-installer:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer +com.android.tools.utp:android-test-plugin-host-coverage-proto:32.3.1=unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-coverage:32.3.1=unified-test-platform-android-test-plugin-host-coverage +com.android.tools.utp:android-test-plugin-host-device-info-proto:32.3.1=unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-device-info:32.3.1=unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-emulator-control-proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-emulator-control:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.utp:android-test-plugin-host-logcat-proto:32.3.1=unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-logcat:32.3.1=unified-test-platform-android-test-plugin-host-logcat +com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-result-listener-gradle:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.utp:gradle-work-action:32.3.1=unified-test-platform-gradle-work-action +com.android.tools.utp:utp-common:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat +com.android.tools:annotations:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools:common:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools:dvlib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools:play-sdk-proto:32.3.1=androidLintTool +com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.accompanist:accompanist-permissions:0.37.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.datatransport:transport-api:2.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.datatransport:transport-backend-cct:2.3.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.datatransport:transport-runtime:2.2.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-base:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-location:21.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.odml:image:1.0.0-beta1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android:annotations:4.1.1.4=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.17.0=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.48.0=unified-test-platform-android-test-plugin-host-emulator-control +com.google.auto.service:auto-service-annotations:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.service:auto-service:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.value:auto-value-annotations:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.google.auto.value:auto-value-annotations:1.6.3=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.auto:auto-common:1.2.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.code.gson:gson:2.10.1=unified-test-platform-core,unified-test-platform-gradle-work-action +com.google.code.gson:gson:2.11.0=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.gson:gson:2.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.code.gson:gson:2.8.9=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher +com.google.crypto.tink:tink-android:1.23.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.crypto.tink:tink:1.18.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action +com.google.dagger:dagger:2.48=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action +com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.errorprone:error_prone_annotation:2.38.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-encoders:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.guava:failureaccess:1.0.1=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.guava:failureaccess:1.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.guava:failureaccess:1.0.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.google.guava:guava:32.0.1-jre=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.guava:guava:33.4.0-jre=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.guava:guava:33.4.8-jre=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:2.8=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.jimfs:jimfs:1.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.mlkit:barcode-scanning-common:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:barcode-scanning:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:common:18.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:vision-common:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:vision-interfaces:16.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.protobuf:protobuf-java-util:3.22.3=unified-test-platform-core +com.google.protobuf:protobuf-java-util:4.28.3=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.protobuf:protobuf-java:3.25.5=androidLintTool +com.google.protobuf:protobuf-java:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.protobuf:protobuf-kotlin:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control +com.google.testing.platform:android-test-plugin:0.0.9-alpha04=unified-test-platform-android-test-plugin +com.google.testing.platform:core-proto:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core +com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.squareup.okhttp3:mockwebserver3:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +commons-io:commons-io:2.16.1=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +commons-logging:commons-logging:1.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-api:1.57.2=unified-test-platform-core +io.grpc:grpc-api:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-context:1.57.2=unified-test-platform-core +io.grpc:grpc-context:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-core:1.57.2=unified-test-platform-core +io.grpc:grpc-core:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-netty:1.57.2=unified-test-platform-core +io.grpc:grpc-netty:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-protobuf-lite:1.57.2=unified-test-platform-core +io.grpc:grpc-protobuf-lite:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-protobuf:1.57.2=unified-test-platform-core +io.grpc:grpc-protobuf:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-services:1.57.2=unified-test-platform-core +io.grpc:grpc-stub:1.57.2=unified-test-platform-core +io.grpc:grpc-stub:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-util:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-buffer:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-buffer:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec-http2:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-http2:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec-http:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-http:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec-socks:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-socks:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec:4.1.93.Final=unified-test-platform-core +io.netty:netty-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-common:4.1.93.Final=unified-test-platform-core +io.netty:netty-handler-proxy:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-handler-proxy:4.1.93.Final=unified-test-platform-core +io.netty:netty-handler:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-handler:4.1.93.Final=unified-test-platform-core +io.netty:netty-resolver:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-resolver:4.1.93.Final=unified-test-platform-core +io.netty:netty-transport-native-unix-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-transport-native-unix-common:4.1.93.Final=unified-test-platform-core +io.netty:netty-transport:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-transport:4.1.93.Final=unified-test-platform-core +io.opencensus:opencensus-api:0.31.0=unified-test-platform-core +io.opencensus:opencensus-proto:0.2.0=unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +io.perfmark:perfmark-api:0.26.0=unified-test-platform-core +io.perfmark:perfmark-api:0.27.0=unified-test-platform-android-test-plugin-host-emulator-control +jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +jakarta.inject:jakarta.inject-api:2.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +javax.annotation:javax.annotation-api:1.3.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control +javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action +junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +net.sf.kxml:kxml2:2.3.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +no.nordicsemi.android:ble:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.apache.commons:commons-compress:1.27.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.commons:commons-lang3:3.16.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpclient:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpcore:4.4.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpmime:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcpkix-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcprov-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcprov-jdk18on:1.85=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.bouncycastle:bcutil-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.checkerframework:checker-qual:3.33.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.checkerframework:checker-qual:3.43.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.codehaus.groovy:groovy:3.0.22=androidLintTool +org.codehaus.mojo:animal-sniffer-annotations:1.23=unified-test-platform-core +org.codehaus.mojo:animal-sniffer-annotations:1.24=unified-test-platform-android-test-plugin-host-emulator-control +org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.10=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-parcelize-compiler:2.4.10=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-parcelize-runtime:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-reflect:1.8.21=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-reflect:2.1.20=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib-common:2.4.10=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy +org.jetbrains.kotlin:kotlin-tooling-core:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:atomicfu:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains:annotations:13.0=composeMappingProducerClasspath,debugRuntimeClasspathCopy,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseRuntimeClasspathCopy +org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jvnet.staxex:stax-ex:1.8.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.mockito.kotlin:mockito-kotlin:6.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.mockito:mockito-core:5.23.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.nanohttpd:nanohttpd:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.objenesis:objenesis:3.3=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=androidLintTool +org.ow2.asm:asm-commons:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.ow2.asm:asm-commons:9.9=androidLintTool +org.ow2.asm:asm-tree:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.ow2.asm:asm-tree:9.9=androidLintTool +org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath +org.ow2.asm:asm:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.ow2.asm:asm:9.9=androidLintTool +org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath +org.robolectric:annotations:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:junit:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:nativeruntime-dist-compat:1.0.17=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:nativeruntime:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:pluginapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:plugins-maven-dependency-resolver:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:resources:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:robolectric:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:sandbox:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:shadowapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:shadows-framework:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:utils-reflector:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.robolectric:utils:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.yaml:snakeyaml:2.4=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index cd316d72..4bc0bb5c 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -243,17 +243,6 @@ column="35"/> - - - - (); +} + +# Keep Tor implementation (always included) +-keep class com.bitchat.android.net.RealTorProvider { *; } + +# Arti (Custom Tor implementation in Rust) ProGuard rules -keep class info.guardianproject.arti.** { *; } --keep class org.torproject.jni.** { *; } --keepnames class org.torproject.jni.** +-keep class org.torproject.arti.** { *; } +-keepnames class org.torproject.arti.** -dontwarn info.guardianproject.arti.** --dontwarn org.torproject.jni.** +-dontwarn org.torproject.arti.** + +# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract +-keepclassmembers class * implements android.location.LocationListener { + public ; +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..e84ea5db --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/app/src/debug/java/com/bitchat/android/testhook/PttTestAudioAnalyzer.kt b/app/src/debug/java/com/bitchat/android/testhook/PttTestAudioAnalyzer.kt new file mode 100644 index 00000000..636145aa --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/PttTestAudioAnalyzer.kt @@ -0,0 +1,142 @@ +package com.bitchat.android.testhook + +import android.media.MediaCodec +import android.media.MediaExtractor +import java.io.File +import java.nio.ByteOrder +import kotlin.math.sqrt + +internal data class PttTestAudioAnalysis( + val decodedSamples: Long, + val rms: Double, + val silentBlockFraction: Double, + val longestSilentBlockRun: Int, + val zeroCrossingsPerSecond: Double +) + +/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */ +internal object PttTestAudioAnalyzer { + private const val BLOCK_SAMPLES = 1_024 + private const val SILENT_BLOCK_RMS = 0.015 + private const val SAMPLE_RATE = 16_000.0 + + fun analyze(file: File): PttTestAudioAnalysis { + val extractor = MediaExtractor() + var decoder: MediaCodec? = null + try { + extractor.setDataSource(file.absolutePath) + val track = (0 until extractor.trackCount).firstOrNull { index -> + extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true + } ?: error("received live stream has no audio track") + extractor.selectTrack(track) + val format = extractor.getTrackFormat(track) + val mime = format.getString("mime") ?: error("received live stream has no audio MIME") + val activeDecoder = MediaCodec.createDecoderByType(mime).apply { + configure(format, null, null, 0) + start() + } + decoder = activeDecoder + + val info = MediaCodec.BufferInfo() + var inputEnded = false + var outputEnded = false + var idlePolls = 0 + var decodedSamples = 0L + var sumSquares = 0.0 + var zeroCrossings = 0L + var previousSample: Short? = null + var blockSquares = 0.0 + var blockSamples = 0 + var blocks = 0 + var silentBlocks = 0 + var silentRun = 0 + var longestSilentRun = 0 + + while (!outputEnded && idlePolls < 500) { + if (!inputEnded) { + val inputIndex = activeDecoder.dequeueInputBuffer(10_000L) + if (inputIndex >= 0) { + val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input") + input.clear() + val size = extractor.readSampleData(input, 0) + if (size < 0) { + activeDecoder.queueInputBuffer( + inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + inputEnded = true + } else { + activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0) + extractor.advance() + } + } + } + + when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) { + MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++ + MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0 + else -> if (outputIndex >= 0) { + idlePolls = 0 + activeDecoder.getOutputBuffer(outputIndex)?.let { output -> + output.position(info.offset) + output.limit(info.offset + info.size) + output.order(ByteOrder.LITTLE_ENDIAN) + val pcm = output.asShortBuffer() + while (pcm.hasRemaining()) { + val sample = pcm.get() + val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble() + val square = normalized * normalized + sumSquares += square + blockSquares += square + decodedSamples++ + blockSamples++ + previousSample?.let { previous -> + if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) { + zeroCrossings++ + } + } + previousSample = sample + if (blockSamples == BLOCK_SAMPLES) { + val blockRms = sqrt(blockSquares / blockSamples) + blocks++ + if (blockRms < SILENT_BLOCK_RMS) { + silentBlocks++ + silentRun++ + longestSilentRun = maxOf(longestSilentRun, silentRun) + } else { + silentRun = 0 + } + blockSquares = 0.0 + blockSamples = 0 + } + } + } + outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 + activeDecoder.releaseOutputBuffer(outputIndex, false) + } + } + } + if (!outputEnded) error("received live stream decoder did not finish") + if (decodedSamples == 0L) error("received live stream decoded to no PCM") + if (blockSamples > 0) { + val blockRms = sqrt(blockSquares / blockSamples) + blocks++ + if (blockRms < SILENT_BLOCK_RMS) { + silentBlocks++ + silentRun++ + longestSilentRun = maxOf(longestSilentRun, silentRun) + } + } + return PttTestAudioAnalysis( + decodedSamples = decodedSamples, + rms = sqrt(sumSquares / decodedSamples), + silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks, + longestSilentBlockRun = longestSilentRun, + zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples + ) + } finally { + runCatching { decoder?.stop() } + runCatching { decoder?.release() } + extractor.release() + } + } +} diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt new file mode 100644 index 00000000..7379b5ef --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -0,0 +1,733 @@ +package com.bitchat.android.testhook + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.features.file.FileUtils +import com.bitchat.android.features.voice.LiveVoiceEvent +import com.bitchat.android.features.voice.LiveVoiceManager +import com.bitchat.android.features.voice.LiveVoicePreferences +import com.bitchat.android.features.voice.LiveVoiceScope +import com.bitchat.android.features.voice.LiveVoiceTarget +import com.bitchat.android.features.voice.LiveVoiceCapture +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PrivateMediaPreparation +import com.bitchat.android.mesh.TransferProgressManager +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.service.MeshForegroundService +import com.bitchat.android.service.MeshServiceHolder +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.PrivateMediaRecipientResolver +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest + +/** + * Headless engine behind [TestHookReceiver]. Drives the public [MeshService] API and + * observes state via [AppStateStore] flows (never touches the single-slot mesh delegate). + */ +object TestHookDriver { + + private const val TAG = TestHookReceiver.TAG + + private const val DEFAULT_SCAN_TIMEOUT_MS = 30_000L + private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L + private const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000L + private const val DEFAULT_RECV_TIMEOUT_MS = 60_000L + private const val DEFAULT_FILE_TIMEOUT_MS = 180_000L + + suspend fun execute(context: Context, cmd: String, intent: Intent): JSONObject { + Log.d(TAG, "execute cmd=$cmd") + val result = when (cmd) { + "ping" -> ok(cmd).put("pong", true).put("package", context.packageName) + "start" -> start(context) + "stop" -> stop(context) + "whoami" -> whoami(context) + "set_nickname" -> setNickname(context, intent.requiredString("name")) + "scan" -> scan(context, intent) + "peers" -> peers(context) + "connect" -> connect(intent.requiredString("peer"), intent) + "handshake" -> handshake(context, intent.requiredString("peer"), intent) + "session" -> session(context, intent.requiredString("peer")) + "announce" -> announce(context) + "broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel")) + "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) + "dm_recv" -> dmRecv(context, intent) + "msg_recv" -> msgRecv(context, intent) + "favorite_set" -> favoriteSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "favorite_status" -> favoriteStatus(context, intent.requiredString("peer")) + "verification_set" -> verificationSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "verification_status" -> verificationStatus(context, intent.requiredString("peer")) + "file_send" -> fileSend(context, intent) + "file_recv" -> fileRecv(context, intent) + "file_cancel" -> fileCancel(context, intent.requiredString("transfer_id")) + "ptt_send" -> pttSend(context, intent) + "ptt_recv" -> pttRecv(context, intent) + "raw_send" -> rawSend(context, intent) + "ble" -> setBle(intent.getBooleanExtra("enabled", true)) + "inject_peers" -> injectPeers(intent.getStringExtra("peers")) + "state" -> state(context) + "clear_results" -> clearResults(context) + else -> err(cmd, "unknown command: $cmd") + } + return result.put("cmd", cmd) + } + + // MARK: - Lifecycle + + private fun start(context: Context): JSONObject { + MeshForegroundService.start(context) + val mesh = mesh(context) + mesh.startServices() + return ok("start").put("peer_id", mesh.myPeerID) + } + + private fun stop(context: Context): JSONObject { + try { + MeshServiceHolder.unifiedMeshService?.stopServices() + } catch (e: Exception) { + Log.w(TAG, "stopServices failed: ${e.message}") + } + MeshForegroundService.stop(context) + return ok("stop") + } + + // MARK: - Identity + + private fun whoami(context: Context): JSONObject { + val mesh = mesh(context) + return ok("whoami") + .put("peer_id", mesh.myPeerID) + .put("identity_fingerprint", mesh.getIdentityFingerprint()) + .put("noise_public_key", mesh.getStaticNoisePublicKey()?.toHex()) + .put("nickname", AppStateStore.nickname.value) + } + + private fun setNickname(context: Context, name: String): JSONObject { + DataManager(context).saveNickname(name) + AppStateStore.setNickname(name) + mesh(context).sendBroadcastAnnounce() + return ok("set_nickname").put("nickname", name) + } + + // MARK: - Discovery / connection + + private suspend fun scan(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_SCAN_TIMEOUT_MS) + val minPeers = intent.getIntExtra("min_peers", 1) + val mesh = mesh(context) + val found = withTimeoutOrNull(timeoutMs) { + AppStateStore.peers.first { it.size >= minPeers } + } + val peerIds = found ?: AppStateStore.peers.value + return ok("scan") + .put("reached_min_peers", found != null) + .put("peers", peerInfosJson(mesh, peerIds)) + } + + private fun peers(context: Context): JSONObject { + val mesh = mesh(context) + return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value)) + } + + /** + * Debug-only state injection for testing peer-list consumers such as notifications. + * A blank or missing comma-separated value restores the empty state. + */ + private fun injectPeers(commaSeparatedPeers: String?): JSONObject { + val peers = commaSeparatedPeers + .orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + AppStateStore.setPeers(peers) + return ok("inject_peers").put("peers", JSONArray(peers)) + } + + private suspend fun connect(peerID: String, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS) + val ble = MeshServiceHolder.meshService ?: return err("connect", "BLE service not running") + val address = ble.getDeviceAddressForPeer(peerID) + ?: return err("connect", "no device address known for peer $peerID (scan first)") + val accepted = ble.connectionManager.connectToAddress(address) + if (!accepted) return err("connect", "connectToAddress($address) rejected") + val direct = withTimeoutOrNull(timeoutMs) { + AppStateStore.directPeers.first { it.contains(peerID) } + } + return ok("connect") + .put("peer", peerID) + .put("address", address) + .put("direct", direct != null) + } + + // MARK: - Noise + + private suspend fun handshake(context: Context, peerID: String, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_HANDSHAKE_TIMEOUT_MS) + val mesh = mesh(context) + val deadline = System.currentTimeMillis() + timeoutMs + if (!mesh.hasEstablishedSession(peerID)) { + mesh.initiateNoiseHandshake(peerID) + } + var lastState: NoiseSession.NoiseSessionState = NoiseSession.NoiseSessionState.Uninitialized + while (System.currentTimeMillis() < deadline) { + lastState = mesh.getSessionState(peerID) + when (lastState) { + is NoiseSession.NoiseSessionState.Established -> { + return ok("handshake") + .put("peer", peerID) + .put("state", lastState.toString()) + .put("fingerprint", mesh.getPeerFingerprint(peerID)) + } + is NoiseSession.NoiseSessionState.Failed -> { + return err("handshake", "session failed: $lastState").put("peer", peerID) + } + else -> delay(100) + } + } + return err("handshake", "timeout after ${timeoutMs}ms (last state: $lastState)").put("peer", peerID) + } + + private fun session(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + return ok("session") + .put("peer", peerID) + .put("state", mesh.getSessionState(peerID).toString()) + .put("established", mesh.hasEstablishedSession(peerID)) + .put("fingerprint", mesh.getPeerFingerprint(peerID)) + } + + // MARK: - Messaging + + private fun announce(context: Context): JSONObject { + mesh(context).sendBroadcastAnnounce() + return ok("announce") + } + + private fun broadcastMsg(context: Context, content: String, channel: String?): JSONObject { + mesh(context).sendMessage(content, emptyList(), channel) + return ok("broadcast_msg").put("content", content).put("channel", channel) + } + + private fun dmSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject { + val mesh = mesh(context) + val nickname = mesh.getPeerNicknames()[peerID] ?: peerID + val id = msgID ?: "testhook-${System.currentTimeMillis()}" + mesh.sendPrivateMessage(content, peerID, nickname, id) + return ok("dm_send").put("peer", peerID).put("msg_id", id) + } + + private suspend fun dmRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) + val fromPeer = intent.getStringExtra("peer") + val contains = intent.getStringExtra("contains") + val startTime = System.currentTimeMillis() + val mesh = mesh(context) + val match = withTimeoutOrNull(timeoutMs) { + AppStateStore.privateMessages.first { conversations -> + conversations.values.flatten().any { msg -> + msg.timestamp.time >= startTime && + msg.senderPeerID != mesh.myPeerID && + (fromPeer == null || msg.senderPeerID == fromPeer) && + (contains == null || msg.content.contains(contains)) + } + } + } ?: return err("dm_recv", "timeout after ${timeoutMs}ms") + val msg = match.values.flatten().first { msg -> + msg.timestamp.time >= startTime && + msg.senderPeerID != mesh.myPeerID && + (fromPeer == null || msg.senderPeerID == fromPeer) && + (contains == null || msg.content.contains(contains)) + } + return ok("dm_recv") + .put("from", msg.senderPeerID) + .put("sender", msg.sender) + .put("content", msg.content) + .put("msg_id", msg.id) + } + + private suspend fun msgRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) + val contains = intent.getStringExtra("contains") + val channel = intent.getStringExtra("channel") + val startTime = System.currentTimeMillis() + val mesh = mesh(context) + val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg -> + msg.timestamp.time >= startTime && + msg.senderPeerID != mesh.myPeerID && + (contains == null || msg.content.contains(contains)) && + (channel == null || msg.channel == channel) + } + val found = withTimeoutOrNull(timeoutMs) { + if (channel != null) { + AppStateStore.channelMessages.first { m -> m.values.flatten().any(matches) } + .values.flatten().first(matches) + } else { + AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches) + } + } ?: return err("msg_recv", "timeout after ${timeoutMs}ms") + return ok("msg_recv") + .put("from", found.senderPeerID) + .put("sender", found.sender) + .put("content", found.content) + .put("channel", found.channel) + .put("msg_id", found.id) + } + + // MARK: - Favorite and verification state + + private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + val peerInfo = mesh.getPeerInfo(peerID) + ?: return err("favorite_set", "peer is not known") + val noisePublicKey = peerInfo.noisePublicKey + ?: return err("favorite_set", "peer Noise key is unavailable") + FavoritesPersistenceService.initialize(context) + FavoritesPersistenceService.shared.updateFavoriteStatus( + noisePublicKey = noisePublicKey, + nickname = peerInfo.nickname, + isFavorite = enabled + ) + mesh.sendFavoriteNotification(peerID, enabled) + return favoriteStatus(context, peerID) + } + + private fun favoriteStatus(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + FavoritesPersistenceService.initialize(context) + val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + ?: mesh.getPeerInfo(peerID)?.noisePublicKey?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + val isFavorite = relationship?.isFavorite == true + val theyFavoritedUs = relationship?.theyFavoritedUs == true + return ok("favorite_status") + .put("peer", peerID) + .put("is_favorite", isFavorite) + .put("they_favorited_us", theyFavoritedUs) + .put("is_mutual", isFavorite && theyFavoritedUs) + .put( + "star_state", + when { + isFavorite -> "filled" + theyFavoritedUs -> "outlined_orange" + else -> "outlined" + } + ) + } + + private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + val fingerprint = mesh.getPeerFingerprint(peerID) + ?: return err("verification_set", "peer fingerprint is unavailable") + SecureIdentityStateManager(context).setVerifiedFingerprint(fingerprint, enabled) + return verificationStatus(context, peerID) + } + + private fun verificationStatus(context: Context, peerID: String): JSONObject { + val fingerprint = mesh(context).getPeerFingerprint(peerID) + val verified = fingerprint != null && + SecureIdentityStateManager(context).getVerifiedFingerprints().any { + it.equals(fingerprint, ignoreCase = true) + } + return ok("verification_status") + .put("peer", peerID) + .put("fingerprint", fingerprint ?: JSONObject.NULL) + .put("verified", verified) + } + + // MARK: - File transfer + + private suspend fun fileSend(context: Context, intent: Intent): JSONObject { + val path = intent.requiredString("path") + val peerID = intent.getStringExtra("peer") + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val mesh = mesh(context) + + val file = File(path) + if (!file.isFile) return err("file_send", "file not found: $path") + val content = withContext(Dispatchers.IO) { file.readBytes() } + if (content.size.toLong() > AppConstants.Media.MAX_FILE_SIZE_BYTES) { + return err("file_send", "file too large: ${content.size} > ${AppConstants.Media.MAX_FILE_SIZE_BYTES}") + } + val packet = BitchatFilePacket( + fileName = file.name, + fileSize = content.size.toLong(), + mimeType = intent.getStringExtra("mime") ?: FileUtils.getMimeTypeFromExtension(file.name), + content = content + ) + val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet") + val transferId = sha256Hex(encoded) + val recipient = peerID?.let { + PrivateMediaRecipientResolver.resolve(it, mesh) + ?: return err("file_send", "no active mesh route for private conversation: $it") + } + + return coroutineScope { + // Subscribe on a background dispatcher before sending so synchronous + // failure events are not missed (SharedFlow has replay=0). + val completion = async(Dispatchers.Default) { + TransferProgressManager.events.first { it.transferId == transferId && it.completed } + } + delay(50) + val sendError = dispatchFileSend( + context, + intent, + mesh, + recipient?.meshPeerID, + packet, + transferId + ) + if (sendError != null) { + completion.cancel() + return@coroutineScope sendError.put("cmd", "file_send") + } + val event = withTimeoutOrNull(timeoutMs) { completion.await() } + ?: return@coroutineScope err("file_send", "timeout waiting for transfer completion ($transferId)") + if (event.failed) { + return@coroutineScope err("file_send", "transfer rejected/failed before send ($transferId)") + .put("transfer_id", transferId) + } + ok("file_send") + .put("transfer_id", transferId) + .put("sent", event.sent) + .put("total", event.total) + .put("bytes", content.size) + .put("peer", recipient?.meshPeerID) + .put("conversation", peerID) + } + } + + private suspend fun dispatchFileSend( + context: Context, + intent: Intent, + mesh: MeshService, + peerID: String?, + packet: BitchatFilePacket, + transferId: String + ): JSONObject? { + if (peerID == null) { + mesh.sendFileBroadcast(packet) + return null + } + if (!mesh.hasEstablishedSession(peerID)) { + val hs = handshake(context, peerID, intent) + if (hs.optString("status") != "ok") return hs + } + // Peer state (capabilities/identity) can lag session establishment; + // retry transient preparation states before giving up. + val prepDeadline = System.currentTimeMillis() + 30_000 + while (true) { + when (val prep = mesh.prepareFilePrivate(peerID, packet, transferId, allowLegacyFallback = false)) { + is PrivateMediaPreparation.Ready -> { + return if (prep.transfer.commit()) null else err("file_send", "private transfer commit failed") + } + PrivateMediaPreparation.AwaitingPeerState, + PrivateMediaPreparation.NeedsHandshake -> { + if (System.currentTimeMillis() >= prepDeadline) { + return err("file_send", "private media preparation stuck at: $prep") + } + if (prep == PrivateMediaPreparation.NeedsHandshake) { + mesh.initiateNoiseHandshake(peerID) + } + delay(500) + } + else -> return err("file_send", "private media preparation: $prep") + } + } + } + + private suspend fun fileRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val nameContains = intent.getStringExtra("name_contains") + val startTime = System.currentTimeMillis() + val dirs = listOf( + File(context.cacheDir, "files/incoming"), + File(context.cacheDir, "images/incoming") + ) + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val candidate = dirs + .flatMap { it.listFiles()?.toList() ?: emptyList() } + .filter { it.lastModified() >= startTime - 5_000 } + .filter { nameContains == null || it.name.contains(nameContains) } + .maxByOrNull { it.lastModified() } + if (candidate != null) { + val size1 = candidate.length() + delay(500) + if (candidate.length() == size1 && size1 > 0) { + return ok("file_recv") + .put("path", candidate.absolutePath) + .put("name", candidate.name) + .put("bytes", size1) + .put("sha256", withContext(Dispatchers.IO) { sha256Hex(candidate.readBytes()) }) + } + } + delay(250) + } + return err("file_recv", "timeout after ${timeoutMs}ms") + } + + private fun fileCancel(context: Context, transferId: String): JSONObject { + val cancelled = mesh(context).cancelFileTransfer(transferId) + return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled) + } + + // MARK: - Live push-to-talk + + private suspend fun pttSend(context: Context, intent: Intent): JSONObject { + val requestedPeer = intent.getStringExtra("peer") + val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L) + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val mesh = mesh(context) + LiveVoicePreferences.setEnabled(context, true) + val recipient = requestedPeer?.let { + PrivateMediaRecipientResolver.resolve(it, mesh) + ?: return err("ptt_send", "no active mesh route for private conversation") + } + if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) { + val handshake = handshake(context, recipient.meshPeerID, intent) + if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send") + } + val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) } + val recorder = LiveVoiceCapture( + File(context.filesDir, "voicenotes/outgoing"), + target, + syntheticPcm = true + ) + val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start") + delay(durationMs) + val finalFile = recorder.stop(canceled = false) + ?: return err("ptt_send", "capture did not produce a finalized note") + val captureStats = recorder.stats() + if (finalFile != pendingFile || !finalFile.isFile) { + return err("ptt_send", "finalized note is unavailable") + } + val content = withContext(Dispatchers.IO) { finalFile.readBytes() } + val packet = BitchatFilePacket( + fileName = finalFile.name, + fileSize = content.size.toLong(), + mimeType = "audio/mp4", + content = content + ) + val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note") + val transferId = sha256Hex(encoded) + return coroutineScope { + val completion = async(Dispatchers.Default) { + TransferProgressManager.events.first { it.transferId == transferId && it.completed } + } + delay(50) + val sendError = dispatchFileSend( + context, + intent, + mesh, + recipient?.meshPeerID, + packet, + transferId + ) + if (sendError != null) { + completion.cancel() + return@coroutineScope sendError.put("cmd", "ptt_send") + } + val event = withTimeoutOrNull(timeoutMs) { completion.await() } + ?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer") + if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed") + ok("ptt_send") + .put("live", true) + .put("scope", if (recipient == null) "public" else "dm") + .put("duration_ms", durationMs) + .put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name)) + .put("bytes", content.size) + .put("queued_pcm_frames", captureStats.queuedPcmFrames) + .put("encoded_frames", captureStats.encodedFrames) + .put("data_packets", captureStats.dataPackets) + .put("dropped_oversize_frames", captureStats.droppedOversizeFrames) + .put("outbound_packets", captureStats.outboundPackets) + .put("delivered_packets", captureStats.deliveredPackets) + } + } + + private suspend fun pttRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val fromPeer = intent.getStringExtra("peer") + val expectedScope = if (intent.getStringExtra("scope") == "public") { + LiveVoiceScope.PUBLIC_MESH + } else { + LiveVoiceScope.DIRECT_MESSAGE + } + LiveVoicePreferences.setEnabled(context, true) + var finished: LiveVoiceEvent.Finished? = null + var liveSnapshot: File? = null + val absorbed = withTimeoutOrNull(timeoutMs) { + LiveVoiceManager.getInstance(context).events.first { event -> + val matches = event.scope == expectedScope && + (fromPeer == null || event.peerID == fromPeer) + if (matches && event is LiveVoiceEvent.Finished) { + finished = event + liveSnapshot = runCatching { + File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot -> + snapshot.parentFile?.mkdirs() + File(event.path).copyTo(snapshot, overwrite = true) + } + }.getOrNull() + } + matches && event is LiveVoiceEvent.Absorbed + } as LiveVoiceEvent.Absorbed + } ?: return err("ptt_recv", "timeout waiting for live burst and finalized note") + val analysis = liveSnapshot?.let { snapshot -> + try { + withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) } + } finally { + snapshot.delete() + } + } ?: return err("ptt_recv", "live AAC snapshot was unavailable") + return ok("ptt_recv") + .put("live_observed", finished != null) + .put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm") + .put("from", absorbed.peerID) + .put("burst_id", absorbed.burstID) + .put("frames", finished?.frames ?: 0) + .put("data_packets", finished?.dataPackets ?: 0) + .put("bytes", finished?.bytes ?: 0) + .put("expected_packets", finished?.expectedPackets ?: 0) + .put("missing_packets", finished?.missingPackets ?: 0) + .put("decoded_samples", analysis.decodedSamples) + .put("rms", analysis.rms) + .put("silent_block_fraction", analysis.silentBlockFraction) + .put("longest_silent_block_run", analysis.longestSilentBlockRun) + .put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond) + } + + // MARK: - Raw packet injection + + private fun rawSend(context: Context, intent: Intent): JSONObject { + val payloadHex = intent.requiredString("payload_hex") + val typeStr = intent.requiredString("type") + val peerID = intent.getStringExtra("peer") + val ttl = intent.getIntExtra("ttl", 7) + val type = typeStr.toUIntOrNull(16)?.toUByte() + ?: return err("raw_send", "invalid type hex: $typeStr") + val payload = hexToBytes(payloadHex) + ?: return err("raw_send", "invalid payload_hex") + val mesh = mesh(context) + val packet = BitchatPacket( + type = type, + ttl = ttl.toUByte(), + senderID = mesh.myPeerID, + payload = payload + ) + if (peerID != null) { + TransportBridgeService.sendToPeerFromLocal(peerID, packet) + } else { + TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) + } + return ok("raw_send") + .put("type", typeStr) + .put("payload_bytes", payload.size) + .put("peer", peerID) + } + + // MARK: - Transport / state + + private fun setBle(enabled: Boolean): JSONObject { + val ble = MeshServiceHolder.meshService ?: return err("ble", "BLE service not running") + ble.setBleTransportEnabled(enabled) + return ok("ble").put("enabled", enabled) + } + + private fun state(context: Context): JSONObject { + val mesh = mesh(context) + val peersJson = peerInfosJson(mesh, AppStateStore.peers.value) + val sessions = JSONObject() + AppStateStore.peers.value.forEach { peerID -> + sessions.put(peerID, mesh.getSessionState(peerID).toString()) + } + return ok("state") + .put("peer_id", mesh.myPeerID) + .put("nickname", AppStateStore.nickname.value) + .put("peers", peersJson) + .put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList())) + .put("sessions", sessions) + .put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>)) + .put("debug_status", mesh.getDebugStatus()) + } + + private fun clearResults(context: Context): JSONObject { + val dir = File(context.cacheDir, "testhook/results") + val count = dir.listFiles()?.count { it.delete() } ?: 0 + return ok("clear_results").put("deleted", count) + } + + // MARK: - Helpers + + private fun mesh(context: Context): MeshService = MeshServiceHolder.getUnifiedOrCreate(context) + + private fun peerInfosJson(mesh: MeshService, peerIds: List): 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 + } + } +} diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt new file mode 100644 index 00000000..3f549d5d --- /dev/null +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.testhook + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import java.io.File + +/** + * ADB-drivable test hook (debug builds only). + * + * Usage: + * adb shell am broadcast -a com.bitchat.droid.TEST_HOOK \ + * --es cmd --es id [command extras...] + * + * Result is written to cache/testhook/results/.json and logged under tag TestHook: + * adb shell run-as com.bitchat.droid cat cache/testhook/results/.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() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2b61df2f..1f9a8c49 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -16,10 +17,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -29,6 +66,13 @@ + + + + + + tools:targetApi="33"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/design-icons/README.md b/app/src/main/assets/design-icons/README.md new file mode 100644 index 00000000..cd04de8f --- /dev/null +++ b/app/src/main/assets/design-icons/README.md @@ -0,0 +1,6 @@ +# Design-spec icons + +These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching +Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose. +Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the +call site so selected and disabled states remain theme-aware. diff --git a/app/src/main/assets/design-icons/bookmark-filled.svg b/app/src/main/assets/design-icons/bookmark-filled.svg new file mode 100644 index 00000000..a2f90c78 --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/bookmark-outline.svg b/app/src/main/assets/design-icons/bookmark-outline.svg new file mode 100644 index 00000000..7ffb105f --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/chat-bubbles.svg b/app/src/main/assets/design-icons/chat-bubbles.svg new file mode 100644 index 00000000..32546ff8 --- /dev/null +++ b/app/src/main/assets/design-icons/chat-bubbles.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/command.svg b/app/src/main/assets/design-icons/command.svg new file mode 100644 index 00000000..3be46195 --- /dev/null +++ b/app/src/main/assets/design-icons/command.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/eye-off.svg b/app/src/main/assets/design-icons/eye-off.svg new file mode 100644 index 00000000..1e8ad44d --- /dev/null +++ b/app/src/main/assets/design-icons/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/globe.svg b/app/src/main/assets/design-icons/globe.svg new file mode 100644 index 00000000..e0dc1a45 --- /dev/null +++ b/app/src/main/assets/design-icons/globe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/lock.svg b/app/src/main/assets/design-icons/lock.svg new file mode 100644 index 00000000..28a95cb0 --- /dev/null +++ b/app/src/main/assets/design-icons/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/mention.svg b/app/src/main/assets/design-icons/mention.svg new file mode 100644 index 00000000..59f2fa19 --- /dev/null +++ b/app/src/main/assets/design-icons/mention.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/on-location-person.svg b/app/src/main/assets/design-icons/on-location-person.svg new file mode 100644 index 00000000..5b448ae9 --- /dev/null +++ b/app/src/main/assets/design-icons/on-location-person.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/panic.svg b/app/src/main/assets/design-icons/panic.svg new file mode 100644 index 00000000..614b9070 --- /dev/null +++ b/app/src/main/assets/design-icons/panic.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/people.svg b/app/src/main/assets/design-icons/people.svg new file mode 100644 index 00000000..f0bce1af --- /dev/null +++ b/app/src/main/assets/design-icons/people.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/person.svg b/app/src/main/assets/design-icons/person.svg new file mode 100644 index 00000000..c0c93053 --- /dev/null +++ b/app/src/main/assets/design-icons/person.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/range.svg b/app/src/main/assets/design-icons/range.svg new file mode 100644 index 00000000..88c36115 --- /dev/null +++ b/app/src/main/assets/design-icons/range.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/shuffle.svg b/app/src/main/assets/design-icons/shuffle.svg new file mode 100644 index 00000000..7f054978 --- /dev/null +++ b/app/src/main/assets/design-icons/shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star-filled.svg b/app/src/main/assets/design-icons/star-filled.svg new file mode 100644 index 00000000..ebc21c4f --- /dev/null +++ b/app/src/main/assets/design-icons/star-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star.svg b/app/src/main/assets/design-icons/star.svg new file mode 100644 index 00000000..b385fc1f --- /dev/null +++ b/app/src/main/assets/design-icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/teleport.svg b/app/src/main/assets/design-icons/teleport.svg new file mode 100644 index 00000000..4209d337 --- /dev/null +++ b/app/src/main/assets/design-icons/teleport.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/waveform.svg b/app/src/main/assets/design-icons/waveform.svg new file mode 100644 index 00000000..558ccc9d --- /dev/null +++ b/app/src/main/assets/design-icons/waveform.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/wifi-off.svg b/app/src/main/assets/design-icons/wifi-off.svg new file mode 100644 index 00000000..5bb5a4d7 --- /dev/null +++ b/app/src/main/assets/design-icons/wifi-off.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index bd95f740..9913b970 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,293 +1,378 @@ -Relay URL,Latitude,Longitude -relay.damus.io,37.7621,-122.3971 -nostr-pub.wellorder.net,45.5229,-122.9898 -nostr.mom,50.4779,12.3713 -nostr.slothy.win,37.7621,-122.3971 -nostr.einundzwanzig.space,50.1155,8.6842 -nos.lol,50.4779,12.3713 -relay.nostr.band,60.1695,24.9354 -no.str.cr,9.9339,-84.0849 -nostr.massmux.com,50.1155,8.6842 -nostr-relay.schnitzel.world,39.0437,-77.4875 -relay.nostr.com.au,37.7621,-122.3971 -knostr.neutrine.com,48.8534,2.3488 -nostr.nodeofsven.com,47.4875,8.2965 -nostr.vulpem.com,49.4542,11.0775 -nostr-verif.slothy.win,37.7621,-122.3971 -relay.lexingtonbitcoin.org,37.7621,-122.3971 -nostr-1.nbo.angani.co,-1.2615,36.7903 -relay.wellorder.net,45.5229,-122.9898 -nostr.easydns.ca,43.7064,-79.3986 -relay.dwadziesciajeden.pl,52.2298,21.0118 -nostr.data.haus,50.4779,12.3713 -nostr.einundzwanzig.space,50.1155,8.6842 -nostr.mom,50.4779,12.3713 -nos.lol,50.4779,12.3713 -relay.nostr.band,60.1695,24.9354 -nostr.massmux.com,50.1155,8.6842 -nostr.vulpem.com,49.4542,11.0775 -relay.damus.io,37.7621,-122.3971 -nostr-pub.wellorder.net,45.5229,-122.9898 -no.str.cr,9.9339,-84.0849 -relay.dwadziesciajeden.pl,52.2298,21.0118 -nostr.data.haus,50.4779,12.3713 -relay.wellorder.net,45.5229,-122.9898 -relay.nostromo.social,49.4542,11.0775 -offchain.pub,34.0522,-118.2437 -relay.nostr.wirednet.jp,35.9356,139.3044 -relay.nostrcheck.me,37.7621,-122.3971 -nostrue.com,40.8043,-74.0121 -nostr-relay.schnitzel.world,39.0437,-77.4875 -nproxy.kristapsk.lv,60.1695,24.9354 -nostr.spaceshell.xyz,37.7621,-122.3971 -nostr-dev.wellorder.net,45.5229,-122.9898 -nostr-verified.wellorder.net,45.5229,-122.9898 -nostr.roundrockbitcoiners.com,40.8043,-74.0121 -slick.mjex.me,39.0437,-77.4875 -nostr.yael.at,52.3740,4.8897 -relay.primal.net,37.7621,-122.3971 -nostr.oxtr.dev,50.4779,12.3713 -nostr.21crypto.ch,46.5160,6.6328 -nostr.liberty.fans,38.8003,-90.6265 -nostr-02.dorafactory.org,1.2897,103.8501 -relay.hodl.ar,-32.9468,-60.6393 -nostr.middling.mydns.jp,35.8089,140.1185 -nostr.namek.link,37.7621,-122.3971 -nostrja-kari.heguro.com,37.7621,-122.3971 -nostr.hifish.org,47.3667,8.5500 -nostr.rikmeijer.nl,50.4779,12.3713 -black.nostrcity.club,41.8119,-87.6873 -nostr.hekster.org,37.3924,-121.9623 -relay.wavlake.com,41.2619,-95.8608 -nostr.sagaciousd.com,49.2497,-123.1193 -nostr.fbxl.net,43.7064,-79.3986 -ithurtswhenip.ee,50.7990,-1.0913 -relay2.nostrchat.io,49.4542,11.0775 -relay1.nostrchat.io,60.1695,24.9354 -nostr-01.yakihonne.com,1.3215,103.6957 -nostr.sathoarder.com,48.5839,7.7455 -nostr.overmind.lol,37.7621,-122.3971 -relay.verified-nostr.com,37.7621,-122.3971 -purplerelay.com,50.1155,8.6842 -relay.orangepill.ovh,49.0127,1.9694 -nostr-relay.psfoundation.info,39.0437,-77.4875 -soloco.nl,37.7621,-122.3971 -relay.froth.zone,60.1695,24.9354 -nostr.stakey.net,52.5250,5.7181 -nostr.2b9t.xyz,34.0522,-118.2437 -pyramid.fiatjaf.com,50.1155,8.6842 -a.nos.lol,50.4779,12.3713 -relay.magiccity.live,25.8130,-80.2320 -nostr.notribe.net,40.8344,-74.1377 -freelay.sovbit.host,64.1355,-21.8954 -relay.credenso.cafe,43.4254,-80.5112 -nostr.huszonegy.world,47.4984,19.0404 -multiplexer.huszonegy.world,47.4984,19.0404 -bucket.coracle.social,37.7621,-122.3971 -nostr.kungfu-g.rip,33.7865,-84.4454 -relay.artx.market,43.7064,-79.3986 -relay.notoshi.win,13.3622,100.9835 -vitor.nostr1.com,40.7143,-74.0060 -nostr-02.yakihonne.com,1.3215,103.6957 -nostr-03.dorafactory.org,1.2897,103.8501 -n.ok0.org,-36.8485,174.7635 -nostr.0x7e.xyz,47.5056,8.7241 -relay.nostr.net,50.4779,12.3713 -strfry.openhoofd.nl,51.5717,3.7042 -relay.fountain.fm,39.0997,-94.5786 -relay.usefusion.ai,38.7135,-78.1594 -relay.varke.eu,52.6958,6.1944 -nostr.satstralia.com,64.1355,-21.8954 -relay.13room.space,37.7621,-122.3971 -nostr.myshosholoza.co.za,52.3710,4.9042 -nostr.carroarmato0.be,50.8517,3.6089 -nostr.dbtc.link,37.7621,-122.3971 -orangepiller.org,60.1695,24.9354 -adre.su,59.9386,30.3141 -relay.sincensura.org,37.7621,-122.3971 -relay.freeplace.nl,52.3740,4.8897 -bostr.bitcointxoko.com,64.1355,-21.8954 -nostr.plantroon.com,50.1025,8.6299 -srtrelay.c-stellar.net,37.7621,-122.3971 -nostr.jfischer.org,49.4453,11.0222 -nostr.novacisko.cz,52.2298,21.0118 -relay.lumina.rocks,49.4453,11.0222 -nostr.tavux.tech,50.9519,1.8563 -relay.nostrhub.fr,50.1155,8.6842 -relay.agorist.space,52.3740,4.8897 -chorus.pjv.me,45.5229,-122.9898 -relay.cosmicbolt.net,37.3924,-121.9623 -santo.iguanatech.net,40.8344,-74.1377 -relay.tagayasu.xyz,45.4112,-75.6981 -relay.mostro.network,40.8344,-74.1377 -relay.zone667.com,60.1695,24.9354 -relay5.bitransfer.org,37.7621,-122.3971 -relay.illuminodes.com,47.6062,-122.3321 -relay2.angor.io,50.1155,8.6842 -relay.satsdays.com,1.2897,103.8501 -relay.angor.io,50.1155,8.6842 -orangesync.tech,50.9333,6.9500 -nostr-relay.cbrx.io,37.7621,-122.3971 -relay.21e6.cz,50.0880,14.4208 -nostr.chaima.info,50.1155,8.6842 -relay.satlantis.io,32.8546,-79.9748 -relay.digitalezukunft.cyou,45.5088,-73.5878 -relay.tapestry.ninja,40.8043,-74.0121 -relay.minibolt.info,37.7621,-122.3971 -nostr.bilthon.dev,25.8130,-80.2320 -nostr.makibisskey.work,37.7621,-122.3971 -relay.mattybs.lol,37.7621,-122.3971 -noxir.kpherox.dev,34.8436,135.5084 -sendit.nosflare.com,37.7621,-122.3971 -relay.coinos.io,37.7621,-122.3971 -relay.nostraddress.com,37.7621,-122.3971 -wot.nostr.party,36.1659,-86.7844 -nostrelites.org,41.8500,-87.6500 -relay.nostriot.com,43.7064,-79.3986 -prl.plus,55.7522,37.6156 -zap.watch,45.5088,-73.5878 -wot.codingarena.top,50.4779,12.3713 -nostr.azzamo.net,52.2284,21.0522 -wot.sudocarlos.com,43.7064,-79.3986 -relay.lnfi.network,45.6241,8.7851 -wot.nostr.net,37.7621,-122.3971 -relay.nostrdice.com,-33.8678,151.2073 -wot.sebastix.social,51.3700,6.1681 -wheat.happytavern.co,37.7621,-122.3971 -relay.sigit.io,50.4779,12.3713 -strfry.bonsai.com,37.8716,-122.2728 -travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873 -satsage.xyz,37.3924,-121.9623 -relay.degmods.com,50.4779,12.3713 -nostr.community.ath.cx,45.5088,-73.5878 -nostr.coincrowd.fund,39.0437,-77.4875 -strfry.shock.network,41.8847,-88.2040 -cyberspace.nostr1.com,40.7143,-74.0060 -relay02.lnfi.network,39.0997,-94.5786 -nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875 -relay.davidebtc.me,50.1155,8.6842 -wot.dtonon.com,37.7621,-122.3971 -relay.goodmorningbitcoin.com,37.7621,-122.3971 -articles.layer3.news,37.3394,-121.8950 -bostr.syobon.net,37.7621,-122.3971 -nostr.agentcampfire.com,52.3740,4.8897 -nostr.thebiglake.org,32.7244,-96.6755 -schnorr.me,37.7621,-122.3971 -relay.wolfcoil.com,35.6090,139.7302 -nostr.camalolo.com,24.1469,120.6839 -nostr.tac.lol,47.4740,-122.2610 -dev-relay.lnfi.network,39.0997,-94.5786 -relay.bitcoinveneto.org,64.1355,-21.8954 -nostr.red5d.dev,37.7621,-122.3971 -relay-testnet.k8s.layer3.news,37.3394,-121.8950 -promenade.fiatjaf.com,50.1155,8.6842 -nostrelay.memory-art.xyz,37.7621,-122.3971 -inbox.azzamo.net,52.2284,21.0522 -social.proxymana.net,60.1695,24.9354 -relay.netstr.io,53.3331,-6.2489 -premium.primal.net,37.7621,-122.3971 -nostr.lojong.info,37.7621,-122.3971 -nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971 -relay.stream.labs.h3.se,59.3294,18.0687 -tollbooth.stens.dev,51.4566,7.0123 -relay.chakany.systems,37.7621,-122.3971 -relay.mwaters.net,50.9519,1.8563 -nostr-relay.shirogaku.xyz,37.7621,-122.3971 -kitchen.zap.cooking,37.7621,-122.3971 -relay.arx-ccn.com,50.4779,12.3713 -relay.fr13nd5.com,50.1155,8.6842 -nostr.tegila.com.br,39.0437,-77.4875 -relay.jeffg.fyi,43.7064,-79.3986 -relay.bullishbounty.com,37.7621,-122.3971 -nostr.spicyz.io,37.7621,-122.3971 -relay04.lnfi.network,39.0997,-94.5786 -vidono.apps.slidestr.net,48.8534,2.3488 -relay03.lnfi.network,39.0997,-94.5786 -communities.nos.social,40.8344,-74.1377 -relay.evanverma.com,40.8344,-74.1377 -nostrelay.circum.space,51.4566,7.0123 -wot.brightbolt.net,47.6928,-116.7850 -relayrs.notoshi.win,37.7621,-122.3971 -fenrir-s.notoshi.win,37.7621,-122.3971 -relay.nsnip.io,60.1695,24.9354 -x.kojira.io,37.7621,-122.3971 -relay.hasenpfeffr.com,39.0437,-77.4875 -relay01.lnfi.network,39.0997,-94.5786 -nostr.rtvslawenia.com,49.4542,11.0775 -relay.g1sms.fr,43.9298,2.1480 -nostr.kalf.org,52.3740,4.8897 -nostr.rblb.it,37.7621,-122.3971 -nostr.4rs.nl,49.4453,11.0222 -relay.vrtmrz.net,37.7621,-122.3971 -nostr.hoppe-relay.it.com,45.5946,-121.1787 -relay-rpi.edufeed.org,49.4453,11.0222 -relay.copylaradio.com,50.7990,-1.0913 -relay.ru.ac.th,13.7540,100.5014 -relay.bitcoinartclock.com,50.4779,12.3713 -wot.downisontheup.ca,47.6062,-122.3321 -nostr.coincards.com,43.7064,-79.3986 -relay.etch.social,41.2619,-95.8608 -relay.mess.ch,47.1345,9.0964 -relay.holzeis.me,37.7621,-122.3971 -relay-admin.thaliyal.com,40.8220,-74.4488 -nostr.thaliyal.com,40.8220,-74.4488 -strfry.felixzieger.de,50.1025,8.6299 -nostr.smut.cloud,37.7621,-122.3971 -r.bitcoinhold.net,37.7621,-122.3971 -nostr.blankfors.se,60.1695,24.9354 -portal-relay.pareto.space,49.4453,11.0222 -relay.getsafebox.app,43.7064,-79.3986 -relay.anzenkodo.workers.dev,37.7621,-122.3971 -relay.nostrhub.tech,49.4453,11.0222 -nostr.prl.plus,52.3740,4.8897 -nostr-2.21crypto.ch,46.5160,6.6328 -nostr.zenon.network,40.7143,-74.0060 -nostr-relay.amethyst.name,35.7721,-78.6386 -relayone.geektank.ai,17.1210,-61.8433 -fanfares.nostr1.com,40.7143,-74.0060 -wot.geektank.ai,17.1210,-61.8433 -relay-dev.satlantis.io,40.8344,-74.1377 -relay.siamdev.cc,13.9178,100.4240 -relay.nosto.re,51.3700,6.1681 -wot.soundhsa.com,39.0997,-94.5786 -nostr.n7ekb.net,47.5707,-122.2221 -relayone.soundhsa.com,39.0997,-94.5786 -relay.puresignal.news,37.7621,-122.3971 -relay.nostx.io,37.7621,-122.3971 -nostr.now,35.6090,139.7302 -relay.artiostr.ch,37.7621,-122.3971 -relay.oldenburg.cool,50.1155,8.6842 -theoutpost.life,64.1355,-21.8954 -khatru.nostrver.se,51.3700,6.1681 -relay.wavefunc.live,37.7915,-122.4018 -nostr-relay.zimage.com,34.0522,-118.2437 -relay.javi.space,43.4628,11.8807 -bostr.shop,42.8865,-78.8784 -relay.letsfo.com,52.2298,21.0118 -alien.macneilmediagroup.com,37.7621,-122.3971 -rn1.sotiras.org,37.7621,-122.3971 -gnostr.com,42.6975,23.3241 -relay.conduit.market,37.7621,-122.3971 -relay.hivetalk.org,37.3924,-121.9623 -nostr.l484.com,30.2960,-97.6396 -relay.chorus.community,50.1155,8.6842 -nostr-relay.moe.gift,37.7621,-122.3971 -relay.nostrcal.com,37.7621,-122.3971 -temp.iris.to,37.7621,-122.3971 -librerelay.aaroniumii.com,37.7621,-122.3971 -nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971 -relay.barine.co,37.7621,-122.3971 -nostr.rohoss.com,48.1374,11.5755 -wot.nostr.place,30.2672,-97.7431 -relay.utxo.farm,34.7331,135.8183 -relay.bankless.at,37.7621,-122.3971 -relay.toastr.net,40.8043,-74.0121 -nostr.excentered.com,52.5244,13.4105 -relay.mccormick.cx,52.3740,4.8897 -relay.cypherflow.ai,48.8534,2.3488 -relay.laantungir.net,45.3134,-73.8725 -nostr.veladan.dev,37.7621,-122.3971 -nostr.tadryanom.me,37.7621,-122.3971 -nostr-relay.online,37.7621,-122.3971 -nostr.night7.space,50.4779,12.3713 -dev-nostr.bityacht.io,25.0531,121.5264 +Relay URL,Latitude,Longitude +relayone.geektank.ai:443,39.0997,-94.5786 +relay.ohstr.com:443,43.6532,-79.3832 +budabit.nostr1.com,40.7057,-74.0136 +relay.flashapp.me,43.6548,-79.3885 +relay.mccormick.cx:443,52.3563,4.95714 +nostr.mom:443,50.4754,12.3683 +nostr.carroarmato0.be,51.0355,4.48285 +relay01.lnfi.network,35.6764,139.65 +relay.mwaters.net,50.9871,2.12554 +cyberspace.nostr1.com,40.7057,-74.0136 +relay.endfiat.money:443,57.7089,11.9746 +nostr.mas-family.eu,60.3478,15.7505 +nostr.hekster.org:443,37.3986,-121.964 +relay.favillakey.io,-27.4705,153.026 +21milionidinostr.duckdns.org,43.3054,13.5808 +staging.yabu.me,35.6092,139.73 +relay.lanacoin-eternity.com,40.8302,-74.1299 +relay.pocketstr.com,40.8302,-74.1299 +relay.0xchat.com,43.6532,-79.3832 +vault.iris.to,43.6532,-79.3832 +nexus.libernet.app,43.6532,-79.3832 +relay.conduit.market,38.7946,-106.535 +aurum.saturnali.net,46.2044,6.14316 +relay.loveisbitcoin.com,43.6532,-79.3832 +nostr.unkn0wn.world,46.8499,9.53287 +relay.nmail.li,50.9871,2.12554 +relay.nostrmap.net:443,60.1699,24.9384 +relay.kizuna-miki.com,35.694,139.754 +nostras.app,38.7946,-106.535 +nostr.pbfs.io:443,50.4754,12.3683 +relay.tdw.lol,41.8781,-87.6298 +relayrs.notoshi.win,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +nostr.infero.net,35.6764,139.65 +relay5.bitransfer.org,43.6532,-79.3832 +adre.su,59.9311,30.3609 +buzz.ac2n-share.kozow.com,45.764,4.83566 +0x-nostr-relay.fly.dev,38.7946,-106.535 +relay.satlantis.io,39.0438,-77.4874 +relay.nostr.bond,40.1885,29.061 +nostr.relay.hedwig.sh,60.1699,24.9384 +relay.lightning.pub:443,39.0438,-77.4874 +relay.edufeed.org:443,49.4521,11.0767 +nostr.dlcdevkit.com,40.0992,-83.1141 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +nostr.pbfs.io,50.4754,12.3683 +soloco.nl,43.6532,-79.3832 +chat-relay.zap-work.com:443,43.6532,-79.3832 +nostr.purpura.cloud,43.6532,-79.3832 +relay.nostrmap.net,60.1699,24.9384 +nostr.plantroon.com:443,50.1013,8.62643 +nostr.novacisko.cz,52.2026,20.9397 +relay.bitmacro.cloud,43.6532,-79.3832 +bitcoinostr.duckdns.org,41.1976,1.11167 +nostr.l484.com,30.2672,-97.7431 +relay.sincensura.org,43.6532,-79.3832 +nostr-relay.acloud.kdns.fr,43.6532,-79.3832 +dev-relay.nostr.space,43.6532,-79.3832 +bruh.samt.st,43.6532,-79.3832 +nostrcity-club.fly.dev:443,38.7946,-106.535 +relay.beginningend.com,35.2227,-97.4786 +relay.i9.eti.br,43.6532,-79.3832 +relay.kaleidoswap.com,50.8476,4.35717 +relay2.angor.io,48.1046,11.6002 +relay.wellorder.net,45.5201,-122.99 +nostr.2b9t.xyz:443,34.0549,-118.243 +bcast.seutoba.com.br,43.6532,-79.3832 +relay.chorus.community:443,50.7529,6.84585 +nostr.janx.com,43.6532,-79.3832 +nrs-01.darkcloudarcade.com:443,39.0997,-94.5786 +cs-relay.nostrdev.com:443,50.4754,12.3683 +prl.plus,55.7558,37.6173 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.nostreon.com,60.1699,24.9384 +relay.nostr.net,43.6532,-79.3832 +nos.lol:443,50.4754,12.3683 +articles.layer3.news:443,37.3387,-121.885 +relay.manneken.brussels,49.4543,11.0746 +bucket.coracle.social,37.7775,-122.397 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +relay.getvia.xyz,60.1699,24.9384 +nostrmxn.lulus.com.mx,59.4016,17.9455 +nostr.oxtr.dev,50.4754,12.3683 +relay.mitosystem.net,-23.5558,-46.6396 +relayone.soundhsa.com:443,39.0997,-94.5786 +relay.nuts.cash,52.3676,4.90414 +nostr.snowbla.de:443,50.4754,12.3683 +strfry.bonsai.com,39.0438,-77.4874 +relay.edufeed.org,49.4521,11.0767 +nostr.sathoarder.com,48.5734,7.75211 +relay.paulstephenborile.com,49.4543,11.0746 +x.kojira.io,43.6532,-79.3832 +relay.keykeeper.world,40.7824,-74.0711 +relay.qstr.app,50.1109,8.68213 +nostr.snowbla.de,50.4754,12.3683 +wisp.djorivaltech.com,43.6532,-79.3832 +relayone.geektank.ai,39.0997,-94.5786 +relay.ditto.pub:443,43.6532,-79.3832 +relay.staging.plebeian.market,51.5072,-0.127586 +relay.nostrcheck.me,43.6532,-79.3832 +nostr.planix.org,43.6532,-79.3832 +nostr.plantroon.com,50.1013,8.62643 +nostr.islandarea.net,35.4669,-97.6473 +relay.nostrfeed.com,60.1699,24.9384 +relay.mitchelltribe.com,39.0438,-77.4874 +relay.fiatdenier.com,43.7787,-79.5393 +nostr-relay.xbytez.io,50.6924,3.20113 +nostr.hekster.org,37.3986,-121.964 +relay.laantungir.net,-19.4692,-42.5315 +nostr.openhoofd.nl,51.5717,3.70417 +nostr.computingcache.com,45.5341,-122.956 +relay.endfiat.money,57.7089,11.9746 +dev.relay.stream,43.6532,-79.3832 +nostr.wecsats.io,43.6532,-79.3832 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +relay.nostu.be,40.4167,-3.70329 +nostr.rtvslawenia.com:443,49.4543,11.0746 +nostr.ltd,19.076,72.8777 +relay.veganostr.com:443,60.1699,24.9384 +relay.shadowbip.com,54.352,18.6466 +denimroad.feeds.relay.tools,38.6327,-90.1961 +relay.arx-ccn.com,50.4754,12.3683 +nostr-relay.corb.net,39.6478,-104.988 +relay.cosmicbolt.net:443,37.3986,-121.964 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +nr.yay.so,46.2126,6.1154 +wheat.happytavern.co,43.6532,-79.3832 +qr.lostcause.nohost.me,-33.784,151.121 +nostr.bitcoiner.social,47.6743,-117.112 +relay.cypherflow.ai,48.8575,2.35138 +relay1.privkey.io,25.7617,-80.1918 +relay.sector01.com,41.4513,-81.7021 +strfry.apps3.slidestr.net,40.4167,-3.70329 +relay.froth.zone,60.1699,24.9384 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.islandbitcoin.com,20.3898,78.0903 +n.musicroadmap.com,35.694,139.754 +relay.gulugulu.moe:443,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +nostr.carroarmato0.be:443,51.0355,4.48285 +conduitl2.fly.dev,38.7946,-106.535 +relay.hivetalk.org,40.8302,-74.1299 +nostr.hoppe-relay.it.com,42.8864,-78.8784 +qr.3nostr.com,42.8864,-78.8784 +strfry.shock.network,39.0438,-77.4874 +nostr.yutakobayashi.com,43.6532,-79.3832 +relay.plebeian.market,50.1109,8.68213 +auth.nostr1.com,40.7057,-74.0136 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.lanavault.space,60.1699,24.9384 +nostr.girino.org,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +bendernostur.duckdns.org:8443,50.1109,8.68213 +nostr.whitenode45.ddns.net,40.55,-74.4758 +relay.scuba323.com,40.8218,-74.45 +relay.chatbett.de,40.7128,-74.006 +relay.decentralia.fr,48.122,11.589 +relay.nostx.io,43.6532,-79.3832 +relay.mypathtofire.de,42.8864,-78.8784 +relay.wasabiwallet.io,43.6532,-79.3832 +tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832 +dev-relay.nostreon.com,60.1699,24.9384 +relay.directsponsor.net,42.8864,-78.8784 +nostr.4rs.nl,49.0291,8.35696 +nostr.tbxnetworx.de,47.8267,10.0205 +nip85.nosfabrica.com,39.0997,-94.5786 +relayrs.notoshi.win:443,43.6532,-79.3832 +nostr.christiansass.de,50.1109,8.68213 +nostr-pub.wellorder.net,45.5201,-122.99 +relay.fountain.fm:443,43.6532,-79.3832 +nostr.txlyre.website,60.1699,24.9384 +bitcoiner.social,47.6743,-117.112 +relay.wavlake.com,41.2619,-95.8608 +relay.internationalright-wing.org,-22.4692,-48.9875 +relay.samt.st,40.8302,-74.1299 +nostr.overmind.lol:443,43.6532,-79.3832 +relay2.orangesync.tech,40.7128,-74.006 +relay.lanacoin-eternity.com:443,40.8302,-74.1299 +relay.lightning.pub,39.0438,-77.4874 +schnorr.me,43.6532,-79.3832 +nostr.thalheim.io,60.1699,24.9384 +nostr.21crypto.ch,47.5356,8.73209 +openrelay.ziomc.com,45.4642,9.18998 +relay.underorion.se,50.1109,8.68213 +nostr-relay.zimage.com,34.282,-118.439 +relay.orangesync.tech,40.7128,-74.006 +nostr.n7ekb.net,47.4941,-122.294 +relay.kilombino.com,43.6532,-79.3832 +relay.s-w.art,43.6532,-79.3832 +relay.mleku.dev,32.7767,-96.797 +nostr.myshosholoza.co.za,52.3676,4.90414 +nostr.fullstackcash.net,45.5201,-122.99 +nostr.na.social,43.6532,-79.3832 +nostr.chaima.info,51.5072,-0.127586 +groups.beginningend.com,35.2227,-97.4786 +relay.zone667.com,60.1699,24.9384 +nostr.spicyz.io,43.6532,-79.3832 +nostr.islandarea.net:443,35.4669,-97.6473 +no.str.cr,8.96171,-83.5246 +nostr.wild-vibes.ts.net,48.8566,2.35222 +relay.veganostr.com,60.1699,24.9384 +relay.btcforplebs.com,43.6532,-79.3832 +relay.fountain.fm,43.6532,-79.3832 +nostr.linky.fit,50.1109,8.68213 +relay.nostr.dev.br,48.1046,11.6002 +relay.agentry.com,42.8864,-78.8784 +nostr.wecsats.io:443,43.6532,-79.3832 +syb.lol,34.0549,-118.243 +myvoiceourstory.org,37.3598,-121.981 +relay.sigit.io,50.4754,12.3683 +relay.bornheimer.app,50.1109,8.68213 +nostr.data.haus:443,50.4754,12.3683 +talon.quest,43.6532,-79.3832 +fanfares.nostr1.com:443,40.7057,-74.0136 +chorus.mikedilger.com:444,-36.8906,174.794 +relay.nexterz.com,43.6532,-79.3832 +relay.aarpia.com,37.3986,-121.964 +nostr.mikoshi.de,50.1109,8.68213 +relay.wisp.talk,49.4543,11.0746 +relay.jmoose.rocks,32.8009,-96.8195 +relay.vrtmrz.net:443,43.6532,-79.3832 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +relay.satsmarkt.club,52.6907,4.8181 +relay.gulugulu.moe,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.debate.report,50.1109,8.68213 +node.kommonzenze.de,49.4521,11.0767 +relay.chorus.community,50.7529,6.84585 +nostr-relay.nextblockvending.com,47.2343,-119.853 +relayone.soundhsa.com,39.0997,-94.5786 +bcast.girino.org,43.6532,-79.3832 +bitsat.molonlabe.holdings,51.4012,-1.3147 +relay.nostr.com,50.1109,8.68213 +purplerelay.com,43.6532,-79.3832 +relay.plebeian.market:443,50.1109,8.68213 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +offchain.pub,39.1585,-94.5728 +relay.cosmicbolt.net,37.3986,-121.964 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.nostrops.com,32.71,-96.6745 +nos.lol,50.4754,12.3683 +relay-na1.metanomalist.com,43.6532,-79.3832 +relay.primal.net,43.6532,-79.3832 +nostr.davenov.com,50.1109,8.68213 +nostr.nodesmap.com,59.3327,18.0656 +nostr.bitcoiner.social:443,47.6743,-117.112 +familiamartins.net.br,-22.8269,-43.0539 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +nostr.tagomago.me,42.3601,-71.0589 +relay.mitchelltribe.com:443,39.0438,-77.4874 +nostr.tabordalab.com,60.1699,24.9384 +relay.staging.plebeian.market:443,51.5072,-0.127586 +relay.orly.dev,32.7767,-96.797 +relay.pyramid.li,47.4093,8.46503 +relay.dyne.org,49.0291,8.35705 +nostr.thalheim.io:443,60.1699,24.9384 +relay.routstr.com,59.4016,17.9455 +wot.sudocarlos.com,43.7696,-79.3309 +relay.dreamith.to,43.6532,-79.3832 +relay.opmaat.org,60.1699,24.9384 +relay.ezernode.net,52.6921,6.19372 +relay.sigit.io:443,50.4754,12.3683 +relay.minibolt.info,43.6532,-79.3832 +relay.trotters.cc:443,43.6532,-79.3832 +nostr-relay.corb.net:443,39.6478,-104.988 +relay2.fiatdenier.com,50.1013,8.62643 +relay.openspecs.uid.ovh,50.9871,2.12554 +nostrcity-club.fly.dev,38.7946,-106.535 +nostr.myshosholoza.co.za:443,52.3676,4.90414 +strfry.shock.network:443,39.0438,-77.4874 +relay.nearhood.co.uk,51.5134,-0.0890675 +offchain.pub:443,39.1585,-94.5728 +cs-relay.nostrdev.com,50.4754,12.3683 +offchain.bostr.online,43.6532,-79.3832 +relay.bullishbounty.com:443,43.6532,-79.3832 +relay.wisp.talk:443,49.4543,11.0746 +relay2.veganostr.com,60.1699,24.9384 +relay.mappingbitcoin.com,43.6532,-79.3832 +nostr.liberty.fans,36.8767,-89.5879 +kotukonostr.onrender.com,37.7775,-122.397 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.hifish.org,47.4009,8.57814 +nostr.stakey.net:443,52.3676,4.90414 +relay.mccormick.cx,52.3563,4.95714 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay-us.zombi.cloudrodion.com,40.7862,-74.0743 +nostrelay.circum.space:443,52.2245,8.826 +relay.nostriot.com,41.5695,-83.9786 +schnorr.me:443,43.6532,-79.3832 +nostr.bond,50.1109,8.68213 +temp.iris.to,43.6532,-79.3832 +cache.trustr.ing,43.6548,-79.3885 +relay.snotr.nl:49999,51.9758,4.31389 +relay.bitos.space,43.6532,-79.3832 +nostr.azzamo.net,52.2633,21.0283 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.tdw.gg,34.0549,-118.243 +relay.44billion.net,43.6532,-79.3832 +maxq.descendant.io,43.6532,-79.3832 +public.crostr.com:443,43.6532,-79.3832 +nostr.easycryptosend.it,43.6532,-79.3832 +relay.ltgnet.work:8443,51.0511,-114.075 +nostr2.girino.org:443,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +nostr.azzamo.net:443,52.2633,21.0283 +nrs-01.darkcloudarcade.com,39.0997,-94.5786 +relay.sharegap.net,43.6532,-79.3832 +relay.guggero.org,46.5971,9.59652 +relay.pprgb.app,52.52,13.405 +relay.jmoose.rocks:443,32.8009,-96.8195 +relay.klabo.world,47.674,-122.122 +nostr.chaima.info:443,51.5072,-0.127586 +nostr-02.uid.ovh,50.9871,2.12554 +nostr.tac.lol,47.4748,-122.273 +relay.nostrian-conquest.com,41.223,-111.974 +hasky.chat,40.7862,-74.0743 +relay.satmaxt.xyz,43.6532,-79.3832 +bridge.tagomago.me,42.3601,-71.0589 +treuzkas.branruz.com,48.8575,2.35138 +relay.illuminodes.com,43.6532,-79.3832 +relay.damus.io,43.6532,-79.3832 +nostr.twinkle.lol,51.902,7.6657 +relay.getsafebox.app:443,43.6532,-79.3832 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +nostr.sovereignservices.xyz,43.6532,-79.3832 +nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 +vm-1734.lnvps.cloud,53.3498,-6.26031 +public.crostr.com,43.6532,-79.3832 +relay-can.zombi.cloudrodion.com,43.6532,-79.3832 +nostr.emanuelemiani.it,45.778,8.79414 +relay.ru.ac.th,13.7607,100.627 +relay.piazza.today,48.122,11.589 +support.flotilla.social,32.9483,-96.7299 +nostr-01.uid.ovh,50.9871,2.12554 +freelay.sovbit.host,60.1699,24.9384 +nostr.data.haus,50.4754,12.3683 +nostr-01.yakihonne.com:443,1.32123,103.695 +relay.wavlake.com:443,41.2619,-95.8608 +no.str.cr:443,8.96171,-83.5246 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.bowlafterbowl.com,32.9483,-96.7299 +strfry.bonsai.com:443,39.0438,-77.4874 +nostr.2b9t.xyz,34.0549,-118.243 +rele.speyhard.fi,51.5072,-0.127586 +relay.lacrypta.ar,43.6532,-79.3832 +infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +reraw.pbla2fish.cc,43.6532,-79.3832 +relay.nostr.space,43.6532,-79.3832 +relay.atsocy.com,43.6532,-79.3832 +nostr-relay.xbytez.io:443,50.6924,3.20113 +relay.earthly.city,34.1749,-118.54 +relay.noeudlibre.fr,50.6924,3.20113 +inbox.scuba323.com,40.8218,-74.45 +relay.getsafebox.app,43.6532,-79.3832 +relay.cyberguy.fyi,52.6907,4.8181 +bitchat.nostr1.com,40.7057,-74.0136 +nostrelay.circum.space,52.2245,8.826 +nostr.oxtr.dev:443,50.4754,12.3683 +relay.degmods.com,50.4754,12.3683 +relay.ohstr.com,43.6532,-79.3832 +relay.bnos.space,43.6532,-79.3832 +relay.novospes.com,43.6532,-79.3832 +relay.satsapp.me,50.4754,12.3683 +nostr.sathoarder.com:443,48.5734,7.75211 +nostr-01.yakihonne.com,1.32123,103.695 +relay.trotters.cc,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +nostr.mom,50.4754,12.3683 +nostr.vulpem.com,49.4543,11.0746 +nostr-dev.wellorder.net,45.5201,-122.99 +nostr.notribe.net,40.8302,-74.1299 +public.obelisk.ar,43.6532,-79.3832 +relay.mostr.pub,43.6532,-79.3832 diff --git a/app/src/main/assets/world_borders.geojson b/app/src/main/assets/world_borders.geojson new file mode 100644 index 00000000..a8cddd07 --- /dev/null +++ b/app/src/main/assets/world_borders.geojson @@ -0,0 +1 @@ +{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[-130.54,54.8],[-129.98,55.28],[-130.01,55.92],[-131.71,56.55],[-132.73,57.69],[-133.36,58.41],[-134.27,58.86],[-134.94,59.27],[-135.48,59.79],[-136.48,59.46],[-137.45,58.91],[-138.34,59.56],[-139.04,60],[-140.01,60.28],[-141.0,60.31],[-140.99,66.0],[-140.99,69.71]]},{"type":"LineString","coordinates":[[-117.13,32.54],[-115.99,32.61],[-114.72,32.72],[-114.81,32.53],[-113.3,32.04],[-111.02,31.33],[-109.04,31.34],[-108.24,31.34],[-108.24,31.75],[-106.51,31.75],[-106.14,31.4],[-105.63,31.08],[-105.04,30.64],[-104.71,30.12],[-104.46,29.57],[-103.94,29.27],[-103.11,28.97],[-102.48,29.76],[-101.66,29.78],[-100.96,29.38],[-100.46,28.7],[-100.11,28.11],[-99.52,27.54],[-99.3,26.84],[-99.02,26.37],[-98.24,26.06],[-97.53,25.84],[-97.14,25.87]]},{"type":"LineString","coordinates":[[-90.1,13.74],[-90.06,13.88],[-89.72,14.13],[-89.53,14.24],[-89.59,14.36],[-89.35,14.42]]},{"type":"LineString","coordinates":[[-92.23,14.54],[-92.2,14.83],[-92.09,15.06],[-92.23,15.25],[-91.75,16.07],[-90.46,16.07],[-90.44,16.41],[-90.6,16.47],[-90.71,16.69],[-91.08,16.92],[-91.45,17.25],[-91.0,17.25],[-91.0,17.82],[-90.07,17.82],[-89.14,17.81]]},{"type":"LineString","coordinates":[[-88.93,15.89],[-89.23,15.89],[-89.15,17.02],[-89.14,17.81]]},{"type":"LineString","coordinates":[[-89.35,14.42],[-89.06,14.34],[-88.84,14.14],[-88.54,13.98],[-88.5,13.85],[-88.07,13.96],[-87.86,13.89],[-87.72,13.79],[-87.79,13.38]]},{"type":"LineString","coordinates":[[-87.32,12.98],[-87.01,13.03],[-86.88,13.25],[-86.73,13.26],[-86.76,13.75],[-86.52,13.78],[-86.31,13.77],[-86.1,14.04],[-85.8,13.84],[-85.7,13.96],[-85.51,14.08],[-85.17,14.35],[-85.15,14.56],[-85.05,14.55],[-84.92,14.79],[-84.82,14.82],[-84.65,14.67],[-84.45,14.62],[-84.23,14.75],[-83.98,14.75],[-83.63,14.88],[-83.49,15.02],[-83.15,15.0]]},{"type":"LineString","coordinates":[[-82.97,8.23],[-82.91,8.42],[-82.83,8.63],[-82.87,8.81],[-82.72,8.93],[-82.93,9.07],[-82.93,9.48],[-82.55,9.57]]},{"type":"LineString","coordinates":[[-83.66,10.94],[-83.9,10.73],[-84.19,10.79],[-84.36,11.0],[-84.67,11.08],[-84.9,10.95],[-85.56,11.22],[-85.71,11.09]]},{"type":"LineString","coordinates":[[-77.35,8.67],[-77.47,8.52],[-77.24,7.94],[-77.43,7.64],[-77.75,7.71],[-77.88,7.22]]},{"type":"LineString","coordinates":[[-71.33,11.78],[-71.97,11.61],[-72.23,11.11],[-72.61,10.82],[-72.91,10.45],[-73.03,9.74],[-73.3,9.15],[-72.79,9.09],[-72.66,8.63],[-72.44,8.41],[-72.36,8.0],[-72.48,7.63],[-72.44,7.42],[-72.2,7.34],[-71.96,6.99],[-70.67,7.09],[-70.09,6.96],[-69.39,6.1],[-68.99,6.21],[-68.27,6.15],[-67.7,6.27],[-67.34,6.1],[-67.52,5.56],[-67.74,5.22],[-67.82,4.5],[-67.62,3.84],[-67.34,3.54],[-67.3,3.32],[-67.81,2.82],[-67.45,2.6],[-67.18,2.25],[-66.88,1.25],[-66.88,1.25]]},{"type":"LineString","coordinates":[[-75.37,-0.15],[-75.23,-0.91],[-75.55,-1.56],[-76.64,-2.61],[-77.84,-3.0],[-78.45,-3.87],[-78.64,-4.55],[-79.21,-4.96],[-79.62,-4.45],[-80.03,-4.35],[-80.44,-4.43],[-80.47,-4.06],[-80.18,-3.82],[-80.3,-3.4]]},{"type":"LineString","coordinates":[[-69.53,-10.95],[-70.09,-11.12],[-70.55,-11.01],[-70.48,-9.49],[-71.3,-10.08],[-72.18,-10.05],[-72.56,-9.52],[-73.23,-9.46],[-73.02,-9.03],[-73.57,-8.42],[-73.99,-7.52],[-73.72,-7.34],[-73.72,-6.92],[-73.12,-6.63],[-73.22,-6.09],[-72.96,-5.74],[-72.89,-5.27],[-71.75,-4.59],[-70.93,-4.4],[-70.79,-4.25],[-69.89,-4.3]]},{"type":"LineString","coordinates":[[-66.96,-54.9],[-67.56,-54.87],[-68.63,-54.87],[-68.63,-52.64]]},{"type":"LineString","coordinates":[[-68.63,-52.3],[-69.5,-52.14],[-71.91,-52.01],[-72.33,-51.43],[-72.31,-50.68],[-72.98,-50.74],[-73.33,-50.38],[-73.42,-49.32],[-72.65,-48.88],[-72.33,-48.24],[-72.45,-47.74],[-71.92,-46.88],[-71.55,-45.56],[-71.66,-44.97],[-71.22,-44.78],[-71.33,-44.41],[-71.79,-44.21],[-71.46,-43.79],[-71.92,-43.41],[-72.15,-42.25],[-71.75,-42.05],[-71.92,-40.83],[-71.68,-39.81],[-71.41,-38.92],[-70.81,-38.55],[-71.12,-37.58],[-71.12,-36.66],[-70.36,-36.01],[-70.39,-35.17],[-69.82,-34.19],[-69.81,-33.27],[-70.07,-33.09],[-70.54,-31.36],[-69.92,-30.34],[-70.01,-29.37],[-69.66,-28.46],[-69.0,-27.52],[-68.3,-26.9],[-68.59,-26.51],[-68.39,-26.18],[-68.42,-24.52],[-67.33,-24.03],[-66.99,-22.99],[-67.11,-22.74]]},{"type":"LineString","coordinates":[[-58.43,-33.91],[-58.35,-33.26],[-58.13,-33.04],[-58.14,-32.04],[-57.87,-31.02],[-57.63,-30.22]]},{"type":"LineString","coordinates":[[-54.63,-25.74],[-54.79,-26.62],[-55.7,-27.39],[-56.49,-27.55],[-57.61,-27.4],[-58.62,-27.12],[-57.63,-25.6],[-57.78,-25.16],[-58.81,-24.77],[-60.03,-24.03],[-60.85,-23.88],[-62.69,-22.25]]},{"type":"LineString","coordinates":[[-58.17,-20.18],[-58.17,-20.18],[-57.87,-20.73],[-57.94,-22.09],[-56.88,-22.28],[-56.47,-22.09],[-55.8,-22.36],[-55.61,-22.66],[-55.52,-23.57],[-55.4,-23.96],[-55.03,-24.0],[-54.65,-23.84],[-54.29,-24.02],[-54.29,-24.57],[-54.43,-25.16],[-54.63,-25.74]]},{"type":"LineString","coordinates":[[-59.76,8.37],[-60.55,7.78],[-60.64,7.42],[-60.3,7.04],[-60.54,6.86],[-61.16,6.7],[-61.14,6.23],[-61.41,5.96],[-60.73,5.2]]},{"type":"LineString","coordinates":[[-56.54,1.9],[-56.78,1.86],[-57.34,1.95],[-57.66,1.68],[-58.11,1.51],[-58.43,1.46],[-58.54,1.27],[-59.03,1.32],[-59.65,1.79],[-59.72,2.25],[-59.97,2.76],[-59.82,3.61],[-59.54,3.96],[-59.77,4.42],[-60.11,4.57],[-59.98,5.01],[-60.21,5.24],[-60.73,5.2]]},{"type":"LineString","coordinates":[[-54.52,2.31],[-55.1,2.52],[-55.57,2.42],[-55.97,2.51],[-56.07,2.22],[-55.91,2.02],[-56.0,1.82],[-56.54,1.9]]},{"type":"LineString","coordinates":[[-51.66,4.16],[-52.25,3.24],[-52.56,2.5],[-52.94,2.12],[-53.42,2.05],[-53.55,2.33],[-53.78,2.38],[-54.09,2.11],[-54.52,2.31]]},{"type":"LineString","coordinates":[[115.45,5.45],[115.41,4.96],[115.35,4.32],[114.87,4.35],[114.66,4.01],[114.2,4.53]]},{"type":"LineString","coordinates":[[-6.2,53.87],[-6.95,54.07],[-7.57,54.06],[-7.37,54.6],[-7.57,55.13]]},{"type":"LineString","coordinates":[[-16.71,13.59],[-15.62,13.62],[-15.4,13.86],[-15.08,13.88],[-14.69,13.63],[-14.38,13.63],[-14.05,13.79],[-13.84,13.51],[-14.28,13.28],[-14.71,13.3],[-15.14,13.51],[-15.51,13.28],[-15.69,13.27],[-15.93,13.13],[-16.84,13.15]]},{"type":"LineString","coordinates":[[35.55,32.39],[35.55,31.78],[35.4,31.49]]},{"type":"LineString","coordinates":[[28.98,-28.96],[29.33,-29.26],[29.02,-29.74],[28.85,-30.07],[28.29,-30.23],[28.11,-30.55],[27.75,-30.65],[27.0,-29.88],[27.53,-29.24],[28.07,-28.85],[28.54,-28.65],[28.98,-28.96]]},{"type":"LineString","coordinates":[[-8.67,27.66],[-8.67,27.59],[-8.68,27.4],[-8.69,25.88],[-11.97,25.93],[-11.94,23.37],[-12.87,23.28],[-13.12,22.77],[-12.93,21.33],[-16.85,21.33],[-17.06,21.0]]},{"type":"LineString","coordinates":[[-8.68,27.4],[-4.92,24.97],[-4.92,24.97]]},{"type":"LineString","coordinates":[[-12.17,14.62],[-12.12,13.99],[-11.93,13.42],[-11.55,13.14],[-11.47,12.75],[-11.51,12.44]]},{"type":"LineString","coordinates":[[-13.7,12.59],[-13.72,12.25],[-13.83,12.14],[-13.74,11.81],[-13.9,11.68],[-14.12,11.68],[-14.38,11.51],[-14.69,11.53],[-15.13,11.04]]},{"type":"LineString","coordinates":[[-11.51,12.44],[-11.46,12.08],[-11.3,12.08],[-11.04,12.21],[-10.87,12.18],[-10.59,11.92],[-10.17,11.84],[-9.89,12.06],[-9.57,12.19],[-9.33,12.33],[-9.13,12.31],[-8.91,12.09],[-8.79,11.81],[-8.38,11.39],[-8.58,11.14],[-8.62,10.81],[-8.41,10.91],[-8.28,10.79],[-8.34,10.49],[-8.03,10.21],[-8.03,10.21]]},{"type":"LineString","coordinates":[[-11.44,6.79],[-11.2,7.11],[-11.15,7.4],[-10.7,7.94],[-10.23,8.41]]},{"type":"LineString","coordinates":[[-8.44,7.69],[-8.49,7.4],[-8.39,6.91],[-8.6,6.47],[-8.31,6.19],[-7.99,6.13],[-7.57,5.71],[-7.54,5.31],[-7.64,5.19],[-7.71,4.36]]},{"type":"LineString","coordinates":[[8.42,36.95],[8.22,36.43],[8.38,35.48],[8.14,34.66],[7.52,34.1],[7.61,33.34],[8.43,32.75],[8.44,32.51],[9.06,32.1],[9.48,30.31],[9.48,30.31]]},{"type":"LineString","coordinates":[[11.49,33.14],[11.43,32.37],[10.94,32.08],[10.64,31.76],[9.95,31.38],[10.06,30.96],[9.97,30.54],[9.48,30.31]]},{"type":"LineString","coordinates":[[25.16,31.57],[24.8,31.09],[24.96,30.66],[24.7,30.04],[25,29.24],[25,25.68],[25,22],[25,22]]},{"type":"LineString","coordinates":[[4.27,19.16],[4.27,16.85],[3.72,16.18],[3.64,15.57],[2.75,15.41],[1.39,15.32],[1.02,14.97],[0.37,14.93],[0.37,14.93]]},{"type":"LineString","coordinates":[[0.37,14.93],[0.3,14.44],[0.43,13.99],[0.99,13.34],[1.02,12.85],[2.18,12.63],[2.15,11.94],[2.15,11.94]]},{"type":"LineString","coordinates":[[-2.86,4.99],[-2.81,5.39],[-3.24,6.25],[-2.98,7.38],[-2.56,8.22],[-2.83,9.64]]},{"type":"LineString","coordinates":[[1.06,5.93],[0.84,6.28],[0.57,6.91],[0.49,7.41],[0.71,8.31],[0.46,8.68],[0.37,9.46],[0.37,10.19],[-0.05,10.71],[0.02,11.02]]},{"type":"LineString","coordinates":[[0.9,11.0],[0.77,10.47],[1.08,10.18],[1.43,9.83],[1.46,9.33],[1.66,9.13],[1.62,6.83],[1.87,6.14]]},{"type":"LineString","coordinates":[[2.15,11.94],[2.49,12.23],[2.85,12.24],[3.61,11.66],[3.61,11.66]]},{"type":"LineString","coordinates":[[3.61,11.66],[3.68,12.55],[3.97,12.96],[4.11,13.53],[4.37,13.75],[5.44,13.87],[6.45,13.49],[6.82,13.12],[7.33,13.1],[7.8,13.34],[9.01,12.83],[9.52,12.85],[10.11,13.28],[10.7,13.25],[10.99,13.39],[11.53,13.33],[12.3,13.04],[13.08,13.6],[13.32,13.56]]},{"type":"LineString","coordinates":[[36.87,22],[32.9,22],[29.02,22],[25,22]]},{"type":"LineString","coordinates":[[14.85,22.86],[15.1,21.31],[15.47,21.05],[15.49,20.73],[15.9,20.39],[15.69,19.96],[15.3,17.93],[15.25,16.63],[13.97,15.68],[13.54,14.37],[13.96,14.0],[13.95,13.35],[14.6,13.33],[14.5,12.86],[14.5,12.86]]},{"type":"LineString","coordinates":[[23.84,19.58],[23.89,15.61],[23.02,15.68],[22.57,14.94],[22.3,14.33],[22.51,14.09],[22.18,13.79],[22.3,13.37],[22.04,12.96],[21.94,12.59],[22.29,12.65],[22.5,12.26],[22.51,11.68],[22.88,11.38],[22.86,11.14]]},{"type":"LineString","coordinates":[[15.28,7.42],[14.78,6.41],[14.54,6.23],[14.46,5.45],[14.56,5.03],[14.48,4.73],[14.95,4.21],[15.04,3.85],[15.41,3.34],[15.86,3.01],[15.91,2.56],[16.01,2.27],[16.01,2.27]]},{"type":"LineString","coordinates":[[11.28,2.26],[11.29,1.06],[9.83,1.07],[9.49,1.01]]},{"type":"LineString","coordinates":[[13.08,2.27],[13.0,1.83],[13.28,1.31],[14.03,1.4],[14.28,1.2],[13.84,0.04],[14.32,-0.55],[14.43,-1.33],[14.3,-2.0],[13.99,-2.47],[13.11,-2.43],[12.58,-1.95],[12.5,-2.39],[11.82,-2.51],[11.48,-2.77],[11.86,-3.43],[11.09,-3.98]]},{"type":"LineString","coordinates":[[16.01,2.27],[16.54,3.2],[17.13,3.73],[17.81,3.56],[18.45,3.5]]},{"type":"LineString","coordinates":[[22.86,11.14],[22.98,10.71],[23.55,10.09],[23.56,9.68],[23.39,9.27],[23.46,8.95],[23.81,8.67],[23.81,8.67]]},{"type":"LineString","coordinates":[[36.43,14.42],[37.59,14.21],[37.91,14.96],[38.51,14.51],[39.1,14.74],[39.34,14.53],[40.03,14.52],[40.9,14.12],[41.16,13.77],[41.6,13.45],[42.01,12.87],[42.35,12.54],[42.35,12.54]]},{"type":"LineString","coordinates":[[43.15,11.46],[42.78,10.93],[42.78,10.93]]},{"type":"LineString","coordinates":[[29.58,-1.34],[29.82,-1.44],[30.42,-1.13],[30.42,-1.13]]},{"type":"LineString","coordinates":[[30.42,-1.13],[30.82,-1.7],[30.76,-2.29],[30.47,-2.41]]},{"type":"LineString","coordinates":[[23.91,-10.93],[24.02,-11.24],[23.9,-11.72],[24.08,-12.19],[23.93,-12.57],[24.02,-12.91],[21.93,-12.9],[21.89,-16.08],[22.56,-16.9],[23.22,-17.52]]},{"type":"LineString","coordinates":[[23.22,-17.52],[24.03,-17.3],[24.68,-17.35],[25.08,-17.58],[25.08,-17.66],[24.93,-17.72]]},{"type":"LineString","coordinates":[[32.07,-26.73],[31.87,-27.18],[31.28,-27.29],[30.69,-26.74],[30.68,-26.4],[30.95,-26.02],[31.04,-25.73],[31.33,-25.66],[31.84,-25.84]]},{"type":"LineString","coordinates":[[25.26,-17.74],[25.65,-18.54],[25.85,-18.71],[26.16,-19.29],[27.3,-20.39],[27.72,-20.5],[27.73,-20.85],[28.02,-21.49],[28.79,-21.64],[29.43,-22.09],[29.43,-22.09]]},{"type":"LineString","coordinates":[[30.27,-15.51],[30.34,-15.88],[31.17,-15.86],[31.64,-16.07],[31.85,-16.32],[32.33,-16.39],[32.85,-16.71],[32.85,-17.98],[32.65,-18.67],[32.61,-19.42],[32.77,-19.72],[32.66,-20.3],[32.51,-20.4],[32.25,-21.12],[31.19,-22.25]]},{"type":"LineString","coordinates":[[32.83,-26.74],[32.07,-26.73]]},{"type":"LineString","coordinates":[[-9.03,41.88],[-8.67,42.13],[-8.26,42.28],[-8.01,41.79],[-7.42,41.79],[-7.25,41.92],[-6.67,41.88],[-6.39,41.38],[-6.85,41.11],[-6.86,40.33],[-7.03,40.18],[-7.07,39.71],[-7.5,39.63],[-7.1,39.03],[-7.37,38.37],[-7.03,38.08],[-7.17,37.8],[-7.54,37.43],[-7.45,37.1]]},{"type":"LineString","coordinates":[[2.99,42.47],[1.83,42.34],[0.7,42.8],[0.34,42.58],[-1.5,43.03],[-1.9,43.42]]},{"type":"LineString","coordinates":[[9.92,54.98],[9.28,54.83],[8.53,54.96]]},{"type":"LineString","coordinates":[[6.19,49.46],[6.66,49.2],[8.1,49.02],[7.59,48.33],[7.47,47.62],[7.47,47.62]]},{"type":"LineString","coordinates":[[14.12,53.76],[14.35,53.25],[14.07,52.98],[14.44,52.62],[14.69,52.09],[14.61,51.75],[15.02,51.11],[15.02,51.11]]},{"type":"LineString","coordinates":[[9.59,47.53],[9.63,47.35],[9.48,47.1],[9.93,46.92],[10.44,46.89]]},{"type":"LineString","coordinates":[[10.44,46.89],[11.05,46.75],[11.16,46.94],[12.15,47.12],[12.38,46.77],[13.81,46.51]]},{"type":"LineString","coordinates":[[19.66,54.43],[20.89,54.31],[22.73,54.33],[23.24,54.22],[23.48,53.91],[23.53,53.47],[23.8,53.09],[23.8,52.69],[23.2,52.49],[23.51,52.02],[23.53,51.58],[24.03,50.71],[23.92,50.42],[23.43,50.31],[22.52,49.48],[22.78,49.03],[22.56,49.09],[21.61,49.47],[20.89,49.33],[20.42,49.43],[19.83,49.22],[19.32,49.57],[18.91,49.44],[18.85,49.5],[18.39,49.99],[17.65,50.05],[17.55,50.36],[16.87,50.47],[16.72,50.22],[16.18,50.42],[16.24,50.7],[15.49,50.78],[15.02,51.11]]},{"type":"LineString","coordinates":[[24.31,57.79],[25.16,57.97],[25.6,57.85],[26.46,57.48],[27.29,57.47],[27.29,57.47]]},{"type":"LineString","coordinates":[[27.98,59.48],[28.13,59.3],[27.42,58.72],[27.72,57.79],[27.29,57.47]]},{"type":"LineString","coordinates":[[28.18,56.17],[29.23,55.92],[29.37,55.67],[29.9,55.79],[30.87,55.55],[30.97,55.08],[30.76,54.81],[31.38,54.16],[31.79,53.97],[31.73,53.79],[32.41,53.62],[32.69,53.35],[32.3,53.13],[31.5,53.17],[31.31,53.07],[31.54,52.74],[31.79,52.1],[31.79,52.1]]},{"type":"LineString","coordinates":[[6.91,53.48],[7.09,53.14],[6.84,52.23],[6.59,51.85],[5.99,51.85],[6.16,50.8]]},{"type":"LineString","coordinates":[[6.16,50.8],[6.04,50.13]]},{"type":"LineString","coordinates":[[6.04,50.13],[6.24,49.9],[6.19,49.46],[6.19,49.46]]},{"type":"LineString","coordinates":[[21.27,55.19],[22.32,55.02],[22.76,54.86],[22.65,54.58],[22.73,54.33]]},{"type":"LineString","coordinates":[[21.06,56.03],[22.2,56.34],[23.88,56.27],[24.86,56.37],[25.0,56.16],[25.53,56.1],[26.49,55.62]]},{"type":"LineString","coordinates":[[18.85,49.5],[18.55,49.49],[18.4,49.31],[18.17,49.27],[18.11,49.04],[17.91,49.0],[17.89,48.9],[17.55,48.8],[17.1,48.82],[16.96,48.6]]},{"type":"LineString","coordinates":[[16.96,48.6],[16.88,48.47],[16.98,48.12]]},{"type":"LineString","coordinates":[[22.09,48.42],[22.64,48.15],[22.71,47.88],[22.1,47.67],[21.63,46.99],[21.02,46.32],[20.22,46.13],[19.6,46.17],[18.83,45.91],[18.46,45.76],[17.63,45.95],[16.88,46.38],[16.56,46.5],[16.37,46.84],[16.2,46.85]]},{"type":"LineString","coordinates":[[18.45,42.48],[18.56,42.65],[17.67,43.03],[17.3,43.45],[16.92,43.67],[16.46,44.04],[16.24,44.35],[15.75,44.82],[15.96,45.23],[16.32,45.0],[16.53,45.21],[17.0,45.23],[17.86,45.07],[18.55,45.08],[19.01,44.86]]},{"type":"LineString","coordinates":[[22.56,49.09],[22.28,48.83],[22.09,48.42],[21.87,48.32],[20.8,48.62],[20.47,48.56],[20.24,48.33],[19.77,48.2],[19.66,48.27],[19.17,48.11],[18.78,48.08],[18.7,47.88],[17.86,47.76],[17.49,47.87],[16.98,48.12]]},{"type":"LineString","coordinates":[[16.56,46.5],[15.77,46.24],[15.67,45.83],[15.32,45.73],[15.33,45.45],[14.94,45.47],[14.6,45.63],[14.41,45.47],[13.72,45.5]]},{"type":"LineString","coordinates":[[26.62,48.22],[26.92,48.12],[27.23,47.83],[27.55,47.41],[28.13,46.81],[28.16,46.37],[28.05,45.94],[28.23,45.49]]},{"type":"LineString","coordinates":[[28.56,43.71],[27.97,43.81],[27.24,44.18],[26.07,43.94],[25.57,43.69],[24.1,43.74],[23.33,43.9],[22.94,43.82],[22.66,44.23],[22.66,44.23]]},{"type":"LineString","coordinates":[[22.66,44.23],[22.41,44.01],[22.5,43.64],[22.99,43.21],[22.6,42.9],[22.44,42.58],[22.54,42.46],[22.38,42.32],[22.36,42.32]]},{"type":"LineString","coordinates":[[22.71,47.88],[23.14,48.1],[23.76,47.99],[24.4,47.98],[24.87,47.74],[25.21,47.89],[25.95,47.99],[26.2,48.22],[26.62,48.22]]},{"type":"LineString","coordinates":[[31.79,52.1],[32.16,52.06],[32.41,52.29],[32.72,52.24],[33.75,52.34],[34.39,51.77],[34.14,51.57],[34.22,51.26],[35.02,51.21],[35.38,50.77],[35.36,50.58],[36.63,50.23],[37.39,50.38],[38.01,49.92],[38.6,49.93],[40.07,49.6],[40.08,49.31],[39.67,48.78],[39.9,48.23],[39.74,47.9],[38.77,47.83],[38.26,47.55],[38.22,47.1]]},{"type":"LineString","coordinates":[[28.0,42.01],[27.14,42.14],[26.12,41.83],[26.11,41.33],[25.2,41.23],[24.49,41.58],[23.69,41.31],[22.95,41.34],[22.88,42.0],[22.38,42.32]]},{"type":"LineString","coordinates":[[22.95,41.34],[22.76,41.3],[22.6,41.13],[22.06,41.15],[21.67,40.93],[21.02,40.84]]},{"type":"LineString","coordinates":[[26.12,41.83],[26.6,41.56],[26.29,40.94],[26.06,40.82]]},{"type":"LineString","coordinates":[[21.02,40.84],[21,40.58],[20.68,40.44],[20.61,40.11],[20.15,39.62]]},{"type":"LineString","coordinates":[[41.55,41.54],[42.62,41.58],[43.58,41.09]]},{"type":"LineString","coordinates":[[39.95,43.44],[40.08,43.55],[40.92,43.38],[42.39,43.22],[43.76,42.74],[43.93,42.55],[44.54,42.71],[45.47,42.5],[45.78,42.09],[46.4,41.86],[46.15,41.72],[46.64,41.18],[46.5,41.06],[45.96,41.12],[45.22,41.41],[44.97,41.25]]},{"type":"LineString","coordinates":[[28.59,69.06],[28.45,68.36],[29.98,67.7],[29.05,66.94],[30.22,65.81],[29.54,64.95],[30.44,64.2],[30.04,63.55],[31.52,62.87],[31.14,62.36],[30.21,61.78],[28.07,60.5]]},{"type":"LineString","coordinates":[[35.4,31.48],[35.42,31.1],[34.92,29.5]]},{"type":"LineString","coordinates":[[38.79,33.38],[39.2,32.16],[39.2,32.16]]},{"type":"LineString","coordinates":[[47.97,29.98],[47.3,30.06],[46.57,29.1],[46.57,29.1]]},{"type":"LineString","coordinates":[[48.57,29.93],[48.01,30.45],[48.0,30.99],[47.69,30.98],[47.85,31.71],[47.33,32.47],[46.11,33.02],[45.42,33.97],[45.65,34.75],[46.15,35.09],[46.08,35.68],[45.42,35.98],[44.77,37.17]]},{"type":"LineString","coordinates":[[53.92,37.2],[54.8,37.39],[55.51,37.96],[56.18,37.94],[56.62,38.12],[57.33,38.03],[58.44,37.52],[59.23,37.41],[60.38,36.53],[61.12,36.49],[61.21,35.65]]},{"type":"LineString","coordinates":[[53.11,16.65],[52.78,17.35],[52,19],[52,19]]},{"type":"LineString","coordinates":[[42.78,16.35],[43.22,16.67],[43.12,17.09],[43.38,17.58],[43.79,17.32],[44.06,17.41],[45.22,17.43],[45.4,17.33],[46.37,17.23],[46.75,17.28],[47,16.95],[47.47,17.12],[48.18,18.17],[49.12,18.62],[52,19]]},{"type":"LineString","coordinates":[[20.65,69.11],[21.98,68.62],[23.54,67.94],[23.57,66.4],[23.9,66.01]]},{"type":"LineString","coordinates":[[48.42,28.55],[47.71,28.53],[47.46,29.0],[46.57,29.1]]},{"type":"LineString","coordinates":[[51.39,24.63],[51.11,24.56],[50.81,24.75]]},{"type":"LineString","coordinates":[[56.07,26.06],[56.26,25.71]]},{"type":"LineString","coordinates":[[56.4,24.92],[55.89,24.92],[55.8,24.27],[55.98,24.13],[55.53,23.93],[55.53,23.52],[55.23,23.11],[55.21,22.71],[55.21,22.71]]},{"type":"LineString","coordinates":[[49.1,46.4],[48.59,46.56],[48.69,47.08],[48.06,47.74],[47.32,47.72],[46.47,48.39],[47.04,49.15],[46.75,49.36],[47.55,50.45],[48.58,49.87],[48.7,50.61],[50.77,51.69],[52.33,51.72],[54.53,51.03],[55.72,50.62],[56.78,51.04],[58.36,51.06],[59.64,50.55],[59.93,50.84],[61.34,50.8],[61.59,51.27],[59.97,51.96],[60.93,52.45],[60.74,52.72],[61.7,52.98],[60.98,53.67],[61.44,54.01],[65.18,54.35],[65.67,54.6],[68.17,54.97],[69.07,55.39],[70.87,55.17],[71.18,54.13],[72.22,54.38],[73.51,54.04],[73.43,53.49],[74.38,53.55],[76.89,54.49],[76.53,54.18],[77.8,53.4],[80.04,50.86],[80.57,51.39],[81.95,50.81],[83.38,51.07],[83.94,50.89],[84.42,50.31],[85.12,50.12],[85.54,49.69],[86.83,49.83],[87.36,49.22]]},{"type":"LineString","coordinates":[[70.96,42.27],[71.26,42.17],[70.42,41.52],[71.16,41.14],[71.87,41.39],[73.06,40.87],[71.77,40.15],[71.01,40.24],[70.6,40.22],[70.46,40.5],[70.67,40.96],[69.33,40.73],[69.01,40.09],[68.54,39.53],[67.7,39.58],[67.44,39.14],[68.18,38.9],[68.39,38.16],[67.83,37.15],[67.83,37.15]]},{"type":"LineString","coordinates":[[66.52,37.36],[66.22,37.39],[65.75,37.66],[65.59,37.31],[64.75,37.11],[64.55,36.31],[63.98,36.01],[63.19,35.86],[62.98,35.4],[62.23,35.27],[61.21,35.65]]},{"type":"LineString","coordinates":[[67.83,37.15],[68.14,37.02],[68.86,37.34],[69.2,37.15],[69.52,37.61],[70.12,37.59],[70.27,37.74],[70.38,38.14],[70.81,38.49],[71.35,38.26],[71.24,37.95],[71.54,37.91],[71.45,37.07],[71.84,36.74],[72.19,36.95],[72.64,37.05],[73.26,37.5],[73.95,37.42],[74.98,37.42]]},{"type":"LineString","coordinates":[[80.26,42.35],[80.12,42.12],[78.54,41.58],[78.19,41.19],[76.9,41.07],[76.53,40.43],[75.47,40.56],[74.78,40.37],[73.82,39.89],[73.96,39.66],[73.68,39.43]]},{"type":"LineString","coordinates":[[73.68,39.43],[73.93,38.51],[74.26,38.61],[74.86,38.38],[74.83,37.99],[74.98,37.42]]},{"type":"LineString","coordinates":[[74.45,32.76],[75.26,32.27],[75.26,32.27]]},{"type":"LineString","coordinates":[[97.33,28.26],[97.4,27.88],[97.05,27.7],[97.13,27.08],[96.42,27.26],[95.12,26.57],[95.16,26.0],[94.6,25.16],[94.55,24.68],[94.11,23.85],[93.33,24.08],[93.29,23.04],[93.06,22.7],[93.17,22.28],[92.67,22.04]]},{"type":"LineString","coordinates":[[88.12,27.88],[86.95,27.97],[85.82,28.2],[85.01,28.64],[84.23,28.84],[83.9,29.32],[83.34,29.46],[82.33,30.12],[81.53,30.42],[81.11,30.18]]},{"type":"LineString","coordinates":[[88.81,27.3],[89.48,28.04],[90.02,28.3],[90.73,28.06],[91.26,28.04],[91.7,27.77]]},{"type":"LineString","coordinates":[[92.37,20.67],[92.3,21.48],[92.65,21.32],[92.67,22.04]]},{"type":"LineString","coordinates":[[97.33,28.26],[97.91,28.34],[98.25,27.75],[98.68,27.51],[98.71,26.74],[98.67,25.92],[97.72,25.08],[97.6,23.9],[98.66,24.06],[98.9,23.14],[99.53,22.95],[99.24,22.12],[99.98,21.74],[100.42,21.56],[101.15,21.85],[101.18,21.44],[101.18,21.44]]},{"type":"LineString","coordinates":[[100.12,20.42],[100.55,20.11],[100.61,19.51],[101.28,19.46],[101.04,18.41],[101.06,17.51],[102.11,18.11],[102.41,17.93],[103.0,17.96],[103.2,18.31],[103.96,18.24],[104.72,17.43],[104.78,16.44],[105.59,15.57],[105.54,14.72],[105.22,14.27],[105.22,14.27],[104.28,14.42],[102.99,14.23],[102.35,13.39],[102.58,12.19]]},{"type":"LineString","coordinates":[[101.18,21.44],[101.27,21.2],[101.8,21.17],[101.65,22.32],[102.17,22.46]]},{"type":"LineString","coordinates":[[107.38,14.2],[107.61,13.54],[107.49,12.34],[105.81,11.57],[106.25,10.96],[105.2,10.89],[104.33,10.49]]},{"type":"LineString","coordinates":[[102.17,22.46],[102.71,22.71],[103.5,22.7],[104.48,22.82],[105.33,23.35],[105.81,22.98],[106.73,22.79],[106.57,22.22],[107.04,21.81],[108.05,21.55]]},{"type":"LineString","coordinates":[[102.14,6.22],[101.81,5.81],[101.15,5.69],[101.08,6.2],[100.26,6.64],[100.09,6.46]]},{"type":"LineString","coordinates":[[117.88,4.14],[117.02,4.31],[115.87,4.31],[115.52,3.17],[115.13,2.82],[114.62,1.43],[113.81,1.22],[112.86,1.5],[112.38,1.41],[111.8,0.9],[111.16,0.98],[110.51,0.77],[109.83,1.34],[109.66,2.01]]},{"type":"LineString","coordinates":[[87.75,49.3],[88.81,49.47],[90.71,50.33],[92.23,50.8],[93.1,50.5],[94.15,50.48],[94.82,50.01],[95.81,49.98],[97.26,49.73],[98.23,50.42],[97.83,51.01],[98.86,52.05],[99.98,51.63],[100.89,51.52],[102.07,51.26],[102.26,50.51],[103.68,50.09],[104.62,50.28],[105.89,50.41],[106.89,50.27],[107.87,49.79],[108.48,49.28],[109.4,49.29],[110.66,49.13],[111.58,49.38],[112.9,49.54],[114.36,50.25],[114.96,50.14],[115.49,49.81],[116.68,49.89]]},{"type":"LineString","coordinates":[[124.27,39.93],[125.08,40.57],[126.18,41.11],[126.87,41.82],[127.34,41.5],[128.21,41.47],[128.05,41.99],[129.6,42.42],[129.99,42.99],[130.64,42.4]]},{"type":"LineString","coordinates":[[128.35,38.61],[128.21,38.37],[127.78,38.3],[127.07,38.26],[126.68,37.8],[126.24,37.84],[126.17,37.75]]},{"type":"LineString","coordinates":[[124.97,-8.89],[125.07,-9.09],[125.09,-9.39]]},{"type":"LineString","coordinates":[[141.0,-2.6],[141.02,-5.86],[141.03,-9.12]]},{"type":"LineString","coordinates":[[11.91,-5.04],[12.32,-4.61],[12.62,-4.44],[13.0,-4.78]]},{"type":"LineString","coordinates":[[19.01,44.86],[19.37,44.86],[19.12,44.42],[19.6,44.04],[19.45,43.57],[19.22,43.52]]},{"type":"LineString","coordinates":[[48.58,41.81],[47.99,41.41],[47.82,41.15],[47.37,41.22],[46.69,41.83],[46.4,41.86]]},{"type":"LineString","coordinates":[[42.35,37.23],[41.84,36.61],[41.29,36.36],[41.38,35.63],[41.01,34.42],[38.79,33.38],[38.79,33.38]]},{"type":"LineString","coordinates":[[35.82,33.28],[35.55,33.26],[35.55,33.26]]},{"type":"LineString","coordinates":[[32.73,35.14],[32.92,35.09],[33.19,35.17],[33.38,35.16],[33.46,35.1],[33.48,35.0],[33.53,35.04],[33.68,35.02],[33.87,35.09],[33.97,35.06]]},{"type":"LineString","coordinates":[[19.22,43.52],[19.48,43.35],[19.63,43.21],[19.96,43.11],[20.34,42.9],[20.26,42.81]]},{"type":"LineString","coordinates":[[44.97,41.25],[45.18,40.99],[45.56,40.81],[45.36,40.56],[45.89,40.22],[45.61,39.9],[46.03,39.63],[46.48,39.46],[46.51,38.77],[46.51,38.77]]},{"type":"LineString","coordinates":[[-122.84,49],[-120,49],[-117.03,49],[-116.05,49],[-113,49],[-110.05,49],[-107.05,49],[-104.05,49.0],[-100.65,49],[-97.23,49.0],[-95.16,49],[-95.16,49.38],[-94.82,49.39],[-94.64,48.84],[-94.33,48.67],[-93.63,48.61],[-92.61,48.45],[-91.64,48.14],[-90.83,48.27],[-89.6,48.01],[-89.27,48.02],[-88.38,48.3],[-87.44,47.94],[-86.46,47.55],[-85.65,47.22],[-84.88,46.9],[-84.78,46.64],[-84.54,46.54],[-84.6,46.44],[-84.34,46.41],[-84.14,46.51],[-84.09,46.28],[-83.89,46.12],[-83.62,46.12],[-83.47,45.99],[-83.59,45.82],[-82.55,45.35],[-82.34,44.44],[-82.14,43.57],[-82.43,42.98],[-82.9,42.43],[-83.12,42.08],[-83.14,41.98],[-83.03,41.83],[-82.69,41.68],[-82.44,41.68],[-81.28,42.21],[-80.25,42.37],[-78.94,42.86],[-78.92,42.97],[-79.01,43.27],[-79.17,43.47],[-78.72,43.63],[-77.74,43.63],[-76.82,43.63],[-76.5,44.02],[-76.38,44.1],[-75.32,44.82],[-74.87,45.0],[-73.35,45.01],[-71.51,45.01],[-71.41,45.26],[-71.08,45.31],[-70.66,45.46],[-70.31,45.91],[-70.0,46.69],[-69.24,47.45],[-68.91,47.19],[-68.23,47.35],[-67.79,47.07],[-67.79,45.7],[-67.14,45.14]]},{"type":"LineString","coordinates":[[38.41,18.0],[37.9,17.43],[37.17,17.26],[36.85,16.96],[36.75,16.29],[36.32,14.82],[36.43,14.42],[36.43,14.42]]},{"type":"LineString","coordinates":[[47.79,8.0],[44.96,5.0],[43.66,4.96],[42.77,4.25],[42.13,4.23],[41.86,3.92]]},{"type":"LineString","coordinates":[[41.86,3.92],[41.17,3.92],[40.77,4.26],[39.85,3.84],[39.56,3.42],[38.89,3.5],[38.67,3.62],[38.44,3.59],[38.12,3.6],[36.86,4.45],[36.16,4.45],[35.82,4.78]]},{"type":"LineString","coordinates":[[39.2,-4.68],[37.77,-3.68],[37.7,-3.1],[34.07,-1.06],[33.9,-0.95]]},{"type":"LineString","coordinates":[[-8.67,27.66],[-8.82,27.66],[-8.79,27.12],[-9.41,27.09],[-9.74,26.86],[-10.19,26.86],[-10.55,26.99],[-11.39,26.88],[-11.72,26.1],[-12.03,26.03],[-12.5,24.77],[-13.89,23.69],[-14.22,22.31],[-14.63,21.86],[-14.75,21.5],[-17.0,21.42]]},{"type":"LineString","coordinates":[[11.03,58.86],[11.47,59.43],[12.3,60.12],[12.63,61.29],[11.99,61.8],[11.93,63.13],[12.58,64.07],[13.57,64.05],[13.92,64.45],[13.56,64.79],[15.11,66.19],[16.11,67.3],[16.77,68.01],[17.73,68.01],[17.99,68.57],[19.88,68.41],[20.03,69.07],[20.65,69.11],[20.65,69.11]]},{"type":"LineString","coordinates":[[40.32,-10.32],[39.52,-10.9],[38.43,-11.29],[37.83,-11.27],[37.47,-11.57],[36.78,-11.59],[36.51,-11.72],[35.31,-11.44],[34.56,-11.52]]},{"type":"LineString","coordinates":[[32.76,-9.23],[33.23,-9.68],[33.49,-10.53],[33.32,-10.8],[33.11,-11.61],[33.31,-12.44],[32.99,-12.78],[32.69,-13.71],[33.21,-13.97],[33.21,-13.97]]},{"type":"LineString","coordinates":[[34.56,-11.52],[34.28,-12.28],[34.56,-13.58],[34.91,-13.57],[35.27,-13.89],[35.69,-14.61],[35.77,-15.9],[35.34,-16.11],[35.03,-16.8],[34.38,-16.18],[34.31,-15.48],[34.52,-15.01],[34.46,-14.61],[34.06,-14.36],[33.79,-14.45],[33.21,-13.97]]},{"type":"LineString","coordinates":[[30.83,3.51],[30.77,2.34],[31.17,2.2],[30.85,1.85],[30.47,1.58],[30.09,1.06],[29.88,0.6],[29.82,-0.21],[29.59,-0.59],[29.58,-1.34]]},{"type":"LineString","coordinates":[[30.47,-2.41],[30.53,-2.81],[30.74,-3.03],[30.75,-3.36],[30.51,-3.57],[30.12,-4.09],[29.75,-4.45],[29.34,-4.5]]},{"type":"LineString","coordinates":[[20.26,42.81],[20.5,42.88],[20.64,43.22],[20.81,43.27],[20.96,43.13],[21.14,43.07],[21.27,42.91],[21.44,42.86],[21.63,42.68],[21.78,42.68],[21.66,42.44],[21.54,42.32],[21.58,42.25]]},{"type":"LineString","coordinates":[[-71.71,18.05],[-71.69,18.32],[-71.95,18.62],[-71.7,18.79],[-71.62,19.17],[-71.71,19.71]]},{"type":"LineString","coordinates":[[48.95,11.41],[48.94,11.39],[48.94,10.98],[48.94,9.97],[48.94,9.45],[48.49,8.84],[47.79,8.0]]},{"type":"LineString","coordinates":[[-69.59,-17.58],[-69.86,-18.09],[-70.37,-18.35]]},{"type":"LineString","coordinates":[[34.92,29.5],[34.27,31.22]]},{"type":"LineString","coordinates":[[35.72,32.71],[35.55,32.4]]},{"type":"LineString","coordinates":[[35.82,33.28],[36.07,33.82],[36.61,34.2],[36.45,34.59],[36.0,34.64]]},{"type":"LineString","coordinates":[[35.55,32.4],[35.55,32.39],[35.18,32.53],[34.97,31.87],[35.23,31.75],[34.97,31.62],[34.93,31.35],[35.4,31.49],[35.4,31.48]]},{"type":"LineString","coordinates":[[35.7,32.72],[35.84,32.87],[35.82,33.28],[35.82,33.28]]},{"type":"LineString","coordinates":[[29.0,9.6],[29.52,9.79],[29.62,10.08],[30.0,10.29],[30.84,9.71],[31.35,9.81],[31.85,10.53],[32.4,11.08],[32.31,11.68],[32.07,11.97],[32.67,12.02],[32.74,12.25],[33.21,12.18],[33.09,11.44],[33.21,10.72],[33.72,10.33],[33.84,9.98],[33.82,9.48],[33.96,9.46]]},{"type":"LineString","coordinates":[[23.89,8.62],[24.54,8.92],[24.79,9.81],[25.07,10.27],[25.79,10.41],[25.96,10.14],[26.48,9.55],[26.75,9.47],[27.11,9.64],[27.83,9.6]]},{"type":"LineString","coordinates":[[34.01,4.25],[33.39,3.79],[32.69,3.79],[31.88,3.56],[31.25,3.78],[30.83,3.51],[30.83,3.51]]},{"type":"LineString","coordinates":[[27.83,9.6],[27.97,9.4],[28.97,9.4],[29.0,9.6]]},{"type":"LineString","coordinates":[[35.9,4.61],[34.41,4.63],[34.01,4.25]]},{"type":"LineString","coordinates":[[35.82,4.78],[35.82,5.34],[35.3,5.51]]},{"type":"LineString","coordinates":[[33.94,-9.69],[33.74,-9.42],[32.76,-9.23]]},{"type":"LineString","coordinates":[[34.56,-11.52],[34.28,-10.16],[33.94,-9.69]]},{"type":"LineString","coordinates":[[-2.17,35.17],[-1.79,34.53],[-1.73,33.92],[-1.39,32.86],[-1.12,32.65],[-1.31,32.26]]},{"type":"LineString","coordinates":[[-1.31,32.26],[-2.62,32.09],[-3.07,31.72],[-3.65,31.64],[-3.69,30.9],[-4.86,30.5],[-5.24,30.0],[-6.06,29.73],[-7.06,29.58],[-8.67,28.84],[-8.67,27.66]]},{"type":"LineString","coordinates":[[75.16,37.13],[75.9,36.67],[75.9,36.67]]},{"type":"LineString","coordinates":[[78.81,33.51],[79.21,32.99]]},{"type":"LineString","coordinates":[[77.84,35.49],[78.91,34.32],[78.81,33.51]]},{"type":"LineString","coordinates":[[91.7,27.77],[92.5,27.9],[93.41,28.64],[94.57,29.28],[95.4,29.03],[96.12,29.45],[96.59,28.83],[96.25,28.41],[97.33,28.26]]},{"type":"LineString","coordinates":[[-57.15,5.97],[-57.31,5.07],[-57.91,4.81],[-57.86,4.58],[-58.04,4.06],[-57.6,3.33]]},{"type":"LineString","coordinates":[[-53.96,5.76],[-54.48,4.9],[-54.4,4.21],[-54.01,3.62],[-54.18,3.19]]},{"type":"LineString","coordinates":[[-57.6,3.33],[-57.28,3.33],[-57.15,2.77],[-56.54,1.9]]},{"type":"LineString","coordinates":[[-54.18,3.19],[-54.27,2.73],[-54.52,2.31]]},{"type":"LineString","coordinates":[[42.78,10.93],[42.56,10.57],[42.93,10.02],[43.3,9.54],[43.68,9.18],[46.95,8.0],[47.79,8.0]]},{"type":"LineString","coordinates":[[41.86,3.92],[40.98,2.78],[40.99,-0.86],[41.59,-1.68]]},{"type":"LineString","coordinates":[[77.84,35.49],[77.29,35.05],[77.29,35.05]]},{"type":"LineString","coordinates":[[33.44,45.97],[33.7,46.22],[34.41,46.01],[34.73,45.97],[34.86,45.77],[35.01,45.74]]},{"type":"LineString","coordinates":[[75.26,32.27],[74.41,31.69],[74.42,30.98],[73.45,29.98],[72.82,28.96],[71.78,27.91],[70.62,27.99],[69.51,26.94],[70.17,26.49],[70.28,25.72],[70.84,25.22],[71.04,24.36],[68.84,24.36],[68.18,23.69]]},{"type":"LineString","coordinates":[[-60.73,5.2],[-60.73,5.2],[-60.6,4.92],[-60.97,4.54],[-62.09,4.16],[-62.8,4.01],[-63.09,3.77],[-63.89,4.02],[-64.63,4.15],[-64.82,4.06],[-64.37,3.8],[-64.41,3.13],[-64.27,2.5],[-63.42,2.41],[-63.37,2.2],[-64.08,1.92],[-64.2,1.49],[-64.61,1.33],[-65.35,1.1],[-65.55,0.79],[-66.33,0.72],[-66.88,1.25]]},{"type":"LineString","coordinates":[[44.79,39.71],[44.79,39.71],[45.0,39.74],[45.3,39.47],[45.74,39.47],[45.74,39.32],[46.14,38.74]]},{"type":"LineString","coordinates":[[46.51,38.77],[46.14,38.74]]},{"type":"LineString","coordinates":[[44.97,41.25],[43.58,41.09]]},{"type":"LineString","coordinates":[[43.58,41.09],[43.75,40.74],[43.66,40.25],[44.4,40.01],[44.79,39.71],[44.79,39.71]]},{"type":"LineString","coordinates":[[44.79,39.71],[44.11,39.43],[44.42,38.28],[44.23,37.97],[44.77,37.17]]},{"type":"LineString","coordinates":[[44.77,37.17],[44.77,37.17],[44.29,37.0],[43.94,37.26],[42.78,37.39],[42.35,37.23],[42.35,37.23]]},{"type":"LineString","coordinates":[[42.35,37.23],[41.21,37.07],[40.67,37.09],[39.52,36.72],[38.7,36.71],[38.17,36.9],[37.07,36.62],[36.74,36.82],[36.69,36.26],[36.42,36.04],[36.15,35.82]]},{"type":"LineString","coordinates":[[38.79,33.38],[36.83,32.31],[35.72,32.71],[35.7,32.72]]},{"type":"LineString","coordinates":[[39.2,32.16],[39.0,32.01],[37.0,31.51],[38.0,30.51],[37.67,30.34],[37.5,30.0],[36.74,29.87],[36.5,29.51],[36.07,29.2],[34.96,29.36]]},{"type":"LineString","coordinates":[[46.57,29.1],[44.71,29.18],[41.89,31.19],[40.4,31.89],[39.2,32.16]]},{"type":"LineString","coordinates":[[52,19],[55,20],[55.67,22],[55.21,22.71]]},{"type":"LineString","coordinates":[[55.21,22.71],[55.01,22.5],[52.0,23.0],[51.62,24.01],[51.58,24.25]]},{"type":"LineString","coordinates":[[61.21,35.65],[60.8,34.4],[60.53,33.68],[60.96,33.53],[60.54,32.98],[60.86,32.18],[60.94,31.55],[61.7,31.38],[61.78,30.74],[60.87,29.83]]},{"type":"LineString","coordinates":[[60.87,29.83],[60.87,29.83],[61.37,29.3],[61.77,28.7],[62.73,28.26],[62.76,27.38],[63.23,27.22],[63.32,26.76],[61.87,26.24],[61.5,25.08]]},{"type":"LineString","coordinates":[[67.83,37.15],[67.08,37.36],[66.52,37.36],[66.52,37.36]]},{"type":"LineString","coordinates":[[66.52,37.36],[66.55,37.97],[65.22,38.4],[64.17,38.89],[63.52,39.36],[62.37,40.05],[61.88,41.08],[61.55,41.27],[60.47,41.22],[60.08,41.43],[59.98,42.22],[58.63,42.75],[57.79,42.17],[56.93,41.83],[57.1,41.32],[55.97,41.31]]},{"type":"LineString","coordinates":[[74.98,37.42],[74.98,37.42],[75.16,37.13],[75.16,37.13]]},{"type":"LineString","coordinates":[[75.16,37.13],[74.58,37.02],[74.07,36.84],[72.92,36.72],[71.85,36.51],[71.26,36.07],[71.5,35.65],[71.61,35.15],[71.12,34.73],[71.16,34.35],[70.88,33.99],[69.93,34.02],[70.32,33.36],[69.69,33.11],[69.26,32.5],[69.32,31.9],[68.93,31.62],[68.56,31.71],[67.79,31.58],[67.68,31.3],[66.94,31.3],[66.38,30.74],[66.35,29.89],[65.05,29.47],[64.35,29.56],[64.15,29.34],[63.55,29.47],[62.55,29.32],[60.87,29.83]]},{"type":"LineString","coordinates":[[73.68,39.43],[71.78,39.28],[70.55,39.6],[69.46,39.53],[69.56,40.1],[70.65,39.94],[71.01,40.24]]},{"type":"LineString","coordinates":[[87.36,49.22],[86.6,48.55],[85.77,48.46],[85.72,47.45],[85.16,47.0],[83.18,47.33],[82.46,45.54],[81.95,45.32],[79.97,44.92],[80.87,43.18],[80.18,42.92],[80.26,42.35],[80.26,42.35]]},{"type":"LineString","coordinates":[[80.26,42.35],[79.64,42.5],[79.14,42.86],[77.66,42.96],[76.0,42.99],[75.64,42.88],[74.21,43.3],[73.65,43.09],[73.49,42.5],[71.84,42.85],[71.19,42.7],[70.96,42.27]]},{"type":"LineString","coordinates":[[70.96,42.27],[70.96,42.27],[70.39,42.08],[69.07,41.38],[68.63,40.67],[68.26,40.66],[67.99,41.14],[66.71,41.17],[66.51,41.99],[66.02,41.99],[66.1,43.0],[64.9,43.73],[63.19,43.65],[62.01,43.5],[61.06,44.41],[60.24,44.78],[58.69,45.5],[58.5,45.59],[55.93,45.0],[55.97,41.31],[55.97,41.31]]},{"type":"LineString","coordinates":[[55.97,41.31],[55.46,41.26],[54.76,42.04],[54.08,42.32],[52.94,42.12],[52.5,41.78]]},{"type":"LineString","coordinates":[[76.65,35.79],[77.84,35.49]]},{"type":"LineString","coordinates":[[81.11,30.18],[80.48,29.73],[80.09,28.79],[81.06,28.42],[82,27.93],[83.3,27.36],[84.68,27.23],[85.25,26.73],[86.02,26.63],[87.23,26.4],[88.06,26.41],[88.17,26.81],[88.04,27.45],[88.12,27.88]]},{"type":"LineString","coordinates":[[88.81,27.3],[88.84,27.1],[89.74,26.72],[90.37,26.88],[91.22,26.81],[92.03,26.84],[92.1,27.45],[91.7,27.77]]},{"type":"LineString","coordinates":[[88.12,27.88],[88.12,27.88],[88.73,28.09],[88.73,28.09],[88.81,27.3],[88.81,27.3]]},{"type":"LineString","coordinates":[[92.67,22.04],[92.15,23.63],[91.87,23.62],[91.71,22.99],[91.16,23.5],[91.47,24.07],[91.92,24.13],[92.38,24.98],[91.8,25.15],[90.87,25.13],[89.92,25.27],[89.83,25.97],[89.36,26.01],[88.56,26.45],[88.21,25.77],[88.93,25.24],[88.31,24.87],[88.08,24.5],[88.7,24.23],[88.53,23.63],[88.88,22.88],[89.03,22.06]]},{"type":"LineString","coordinates":[[101.18,21.44],[100.33,20.79],[100.12,20.42],[100.12,20.42]]},{"type":"LineString","coordinates":[[100.12,20.42],[99.54,20.19],[98.96,19.75],[98.25,19.71],[97.8,18.63],[97.38,18.45],[97.86,17.57],[98.49,16.84],[98.9,16.18],[98.54,15.31],[98.19,15.12],[98.43,14.62],[99.1,13.83],[99.21,13.27],[99.2,12.8],[99.59,11.89],[99.04,10.96],[98.55,9.93]]},{"type":"LineString","coordinates":[[102.17,22.46],[102.17,22.46],[102.75,21.68],[103.2,20.77],[104.44,20.76],[104.82,19.89],[104.18,19.62],[103.9,19.27],[105.09,18.67],[105.93,17.49],[106.56,16.6],[107.31,15.91],[107.56,15.2],[107.38,14.2],[107.38,14.2]]},{"type":"LineString","coordinates":[[107.38,14.2],[106.5,14.57],[106.04,13.88],[105.22,14.27]]},{"type":"LineString","coordinates":[[130.64,42.4],[130.78,42.22]]},{"type":"LineString","coordinates":[[27.29,57.47],[27.77,57.24],[27.86,56.76],[28.18,56.17],[28.18,56.17]]},{"type":"LineString","coordinates":[[28.18,56.17],[27.1,55.78],[26.49,55.62]]},{"type":"LineString","coordinates":[[31.79,52.1],[30.93,52.04],[30.62,51.82],[30.56,51.32],[30.16,51.42],[29.25,51.37],[28.99,51.6],[28.62,51.43],[28.24,51.57],[27.45,51.59],[26.34,51.83],[25.33,51.91],[24.55,51.89],[24.01,51.62],[23.53,51.58]]},{"type":"LineString","coordinates":[[26.49,55.62],[26.49,55.62],[26.59,55.17],[25.77,54.85],[25.54,54.28],[24.45,53.91],[23.48,53.91]]},{"type":"LineString","coordinates":[[20.65,69.11],[21.24,69.37],[22.36,68.84],[23.66,68.89],[24.74,68.65],[25.69,69.09],[26.18,69.83],[27.73,70.16],[29.02,69.77],[28.59,69.06],[28.59,69.06]]},{"type":"LineString","coordinates":[[28.59,69.06],[29.4,69.16],[31.1,69.56]]},{"type":"LineString","coordinates":[[26.62,48.22],[26.62,48.22],[26.86,48.37],[27.52,48.47],[28.26,48.16],[28.67,48.12],[29.12,47.85],[29.05,47.51],[29.42,47.35],[29.56,46.93],[29.91,46.67],[29.84,46.53],[30.02,46.42],[29.76,46.35],[29.17,46.38],[29.07,46.52],[28.86,46.44],[28.93,46.26],[28.66,45.94],[28.49,45.6],[28.23,45.49]]},{"type":"LineString","coordinates":[[28.23,45.49],[28.23,45.49],[28.68,45.3],[29.15,45.46],[29.6,45.29]]},{"type":"LineString","coordinates":[[22.66,44.23],[22.47,44.41],[22.71,44.58],[22.46,44.7],[22.15,44.48],[21.56,44.77],[21.48,45.18],[20.87,45.42],[20.76,45.73],[20.22,46.13]]},{"type":"LineString","coordinates":[[21.02,40.84],[21.02,40.84],[20.61,41.09],[20.46,41.52],[20.59,41.86]]},{"type":"LineString","coordinates":[[21.58,42.25],[21.58,42.25],[21.35,42.21],[20.76,42.05],[20.72,41.85],[20.59,41.86]]},{"type":"LineString","coordinates":[[20.59,41.86],[20.59,41.86],[20.52,42.22],[20.28,42.32],[20.07,42.59]]},{"type":"LineString","coordinates":[[20.07,42.59],[20.07,42.59],[19.8,42.5],[19.74,42.69],[19.3,42.2],[19.37,41.88]]},{"type":"LineString","coordinates":[[22.36,42.32],[21.92,42.3],[21.58,42.25]]},{"type":"LineString","coordinates":[[20.26,42.81],[20.26,42.81],[20.07,42.59]]},{"type":"LineString","coordinates":[[19.22,43.52],[19.03,43.43],[18.71,43.2],[18.56,42.65]]},{"type":"LineString","coordinates":[[19.01,44.86],[19.39,45.24],[19.07,45.52],[18.83,45.91]]},{"type":"LineString","coordinates":[[15.02,51.11],[14.57,51.0],[14.31,51.12],[14.06,50.93],[13.34,50.73],[12.97,50.48],[12.24,50.27],[12.42,49.97],[12.52,49.55],[13.03,49.31],[13.6,48.88],[13.6,48.88]]},{"type":"LineString","coordinates":[[13.6,48.88],[13.24,48.42],[12.88,48.29],[13.03,47.64],[12.93,47.47],[12.62,47.67],[12.14,47.7],[11.43,47.52],[10.54,47.57],[10.4,47.3],[9.9,47.58],[9.59,47.53],[9.59,47.53]]},{"type":"LineString","coordinates":[[9.59,47.53],[8.52,47.83],[8.32,47.61],[7.47,47.62]]},{"type":"LineString","coordinates":[[16.96,48.6],[16.5,48.79],[16.03,48.73],[15.25,49.04],[14.9,48.96],[14.34,48.56],[13.6,48.88]]},{"type":"LineString","coordinates":[[16.98,48.12],[16.9,47.71],[16.34,47.71],[16.53,47.5],[16.2,46.85],[16.2,46.85]]},{"type":"LineString","coordinates":[[16.2,46.85],[16.01,46.68],[15.14,46.66],[14.63,46.43],[13.81,46.51]]},{"type":"LineString","coordinates":[[13.81,46.51],[13.7,46.02],[13.94,45.59]]},{"type":"LineString","coordinates":[[10.44,46.89],[10.36,46.48],[9.92,46.31],[9.18,46.44],[8.97,46.04],[8.49,46.01],[8.32,46.16],[7.76,45.82],[7.27,45.78],[6.84,45.99]]},{"type":"LineString","coordinates":[[7.47,47.62],[7.19,47.45],[6.74,47.54],[6.77,47.29],[6.04,46.73],[6.02,46.27],[6.5,46.43],[6.84,45.99]]},{"type":"LineString","coordinates":[[6.84,45.99],[6.84,45.99],[6.8,45.71],[7.1,45.33],[6.75,45.03],[7.01,44.25],[7.55,44.13],[7.44,43.69]]},{"type":"LineString","coordinates":[[6.04,50.13],[6.04,50.13],[5.78,50.09],[5.67,49.53],[5.67,49.53]]},{"type":"LineString","coordinates":[[5.67,49.53],[4.8,49.99],[4.29,49.91],[3.59,50.38],[3.12,50.78],[2.66,50.8],[2.51,51.15]]},{"type":"LineString","coordinates":[[6.16,50.8],[6.16,50.8],[5.61,51.04],[4.97,51.48],[4.05,51.27],[3.31,51.35]]},{"type":"LineString","coordinates":[[-4.92,24.97],[-6.45,24.96],[-5.97,20.64],[-5.49,16.33],[-5.32,16.2],[-5.54,15.5],[-9.55,15.49],[-9.7,15.26],[-10.09,15.33],[-10.65,15.13],[-11.35,15.41],[-11.67,15.39],[-11.83,14.8],[-12.17,14.62],[-12.17,14.62]]},{"type":"LineString","coordinates":[[-12.17,14.62],[-12.83,15.3],[-13.44,16.04],[-14.1,16.3],[-14.58,16.6],[-15.14,16.59],[-15.62,16.37],[-16.12,16.46],[-16.46,16.14]]},{"type":"LineString","coordinates":[[9.48,30.31],[9.81,29.42],[9.86,28.96],[9.68,28.14],[9.76,27.69],[9.63,27.14],[9.72,26.51],[9.32,26.09],[9.91,25.37],[9.95,24.94],[10.3,24.38],[10.77,24.56],[11.56,24.1],[12.0,23.47],[12.0,23.47]]},{"type":"LineString","coordinates":[[12.0,23.47],[8.57,21.57],[5.68,19.6],[4.27,19.16]]},{"type":"LineString","coordinates":[[4.27,19.16],[4.27,19.16],[3.16,19.06],[3.15,19.69],[2.68,19.86],[2.06,20.14],[1.82,20.61],[-1.55,22.79],[-4.92,24.97]]},{"type":"LineString","coordinates":[[14.85,22.86],[14.14,22.49],[13.58,23.04],[12.0,23.47]]},{"type":"LineString","coordinates":[[25,22],[25,20.0],[23.85,20],[23.84,19.58]]},{"type":"LineString","coordinates":[[23.84,19.58],[19.85,21.5],[15.86,23.41],[14.85,22.86]]},{"type":"LineString","coordinates":[[22.86,11.14],[22.23,10.97],[21.72,10.57],[21.0,9.48],[20.06,9.01],[19.09,9.07],[18.81,8.98],[18.91,8.63],[18.39,8.28],[17.96,7.89],[16.71,7.51],[16.46,7.73],[16.29,7.75],[16.11,7.5],[15.28,7.42]]},{"type":"LineString","coordinates":[[15.28,7.42],[15.28,7.42],[15.44,7.69],[15.12,8.38],[14.98,8.8],[14.54,8.97],[13.95,9.55],[14.17,10.02],[14.63,9.92],[14.91,9.99],[15.47,9.98],[14.92,10.89],[14.96,11.56],[14.89,12.22],[14.5,12.86]]},{"type":"LineString","coordinates":[[14.5,12.86],[14.21,12.8],[14.18,12.48]]},{"type":"LineString","coordinates":[[14.18,12.48],[14.58,12.09],[14.47,11.9],[14.42,11.57],[13.57,10.8],[13.31,10.16],[13.17,9.64],[12.96,9.42],[12.75,8.72],[12.22,8.31],[12.06,7.8],[11.84,7.4],[11.75,6.98],[11.06,6.64],[10.5,7.06],[10.12,7.04],[9.52,6.45],[9.23,6.44],[8.76,5.48],[8.5,4.77]]},{"type":"LineString","coordinates":[[-11.51,12.44],[-11.51,12.44],[-11.66,12.39],[-12.2,12.47],[-12.28,12.35],[-12.5,12.33],[-13.22,12.58],[-13.7,12.59]]},{"type":"LineString","coordinates":[[-8.03,10.21],[-8.23,10.13],[-8.31,9.79],[-8.08,9.38],[-7.83,8.58],[-8.2,8.46],[-8.3,8.32],[-8.22,8.12],[-8.28,7.69],[-8.44,7.69]]},{"type":"LineString","coordinates":[[-8.44,7.69],[-8.44,7.69],[-8.72,7.71],[-8.93,7.31],[-9.21,7.31],[-9.4,7.53],[-9.34,7.93],[-9.76,8.54],[-10.02,8.43],[-10.23,8.41]]},{"type":"LineString","coordinates":[[-10.23,8.41],[-10.23,8.41],[-10.51,8.35],[-10.49,8.72],[-10.65,8.98],[-10.62,9.27],[-10.84,9.69],[-11.12,10.05],[-11.92,10.05],[-12.15,9.86],[-12.43,9.84],[-12.6,9.62],[-12.71,9.34],[-13.25,8.9]]},{"type":"LineString","coordinates":[[0.37,14.93],[-0.27,14.92],[-0.52,15.12],[-1.07,14.97],[-2.0,14.56],[-2.19,14.25],[-2.97,13.8],[-3.1,13.54],[-3.52,13.34],[-4.01,13.47],[-4.28,13.23],[-4.43,12.54],[-5.22,11.71],[-5.2,11.38],[-5.47,10.95],[-5.4,10.37],[-5.4,10.37]]},{"type":"LineString","coordinates":[[-5.4,10.37],[-5.82,10.22],[-6.05,10.1],[-6.21,10.52],[-6.49,10.41],[-6.67,10.43],[-6.85,10.14],[-7.62,10.15],[-7.9,10.3],[-8.03,10.21]]},{"type":"LineString","coordinates":[[2.15,11.94],[1.94,11.64],[1.45,11.55],[1.24,11.11],[0.9,11.0],[0.9,11.0]]},{"type":"LineString","coordinates":[[0.9,11.0],[0.02,11.02]]},{"type":"LineString","coordinates":[[-2.83,9.64],[-3.51,9.9],[-3.98,9.86],[-4.33,9.61],[-4.78,9.82],[-4.95,10.15],[-5.4,10.37]]},{"type":"LineString","coordinates":[[0.02,11.02],[0.02,11.02],[-0.44,11.1],[-0.76,10.94],[-1.2,11.01],[-2.94,10.96],[-2.96,10.4],[-2.83,9.64],[-2.83,9.64]]},{"type":"LineString","coordinates":[[3.61,11.66],[3.57,11.33],[3.8,10.73],[3.6,10.33],[3.71,10.06],[3.22,9.44],[2.91,9.14],[2.72,8.51],[2.75,7.87],[2.69,6.26]]},{"type":"LineString","coordinates":[[16.01,2.27],[15.94,1.73],[15.15,1.96],[14.34,2.23],[13.08,2.27],[13.08,2.27]]},{"type":"LineString","coordinates":[[13.08,2.27],[12.95,2.32],[12.36,2.19],[11.75,2.33],[11.28,2.26],[11.28,2.26]]},{"type":"LineString","coordinates":[[11.28,2.26],[9.65,2.28]]},{"type":"LineString","coordinates":[[23.81,8.67],[24.57,8.23],[25.11,7.83],[25.12,7.5],[25.8,6.98],[26.21,6.55],[26.47,5.95],[27.21,5.55],[27.37,5.23],[27.37,5.23]]},{"type":"LineString","coordinates":[[27.37,5.23],[27.04,5.13],[26.4,5.15],[25.65,5.26],[25.28,5.17],[25.13,4.93],[24.81,4.9],[24.41,5.11],[23.3,4.61],[22.84,4.71],[22.7,4.63],[22.41,4.03],[21.66,4.22],[20.93,4.32],[20.29,4.69],[19.47,5.03],[18.93,4.71],[18.54,4.2],[18.45,3.5]]},{"type":"LineString","coordinates":[[36.43,14.42],[36.27,13.56],[35.86,12.58],[35.26,12.08],[34.83,11.32],[34.73,10.91],[34.26,10.63],[33.96,9.58],[33.96,9.58]]},{"type":"LineString","coordinates":[[33.96,9.58],[33.97,8.68],[33.83,8.38],[33.29,8.35],[32.95,7.78],[33.57,7.71],[34.08,7.23],[34.25,6.83],[34.71,6.59],[35.3,5.51]]},{"type":"LineString","coordinates":[[42.35,12.54],[42.78,12.46],[43.08,12.7]]},{"type":"LineString","coordinates":[[42.78,10.93],[42.55,11.11],[42.31,11.03],[41.76,11.05],[41.74,11.36],[41.66,11.63],[42,12.1],[42.35,12.54]]},{"type":"LineString","coordinates":[[33.9,-0.95],[33.9,-0.95],[33.89,0.11],[34.18,0.52],[34.67,1.18],[35.04,1.91],[34.6,3.05],[34.48,3.56],[34.01,4.25]]},{"type":"LineString","coordinates":[[30.83,3.51],[29.95,4.17],[29.72,4.6],[29.16,4.39],[28.7,4.46],[28.43,4.29],[27.98,4.41],[27.37,5.23]]},{"type":"LineString","coordinates":[[18.45,3.5],[18.45,3.5],[18.39,2.9],[18.09,2.37],[17.9,1.74],[17.77,0.86],[17.83,0.29],[17.66,-0.06],[17.64,-0.42],[17.52,-0.74],[16.87,-1.23],[16.41,-1.74],[15.97,-2.71],[16.01,-3.54],[15.75,-3.86],[15.17,-4.34],[14.58,-4.97],[14.21,-4.79],[14.14,-4.51],[13.6,-4.5],[13.26,-4.88],[13.0,-4.78]]},{"type":"LineString","coordinates":[[13.0,-4.78],[13.0,-4.78],[12.63,-4.99],[12.47,-5.25],[12.44,-5.68],[12.18,-5.79]]},{"type":"LineString","coordinates":[[29.58,-1.34],[29.29,-1.62],[29.25,-2.22],[29.12,-2.29],[29.02,-2.84]]},{"type":"LineString","coordinates":[[29.02,-2.84],[29.28,-3.29],[29.34,-4.5]]},{"type":"LineString","coordinates":[[29.34,-4.5],[29.52,-5.42],[29.42,-5.94],[29.62,-6.52],[30.2,-7.08],[30.74,-8.34]]},{"type":"LineString","coordinates":[[30.74,-8.34],[30.35,-8.24],[29.0,-8.41],[28.73,-8.53],[28.45,-9.16],[28.67,-9.61],[28.5,-10.79],[28.37,-11.79],[28.64,-11.97],[29.34,-12.36],[29.62,-12.18],[29.7,-13.26],[28.93,-13.25],[28.52,-12.7],[28.16,-12.27],[27.39,-12.13],[27.16,-11.61],[26.55,-11.92],[25.75,-11.78],[25.42,-11.33],[24.78,-11.24],[24.31,-11.26],[24.26,-10.95],[23.91,-10.93]]},{"type":"LineString","coordinates":[[23.91,-10.93],[23.46,-10.87],[22.84,-11.02],[22.4,-10.99],[22.16,-11.08],[22.21,-9.89],[21.88,-9.52],[21.8,-8.91],[21.95,-8.31],[21.75,-7.92],[21.73,-7.29],[20.51,-7.3],[20.6,-6.94],[20.09,-6.94],[20.04,-7.12],[19.42,-7.16],[19.17,-7.74],[19.02,-7.99],[18.46,-7.85],[18.13,-7.99],[17.47,-8.07],[17.09,-7.55],[16.86,-7.22],[16.57,-6.62],[16.33,-5.88],[13.38,-5.86],[13.02,-5.98],[12.74,-5.97],[12.32,-6.1]]},{"type":"LineString","coordinates":[[30.47,-2.41],[30.47,-2.41],[29.94,-2.35],[29.63,-2.92],[29.02,-2.84]]},{"type":"LineString","coordinates":[[30.42,-1.13],[30.77,-1.01],[31.87,-1.03],[33.9,-0.95]]},{"type":"LineString","coordinates":[[32.76,-9.23],[32.19,-8.93],[31.56,-8.76],[31.16,-8.59],[30.74,-8.34]]},{"type":"LineString","coordinates":[[33.21,-13.97],[30.18,-14.8],[30.27,-15.51]]},{"type":"LineString","coordinates":[[30.27,-15.51],[30.27,-15.51],[29.52,-15.64],[28.95,-16.04],[28.83,-16.39],[28.47,-16.47],[27.6,-17.29],[27.04,-17.94],[26.71,-17.96],[26.38,-17.85],[25.26,-17.74],[25.08,-17.66]]},{"type":"LineString","coordinates":[[23.22,-17.52],[21.38,-17.93],[18.96,-17.79],[18.26,-17.31],[14.21,-17.35],[14.06,-17.42],[13.46,-16.97],[12.81,-16.94],[12.22,-17.11],[11.73,-17.3]]},{"type":"LineString","coordinates":[[24.93,-17.72],[24.52,-17.89],[24.22,-17.89],[23.58,-18.28],[23.2,-17.87],[21.66,-18.22],[20.91,-18.25],[20.88,-21.81],[19.9,-21.85],[19.9,-24.77],[19.9,-24.77]]},{"type":"LineString","coordinates":[[29.43,-22.09],[28.02,-22.83],[27.12,-23.57],[26.79,-24.24],[26.49,-24.62],[25.94,-24.7],[25.77,-25.17],[25.66,-25.49],[25.03,-25.72],[24.21,-25.67],[23.73,-25.39],[23.31,-25.27],[22.82,-25.5],[22.58,-25.98],[22.11,-26.28],[21.61,-26.73],[20.89,-26.83],[20.67,-26.48],[20.76,-25.87],[20.17,-24.92],[19.9,-24.77]]},{"type":"LineString","coordinates":[[31.19,-22.25],[31.19,-22.25],[30.66,-22.15],[30.32,-22.27],[29.84,-22.1],[29.43,-22.09]]},{"type":"LineString","coordinates":[[32.07,-26.73],[32.07,-26.73],[31.99,-26.29],[31.84,-25.84]]},{"type":"LineString","coordinates":[[31.84,-25.84],[31.84,-25.84],[31.75,-25.48],[31.93,-24.37],[31.67,-23.66],[31.19,-22.25]]},{"type":"LineString","coordinates":[[-89.14,17.81],[-89.14,17.81],[-89.15,17.96],[-89.03,18.0],[-88.85,17.88],[-88.49,18.49],[-88.3,18.5]]},{"type":"LineString","coordinates":[[-89.35,14.42],[-89.15,14.68],[-89.23,14.87],[-89.15,15.07],[-88.68,15.35],[-88.23,15.73]]},{"type":"LineString","coordinates":[[-66.88,1.25],[-67.07,1.13],[-67.26,1.72],[-67.54,2.04],[-67.87,1.69],[-69.82,1.71],[-69.8,1.09],[-69.22,0.99],[-69.25,0.6],[-69.45,0.71],[-70.02,0.54],[-70.02,-0.19],[-69.58,-0.55],[-69.42,-1.12],[-69.44,-1.56],[-69.89,-4.3],[-69.89,-4.3]]},{"type":"LineString","coordinates":[[-69.89,-4.3],[-70.39,-3.77],[-70.69,-3.74],[-70.05,-2.73],[-70.81,-2.26],[-71.41,-2.34],[-71.77,-2.17],[-72.33,-2.43],[-73.07,-2.31],[-73.66,-1.26],[-74.12,-1.0],[-74.44,-0.53],[-75.11,-0.06],[-75.37,-0.15],[-75.37,-0.15]]},{"type":"LineString","coordinates":[[-75.37,-0.15],[-75.8,0.08],[-76.29,0.42],[-76.58,0.26],[-77.42,0.4],[-77.67,0.83],[-77.86,0.81],[-78.86,1.38]]},{"type":"LineString","coordinates":[[-67.11,-22.74],[-67.11,-22.74],[-67.83,-22.87],[-68.22,-21.49],[-68.76,-20.37],[-68.44,-19.41],[-68.97,-18.98],[-69.1,-18.26],[-69.59,-17.58]]},{"type":"LineString","coordinates":[[-58.17,-20.18],[-58.18,-19.87],[-59.12,-19.36],[-60.04,-19.34],[-61.79,-19.63],[-62.27,-20.51],[-62.29,-21.05],[-62.69,-22.25]]},{"type":"LineString","coordinates":[[-54.63,-25.74],[-54.63,-25.74],[-54.13,-25.55],[-53.63,-26.12],[-53.65,-26.92],[-54.49,-27.47],[-55.16,-27.88],[-56.29,-28.85],[-57.63,-30.22]]},{"type":"LineString","coordinates":[[-57.63,-30.22],[-57.63,-30.22],[-56.98,-30.11],[-55.97,-30.88],[-55.6,-30.85],[-54.57,-31.49],[-53.79,-32.05],[-53.21,-32.73],[-53.65,-33.2],[-53.37,-33.77]]},{"type":"LineString","coordinates":[[-69.59,-17.58],[-68.96,-16.5],[-69.39,-15.66],[-69.16,-15.32],[-69.34,-14.95],[-68.95,-14.45],[-68.93,-13.6],[-68.88,-12.9],[-68.67,-12.56],[-69.53,-10.95]]},{"type":"LineString","coordinates":[[13.32,13.56],[13.32,13.56],[14.0,12.46],[14.18,12.48],[14.18,12.48]]},{"type":"LineString","coordinates":[[6.19,49.46],[5.9,49.44],[5.67,49.53]]},{"type":"MultiLineString","coordinates":[[[46.14,38.74],[45.46,38.87],[44.95,39.34],[44.79,39.71]],[[46.51,38.77],[47.69,39.51],[48.06,39.58],[48.36,39.29],[48.01,38.79],[48.63,38.27],[48.88,38.32]]]},{"type":"LineString","coordinates":[[-13.7,12.59],[-13.7,12.59],[-15.55,12.63],[-15.82,12.52],[-16.15,12.55],[-16.68,12.38]]},{"type":"LineString","coordinates":[[19.9,-24.77],[19.89,-28.46],[19.0,-28.97],[18.46,-29.05],[17.84,-28.86],[17.39,-28.78],[17.22,-28.36],[16.82,-28.08],[16.34,-28.58]]},{"type":"LineString","coordinates":[[-62.69,-22.25],[-62.69,-22.25],[-62.85,-22.03],[-63.99,-21.99],[-64.38,-22.8],[-64.96,-22.08],[-66.27,-21.83],[-67.11,-22.74]]},{"type":"MultiLineString","coordinates":[[[-69.53,-10.95],[-69.53,-10.95],[-68.79,-11.04],[-68.27,-11.01],[-68.05,-10.71],[-67.17,-10.31],[-66.65,-9.93],[-65.34,-9.76],[-65.44,-10.51],[-65.32,-10.9],[-65.4,-11.57],[-64.32,-12.46],[-63.2,-12.63],[-62.8,-13.0],[-62.13,-13.2],[-61.71,-13.49],[-61.08,-13.48],[-60.5,-13.78],[-60.46,-14.35],[-60.26,-14.65],[-60.25,-15.08],[-60.54,-15.09],[-60.16,-16.26],[-58.24,-16.3],[-58.39,-16.88],[-58.28,-17.27],[-57.73,-17.55],[-57.5,-18.17],[-57.68,-18.96],[-57.95,-19.4],[-57.85,-19.97],[-57.85,-19.97],[-58.17,-20.18]],[[-57.85,-19.97],[-58.17,-20.18]]]},{"type":"LineString","coordinates":[[75.9,36.67],[76.19,35.9],[76.65,35.79]]},{"type":"LineString","coordinates":[[79.21,32.99],[79.21,32.99],[79.18,32.48],[79.18,32.48]]},{"type":"LineString","coordinates":[[79.18,32.48],[78.46,32.62],[78.55,32.27]]},{"type":"LineString","coordinates":[[78.55,32.27],[78.65,31.85]]},{"type":"LineString","coordinates":[[78.65,31.85],[78.74,31.52],[78.88,31.43]]},{"type":"LineString","coordinates":[[78.88,31.43],[79.37,31.11]]},{"type":"LineString","coordinates":[[79.37,31.11],[79.72,30.88],[79.81,30.84]]},{"type":"LineString","coordinates":[[79.81,30.84],[80.26,30.61]]},{"type":"LineString","coordinates":[[80.26,30.61],[81.11,30.18],[81.11,30.18]]},{"type":"LineString","coordinates":[[36.7,45.61],[36.58,45.2],[36.64,44.95]]},{"type":"LineString","coordinates":[[87.36,49.22],[87.75,49.3],[88.01,48.6],[88.85,48.07],[90.28,47.69],[90.97,46.89],[90.59,45.72],[90.95,45.29],[92.13,45.12],[93.48,44.98],[94.69,44.35],[95.31,44.24],[95.76,43.32],[96.35,42.73],[97.45,42.75],[99.52,42.52],[100.85,42.66],[101.83,42.51],[103.31,41.91],[104.52,41.91],[104.97,41.6],[106.13,42.13],[107.74,42.48],[109.24,42.52],[110.41,42.87],[111.13,43.41],[111.83,43.74],[111.67,44.07],[111.35,44.46],[111.87,45.1],[112.44,45.01],[113.46,44.81],[114.46,45.34],[115.99,45.73],[116.72,46.39],[117.42,46.67],[118.87,46.81],[119.66,46.69],[119.77,47.05],[118.87,47.75],[118.06,48.07],[117.3,47.7],[116.31,47.85],[115.74,47.73],[115.49,48.14],[116.19,49.13],[116.68,49.89],[116.68,49.89]]},{"type":"LineString","coordinates":[[116.68,49.89],[116.68,49.89],[117.88,49.51],[119.29,50.14],[119.28,50.58],[120.18,51.64],[120.74,51.96],[120.73,52.52],[120.18,52.75],[121.0,53.25],[122.25,53.43],[123.57,53.46],[125.07,53.16],[125.95,52.79],[126.56,51.78],[126.94,51.35],[127.29,50.74],[127.66,49.76],[129.4,49.44],[130.58,48.73],[130.99,47.79],[132.51,47.79],[133.37,48.18],[135.03,48.48],[134.5,47.58],[134.11,47.21],[133.77,46.12],[133.1,45.14],[131.88,45.32],[131.03,44.97],[131.29,44.11],[131.14,42.93],[130.63,42.9],[130.64,42.4]]},{"type":"LineString","coordinates":[[35.55,33.26],[35.46,33.09],[35.13,33.09]]},{"type":"LineString","coordinates":[[75.14,34.6],[74.24,34.75],[73.75,34.32],[74.1,33.44],[74.45,32.76]]},{"type":"LineString","coordinates":[[77.29,35.05],[76.87,34.65],[75.76,34.5],[75.14,34.6]]}]} \ No newline at end of file diff --git a/app/src/main/assets/world_cities.geojson b/app/src/main/assets/world_cities.geojson new file mode 100644 index 00000000..435f8654 --- /dev/null +++ b/app/src/main/assets/world_cities.geojson @@ -0,0 +1 @@ +{"cities":[{"n":"Bombo","r":10,"cap":0,"mega":0,"pop":75000,"lon":32.533,"lat":0.583},{"n":"Fort Portal","r":10,"cap":0,"mega":0,"pop":42670,"lon":30.275,"lat":0.671},{"n":"Potenza","r":10,"cap":0,"mega":0,"pop":69060,"lon":15.799,"lat":40.642},{"n":"Campobasso","r":10,"cap":0,"mega":0,"pop":50762,"lon":14.656,"lat":41.563},{"n":"Aosta","r":10,"cap":0,"mega":0,"pop":34062,"lon":7.315,"lat":45.737},{"n":"Mariehamn","r":10,"cap":0,"mega":0,"pop":10682,"lon":19.949,"lat":60.097},{"n":"Ramallah","r":10,"cap":0,"mega":0,"pop":24599,"lon":35.206,"lat":31.903},{"n":"Vatican City","r":8,"cap":1,"mega":0,"pop":832,"lon":12.453,"lat":41.903},{"n":"Poitier","r":8,"cap":0,"mega":0,"pop":85960,"lon":0.333,"lat":46.583},{"n":"Clermont-Ferrand","r":8,"cap":0,"mega":0,"pop":233050,"lon":3.08,"lat":45.78},{"n":"Besancon","r":8,"cap":0,"mega":0,"pop":128426,"lon":6.03,"lat":47.23},{"n":"Artigas Base","r":8,"cap":0,"mega":0,"pop":60,"lon":-58.864,"lat":-62.174},{"n":"Chipata","r":7,"cap":0,"mega":0,"pop":85963,"lon":32.64,"lat":-13.63},{"n":"Jinja","r":7,"cap":0,"mega":0,"pop":301619,"lon":33.2,"lat":0.44},{"n":"Arua","r":7,"cap":0,"mega":0,"pop":250000,"lon":30.9,"lat":3.02},{"n":"Mbale","r":7,"cap":0,"mega":0,"pop":402368,"lon":34.17,"lat":1.09},{"n":"Moroto","r":7,"cap":0,"mega":0,"pop":371,"lon":34.64,"lat":2.54},{"n":"Masaka","r":7,"cap":0,"mega":0,"pop":65373,"lon":31.73,"lat":-0.33},{"n":"Mbarara","r":7,"cap":0,"mega":0,"pop":83700,"lon":30.65,"lat":-0.6},{"n":"Otjiwarongo","r":7,"cap":0,"mega":0,"pop":24815,"lon":16.64,"lat":-20.46},{"n":"Bologna","r":7,"cap":0,"mega":0,"pop":488172,"lon":11.34,"lat":44.5},{"n":"Cagliari","r":7,"cap":0,"mega":0,"pop":291511,"lon":9.104,"lat":39.222},{"n":"Catanzaro","r":7,"cap":0,"mega":0,"pop":95251,"lon":16.6,"lat":38.9},{"n":"Bari","r":7,"cap":0,"mega":0,"pop":500577,"lon":16.873,"lat":41.114},{"n":"L'Aquila","r":7,"cap":0,"mega":0,"pop":68503,"lon":13.39,"lat":42.35},{"n":"Ancona","r":7,"cap":0,"mega":0,"pop":100507,"lon":13.5,"lat":43.6},{"n":"Perugia","r":7,"cap":0,"mega":0,"pop":149125,"lon":12.39,"lat":43.11},{"n":"Trieste","r":7,"cap":0,"mega":0,"pop":216035,"lon":13.8,"lat":45.65},{"n":"Trento","r":7,"cap":0,"mega":0,"pop":107808,"lon":11.12,"lat":46.08},{"n":"Fort-de-France","r":7,"cap":0,"mega":0,"pop":253995,"lon":-61.08,"lat":14.61},{"n":"Gifu","r":7,"cap":0,"mega":0,"pop":412895,"lon":136.763,"lat":35.423},{"n":"Saint-Laurent-du-Maroni","r":7,"cap":0,"mega":0,"pop":24287,"lon":-54.033,"lat":5.498},{"n":"Caen","r":7,"cap":0,"mega":0,"pop":190099,"lon":-0.35,"lat":49.184},{"n":"Nantes","r":7,"cap":0,"mega":0,"pop":438537,"lon":-1.59,"lat":47.21},{"n":"Ajaccio","r":7,"cap":0,"mega":0,"pop":54364,"lon":8.728,"lat":41.927},{"n":"Montpellier","r":7,"cap":0,"mega":0,"pop":327254,"lon":3.87,"lat":43.61},{"n":"Dijon","r":7,"cap":0,"mega":0,"pop":169946,"lon":5.03,"lat":47.33},{"n":"Orleans","r":7,"cap":0,"mega":0,"pop":217301,"lon":1.9,"lat":47.9},{"n":"Rouen","r":7,"cap":0,"mega":0,"pop":532559,"lon":1.08,"lat":49.43},{"n":"Reims","r":7,"cap":0,"mega":0,"pop":196565,"lon":4.03,"lat":49.25},{"n":"Amiens","r":7,"cap":0,"mega":0,"pop":143086,"lon":2.3,"lat":49.9},{"n":"Nancy","r":7,"cap":0,"mega":0,"pop":268976,"lon":6.2,"lat":48.684},{"n":"Basse-terre","r":7,"cap":0,"mega":0,"pop":307,"lon":-61.705,"lat":16.01},{"n":"Novi Sad","r":7,"cap":0,"mega":0,"pop":225457,"lon":19.85,"lat":45.25},{"n":"Banja Luka","r":7,"cap":0,"mega":0,"pop":221738,"lon":17.18,"lat":44.78},{"n":"George Town","r":7,"cap":0,"mega":0,"pop":4268,"lon":-81.372,"lat":19.295},{"n":"Grand Turk","r":7,"cap":0,"mega":0,"pop":5801,"lon":-71.136,"lat":21.466},{"n":"Douglas","r":7,"cap":0,"mega":0,"pop":35854,"lon":-4.48,"lat":54.149},{"n":"San Marino","r":7,"cap":1,"mega":0,"pop":29579,"lon":12.442,"lat":43.936},{"n":"Willemstad","r":7,"cap":0,"mega":0,"pop":146813,"lon":-68.872,"lat":12.112},{"n":"Oranjestad","r":7,"cap":0,"mega":0,"pop":68775,"lon":-70.026,"lat":12.522},{"n":"Vaduz","r":7,"cap":1,"mega":0,"pop":36281,"lon":9.517,"lat":47.134},{"n":"Capitan Arturo Prat Station","r":7,"cap":0,"mega":0,"pop":41,"lon":-59.683,"lat":-62.5},{"n":"Marambio Base","r":7,"cap":0,"mega":0,"pop":150,"lon":-56.65,"lat":-64.233},{"n":"Zucchelli Station","r":7,"cap":0,"mega":0,"pop":80,"lon":164.219,"lat":-74.619},{"n":"Rothera Station","r":7,"cap":0,"mega":0,"pop":130,"lon":-68.124,"lat":-67.565},{"n":"Palmer Station","r":7,"cap":0,"mega":0,"pop":46,"lon":-64.047,"lat":-64.762},{"n":"Base Presidente Montalva","r":7,"cap":0,"mega":0,"pop":150,"lon":-58.908,"lat":-62.183},{"n":"Carlini Base","r":7,"cap":0,"mega":0,"pop":60,"lon":-58.65,"lat":-62.224},{"n":"King Sejong Station","r":7,"cap":0,"mega":0,"pop":90,"lon":-58.772,"lat":-62.225},{"n":"Great Wall Station","r":7,"cap":0,"mega":0,"pop":40,"lon":-58.967,"lat":-62.216},{"n":"Escudero Base","r":7,"cap":0,"mega":0,"pop":20,"lon":-58.953,"lat":-62.195},{"n":"Elephant Island","r":7,"cap":0,"mega":0,"pop":6,"lon":-58.0,"lat":-62.0},{"n":"Scott Base","r":7,"cap":0,"mega":0,"pop":85,"lon":166.749,"lat":-77.847},{"n":"McMurdo Station","r":7,"cap":0,"mega":0,"pop":1000,"lon":166.869,"lat":-77.732},{"n":"Zhongshan Station","r":7,"cap":0,"mega":0,"pop":60,"lon":76.339,"lat":-69.434},{"n":"Vostok","r":7,"cap":0,"mega":0,"pop":25,"lon":106.8,"lat":-78.466},{"n":"Peter I Island","r":7,"cap":0,"mega":0,"pop":1,"lon":-90.584,"lat":-68.795},{"n":"Mirny Station","r":7,"cap":0,"mega":0,"pop":169,"lon":93.006,"lat":-66.576},{"n":"Mawson Station","r":7,"cap":0,"mega":0,"pop":60,"lon":62.875,"lat":-67.615},{"n":"Davis Station","r":7,"cap":0,"mega":0,"pop":70,"lon":78.141,"lat":-68.778},{"n":"Concordia Research Station","r":7,"cap":0,"mega":0,"pop":30,"lon":124.167,"lat":-74.666},{"n":"Casey Station","r":7,"cap":0,"mega":0,"pop":200,"lon":110.536,"lat":-66.286},{"n":"AmundseniScott South Pole Station","r":7,"cap":0,"mega":0,"pop":200,"lon":176.994,"lat":-90},{"n":"Wasa Station","r":7,"cap":0,"mega":0,"pop":10,"lon":-13.417,"lat":-73.05},{"n":"Troll Station","r":7,"cap":0,"mega":0,"pop":44,"lon":2.533,"lat":-72.016},{"n":"Svea Station","r":7,"cap":0,"mega":0,"pop":10,"lon":-11.217,"lat":-74.583},{"n":"Novolazarevskaya Station","r":7,"cap":0,"mega":0,"pop":70,"lon":-11.85,"lat":-71.301},{"n":"Neumayer Station III","r":7,"cap":0,"mega":0,"pop":40,"lon":-7.838,"lat":-70.89},{"n":"Maitri Station","r":7,"cap":0,"mega":0,"pop":65,"lon":11.73,"lat":-70.782},{"n":"Halley Station","r":7,"cap":0,"mega":0,"pop":70,"lon":-26.474,"lat":-76.092},{"n":"Belgrano II Base","r":7,"cap":0,"mega":0,"pop":100,"lon":-34.617,"lat":-77.866},{"n":"Sobral Base","r":7,"cap":0,"mega":0,"pop":40,"lon":-40.502,"lat":-81.179},{"n":"Aboa Station","r":7,"cap":0,"mega":0,"pop":18,"lon":-13.417,"lat":-75.05},{"n":"San Martin Base","r":7,"cap":0,"mega":0,"pop":20,"lon":-67.1,"lat":-68.116},{"n":"Gen. O'Higgins Base","r":7,"cap":0,"mega":0,"pop":44,"lon":-57.89,"lat":-63.328},{"n":"Esperanza Station","r":7,"cap":0,"mega":0,"pop":250,"lon":-57.026,"lat":-63.375},{"n":"Orcadas Station","r":7,"cap":0,"mega":0,"pop":45,"lon":-44.733,"lat":-60.733},{"n":"Signy Research Station","r":7,"cap":0,"mega":0,"pop":8,"lon":-45.601,"lat":-60.631},{"n":"Dumont d'Urville Station","r":7,"cap":0,"mega":0,"pop":120,"lon":139.964,"lat":-66.775},{"n":"Showa Station","r":7,"cap":0,"mega":0,"pop":60,"lon":39.746,"lat":-69.03},{"n":"Gibraltar","r":6,"cap":0,"mega":0,"pop":187083,"lon":-5.346,"lat":36.128},{"n":"Lobamba","r":6,"cap":0,"mega":0,"pop":9782,"lon":31.2,"lat":-26.467},{"n":"Edinburgh","r":6,"cap":0,"mega":0,"pop":504966,"lon":-3.219,"lat":55.948},{"n":"Cardiff","r":6,"cap":0,"mega":0,"pop":861400,"lon":-3.168,"lat":51.483},{"n":"Longyearbyen","r":6,"cap":0,"mega":0,"pop":1232,"lon":15.621,"lat":78.221},{"n":"Luxembourg","r":6,"cap":1,"mega":0,"pop":107260,"lon":6.13,"lat":49.612},{"n":"Turin","r":6,"cap":0,"mega":1,"pop":1652000,"lon":7.668,"lat":45.072},{"n":"Noumea","r":6,"cap":0,"mega":0,"pop":93060,"lon":166.444,"lat":-22.263},{"n":"Matsuyama","r":6,"cap":0,"mega":0,"pop":533541,"lon":132.766,"lat":33.846},{"n":"Rennes","r":6,"cap":0,"mega":0,"pop":209375,"lon":-1.67,"lat":48.1},{"n":"Toulouse","r":6,"cap":0,"mega":1,"pop":847000,"lon":1.448,"lat":43.622},{"n":"Limoges","r":6,"cap":0,"mega":0,"pop":152199,"lon":1.25,"lat":45.83},{"n":"Lille","r":6,"cap":0,"mega":1,"pop":1044000,"lon":3.078,"lat":50.652},{"n":"Strasbourg","r":6,"cap":0,"mega":0,"pop":439972,"lon":7.75,"lat":48.58},{"n":"Batumi","r":6,"cap":0,"mega":0,"pop":155542,"lon":41.63,"lat":41.6},{"n":"Funchal","r":6,"cap":0,"mega":0,"pop":204767,"lon":-16.88,"lat":32.65},{"n":"El Fasher","r":6,"cap":0,"mega":0,"pop":252609,"lon":25.35,"lat":13.63},{"n":"Keetmanshoop","r":6,"cap":0,"mega":0,"pop":18039,"lon":18.13,"lat":-26.574},{"n":"Swakopmund","r":6,"cap":0,"mega":0,"pop":29491,"lon":14.535,"lat":-22.669},{"n":"Genoa","r":6,"cap":0,"mega":0,"pop":647497,"lon":8.93,"lat":44.41},{"n":"Sukhumi","r":6,"cap":0,"mega":0,"pop":81546,"lon":41.02,"lat":43.02},{"n":"Palikir","r":6,"cap":1,"mega":0,"pop":4645,"lon":158.15,"lat":6.917},{"n":"Majuro","r":6,"cap":1,"mega":0,"pop":25400,"lon":171.38,"lat":7.103},{"n":"Agana","r":6,"cap":0,"mega":0,"pop":122411,"lon":144.75,"lat":13.47},{"n":"Funafuti","r":6,"cap":1,"mega":0,"pop":4749,"lon":179.217,"lat":-8.517},{"n":"Melekeok","r":6,"cap":1,"mega":0,"pop":7026,"lon":134.627,"lat":7.487},{"n":"Bir Lehlou","r":6,"cap":0,"mega":0,"pop":500,"lon":-9.653,"lat":26.119},{"n":"Monaco","r":6,"cap":1,"mega":0,"pop":36371,"lon":7.407,"lat":43.74},{"n":"Tarawa","r":6,"cap":1,"mega":0,"pop":28802,"lon":173.018,"lat":1.338},{"n":"Moroni","r":6,"cap":1,"mega":0,"pop":128698,"lon":43.24,"lat":-11.704},{"n":"Macau","r":6,"cap":0,"mega":0,"pop":568700,"lon":113.544,"lat":22.189},{"n":"Andorra","r":6,"cap":1,"mega":0,"pop":53998,"lon":1.527,"lat":42.511},{"n":"San Bernardino","r":4,"cap":0,"mega":1,"pop":1745000,"lon":-117.302,"lat":34.122},{"n":"Bridgeport","r":4,"cap":0,"mega":1,"pop":1018000,"lon":-73.202,"lat":41.182},{"n":"Rochester","r":4,"cap":0,"mega":1,"pop":755000,"lon":-77.622,"lat":43.172},{"n":"Manchester","r":4,"cap":0,"mega":1,"pop":2230000,"lon":-2.249,"lat":53.475},{"n":"Gujranwala","r":4,"cap":0,"mega":1,"pop":1513000,"lon":74.183,"lat":32.162},{"n":"Incheon","r":4,"cap":0,"mega":1,"pop":2550000,"lon":126.64,"lat":37.478},{"n":"Benin City","r":4,"cap":0,"mega":1,"pop":1190000,"lon":5.618,"lat":6.342},{"n":"Xiamen","r":4,"cap":0,"mega":1,"pop":2519000,"lon":118.078,"lat":24.452},{"n":"Nanchong","r":4,"cap":0,"mega":1,"pop":2174000,"lon":106.128,"lat":30.782},{"n":"Neijiang","r":4,"cap":0,"mega":1,"pop":1466000,"lon":105.048,"lat":29.582},{"n":"Nanyang","r":4,"cap":0,"mega":1,"pop":1944000,"lon":112.528,"lat":33.002},{"n":"Jinxi","r":4,"cap":0,"mega":1,"pop":2426000,"lon":120.828,"lat":40.752},{"n":"Yantai","r":4,"cap":0,"mega":1,"pop":2116000,"lon":121.398,"lat":37.532},{"n":"Zaozhuang","r":4,"cap":0,"mega":1,"pop":2145000,"lon":117.568,"lat":34.882},{"n":"Suzhou","r":4,"cap":0,"mega":1,"pop":1650000,"lon":120.618,"lat":31.302},{"n":"Xuzhou","r":4,"cap":0,"mega":1,"pop":2091000,"lon":117.178,"lat":34.282},{"n":"Wuxi","r":4,"cap":0,"mega":1,"pop":1749000,"lon":120.298,"lat":31.582},{"n":"Jilin","r":4,"cap":0,"mega":1,"pop":2396000,"lon":126.548,"lat":43.852},{"n":"Chandigarh","r":4,"cap":0,"mega":1,"pop":979000,"lon":76.778,"lat":30.722},{"n":"Jammu","r":4,"cap":0,"mega":1,"pop":791000,"lon":74.845,"lat":32.714},{"n":"Sholapur","r":4,"cap":0,"mega":1,"pop":1057000,"lon":75.898,"lat":17.672},{"n":"Aurangabad","r":4,"cap":0,"mega":1,"pop":1113000,"lon":75.329,"lat":19.862},{"n":"Nasik","r":4,"cap":0,"mega":1,"pop":1473000,"lon":73.778,"lat":20.002},{"n":"Dispur","r":4,"cap":0,"mega":0,"pop":16140,"lon":91.767,"lat":26.144},{"n":"Jullundur","r":4,"cap":0,"mega":1,"pop":855000,"lon":75.567,"lat":31.337},{"n":"Allahabad","r":4,"cap":0,"mega":1,"pop":1201000,"lon":81.838,"lat":25.457},{"n":"Moradabad","r":4,"cap":0,"mega":1,"pop":787000,"lon":78.755,"lat":28.844},{"n":"Ghaziabad","r":4,"cap":0,"mega":1,"pop":1341000,"lon":77.406,"lat":28.662},{"n":"Agra","r":4,"cap":0,"mega":1,"pop":1592000,"lon":78.013,"lat":27.172},{"n":"Aligarh","r":4,"cap":0,"mega":1,"pop":805000,"lon":78.06,"lat":27.894},{"n":"Meerut","r":4,"cap":0,"mega":1,"pop":1398000,"lon":77.698,"lat":29.002},{"n":"Dhanbad","r":4,"cap":0,"mega":1,"pop":1246000,"lon":86.418,"lat":23.802},{"n":"Gwalior","r":4,"cap":0,"mega":1,"pop":978000,"lon":78.169,"lat":26.2},{"n":"Vadodara","r":4,"cap":0,"mega":1,"pop":1756000,"lon":73.178,"lat":22.312},{"n":"Rajkot","r":4,"cap":0,"mega":1,"pop":1260000,"lon":70.798,"lat":22.312},{"n":"Durazno","r":4,"cap":0,"mega":0,"pop":34037,"lon":-56.51,"lat":-33.41},{"n":"International Falls","r":4,"cap":0,"mega":0,"pop":15240,"lon":-93.411,"lat":48.601},{"n":"St. Paul","r":4,"cap":0,"mega":0,"pop":734854,"lon":-93.085,"lat":44.944},{"n":"Billings","r":4,"cap":0,"mega":0,"pop":104552,"lon":-108.54,"lat":45.788},{"n":"Great Falls","r":4,"cap":0,"mega":0,"pop":66558,"lon":-111.3,"lat":47.5},{"n":"Missoula","r":4,"cap":0,"mega":0,"pop":72856,"lon":-113.993,"lat":46.872},{"n":"Minot","r":4,"cap":0,"mega":0,"pop":39439,"lon":-101.296,"lat":48.232},{"n":"Fargo","r":4,"cap":0,"mega":0,"pop":162842,"lon":-96.789,"lat":46.877},{"n":"Hilo","r":4,"cap":0,"mega":0,"pop":52391,"lon":-155.082,"lat":19.706},{"n":"Olympia","r":4,"cap":0,"mega":0,"pop":156984,"lon":-122.899,"lat":47.038},{"n":"Spokane","r":4,"cap":0,"mega":0,"pop":347705,"lon":-117.42,"lat":47.67},{"n":"Vancouver","r":4,"cap":0,"mega":0,"pop":525802,"lon":-122.64,"lat":45.63},{"n":"Flagstaff","r":4,"cap":0,"mega":0,"pop":63993,"lon":-111.651,"lat":35.198},{"n":"Tucson","r":4,"cap":0,"mega":1,"pop":823000,"lon":-110.892,"lat":32.207},{"n":"Santa Barbara","r":4,"cap":0,"mega":0,"pop":181632,"lon":-119.717,"lat":34.411},{"n":"Fresno","r":4,"cap":0,"mega":0,"pop":616353,"lon":-119.773,"lat":36.748},{"n":"Eureka","r":4,"cap":0,"mega":0,"pop":42398,"lon":-124.15,"lat":40.793},{"n":"Colorado Springs","r":4,"cap":0,"mega":0,"pop":493654,"lon":-104.792,"lat":38.863},{"n":"Reno","r":4,"cap":0,"mega":0,"pop":328585,"lon":-119.82,"lat":39.53},{"n":"Elko","r":4,"cap":0,"mega":0,"pop":19252,"lon":-115.762,"lat":40.833},{"n":"Albuquerque","r":4,"cap":0,"mega":0,"pop":898642,"lon":-106.641,"lat":35.105},{"n":"Salem","r":4,"cap":0,"mega":0,"pop":229010,"lon":-123.024,"lat":44.928},{"n":"Casper","r":4,"cap":0,"mega":0,"pop":60791,"lon":-106.323,"lat":42.847},{"n":"Topeka","r":4,"cap":0,"mega":0,"pop":132091,"lon":-95.67,"lat":39.05},{"n":"Kansas City","r":4,"cap":0,"mega":1,"pop":1469000,"lon":-94.606,"lat":39.109},{"n":"Tulsa","r":4,"cap":0,"mega":0,"pop":946962,"lon":-95.93,"lat":36.12},{"n":"Sioux Falls","r":4,"cap":0,"mega":0,"pop":155724,"lon":-96.73,"lat":43.55},{"n":"Shreveport","r":4,"cap":0,"mega":0,"pop":248053,"lon":-93.77,"lat":32.5},{"n":"Baton Rouge","r":4,"cap":0,"mega":0,"pop":422072,"lon":-91.14,"lat":30.458},{"n":"Ft. Worth","r":4,"cap":0,"mega":0,"pop":1440454,"lon":-97.34,"lat":32.74},{"n":"Corpus Christi","r":4,"cap":0,"mega":0,"pop":277454,"lon":-97.402,"lat":27.743},{"n":"Austin","r":4,"cap":0,"mega":1,"pop":1161000,"lon":-97.745,"lat":30.269},{"n":"Amarillo","r":4,"cap":0,"mega":0,"pop":181766,"lon":-101.83,"lat":35.23},{"n":"El Paso","r":4,"cap":0,"mega":1,"pop":753000,"lon":-106.512,"lat":31.782},{"n":"Laredo","r":4,"cap":0,"mega":0,"pop":434768,"lon":-99.507,"lat":27.506},{"n":"Merida","r":4,"cap":0,"mega":0,"pop":345489,"lon":-71.183,"lat":8.57},{"n":"Burlington","r":4,"cap":0,"mega":0,"pop":93807,"lon":-73.212,"lat":44.476},{"n":"Montgomery","r":4,"cap":0,"mega":0,"pop":198325,"lon":-86.279,"lat":32.362},{"n":"Tallahassee","r":4,"cap":0,"mega":0,"pop":221222,"lon":-84.28,"lat":30.45},{"n":"Orlando","r":4,"cap":0,"mega":1,"pop":1350000,"lon":-81.382,"lat":28.512},{"n":"Jacksonville","r":4,"cap":0,"mega":1,"pop":988000,"lon":-81.672,"lat":30.332},{"n":"Savannah","r":4,"cap":0,"mega":0,"pop":180187,"lon":-81.11,"lat":32.021},{"n":"Columbia","r":4,"cap":0,"mega":0,"pop":398093,"lon":-81.031,"lat":33.997},{"n":"Indianapolis","r":4,"cap":0,"mega":1,"pop":1436000,"lon":-86.172,"lat":39.752},{"n":"Wilmington","r":4,"cap":0,"mega":0,"pop":161560,"lon":-77.945,"lat":34.226},{"n":"Knoxville","r":4,"cap":0,"mega":0,"pop":655400,"lon":-83.92,"lat":35.97},{"n":"Richmond","r":4,"cap":0,"mega":1,"pop":912000,"lon":-77.452,"lat":37.552},{"n":"Charleston","r":4,"cap":0,"mega":0,"pop":123799,"lon":-81.633,"lat":38.35},{"n":"Baltimore","r":4,"cap":0,"mega":1,"pop":2255000,"lon":-76.615,"lat":39.282},{"n":"Syracuse","r":4,"cap":0,"mega":0,"pop":662577,"lon":-76.15,"lat":43.05},{"n":"Puerto Ayacucho","r":4,"cap":0,"mega":0,"pop":52526,"lon":-67.624,"lat":5.664},{"n":"Port-of-Spain","r":4,"cap":1,"mega":0,"pop":294934,"lon":-61.517,"lat":10.652},{"n":"Augusta","r":4,"cap":0,"mega":0,"pop":24042,"lon":-69.78,"lat":44.311},{"n":"Sault Ste. Marie","r":4,"cap":0,"mega":0,"pop":83805,"lon":-84.345,"lat":46.495},{"n":"Atakpame","r":4,"cap":0,"mega":0,"pop":80683,"lon":1.12,"lat":7.53},{"n":"Sousse","r":4,"cap":0,"mega":0,"pop":327004,"lon":10.625,"lat":35.83},{"n":"Taizz","r":4,"cap":0,"mega":1,"pop":751000,"lon":44.037,"lat":13.606},{"n":"Sitka","r":4,"cap":0,"mega":0,"pop":8931,"lon":-135.328,"lat":57.06},{"n":"Lvov","r":4,"cap":0,"mega":0,"pop":803880,"lon":24.03,"lat":49.835},{"n":"Odessa","r":4,"cap":0,"mega":1,"pop":991000,"lon":30.708,"lat":46.492},{"n":"Zhytomyr","r":4,"cap":0,"mega":0,"pop":282192,"lon":28.662,"lat":50.246},{"n":"Dnipro","r":4,"cap":0,"mega":1,"pop":1050000,"lon":34.998,"lat":48.482},{"n":"Donetsk","r":4,"cap":0,"mega":1,"pop":988000,"lon":37.828,"lat":48.002},{"n":"Kharkiv","r":4,"cap":0,"mega":1,"pop":1461000,"lon":36.248,"lat":50.002},{"n":"Turkmenbasy","r":4,"cap":0,"mega":0,"pop":68292,"lon":52.97,"lat":40.023},{"n":"Bukhara","r":4,"cap":0,"mega":0,"pop":319476,"lon":64.43,"lat":39.78},{"n":"Nukus","r":4,"cap":0,"mega":0,"pop":230006,"lon":59.615,"lat":42.47},{"n":"Turkmenabat","r":4,"cap":0,"mega":0,"pop":234817,"lon":63.58,"lat":39.11},{"n":"Mary","r":4,"cap":0,"mega":0,"pop":178708,"lon":61.833,"lat":37.6},{"n":"Andijon","r":4,"cap":0,"mega":0,"pop":650000,"lon":72.34,"lat":40.79},{"n":"Haiphong","r":4,"cap":0,"mega":1,"pop":1969000,"lon":106.678,"lat":20.832},{"n":"Da Nang","r":4,"cap":0,"mega":0,"pop":1000000,"lon":108.25,"lat":16.06},{"n":"Kabwe","r":4,"cap":0,"mega":0,"pop":188979,"lon":28.45,"lat":-14.44},{"n":"Mufulira","r":4,"cap":0,"mega":0,"pop":153624,"lon":28.26,"lat":-12.55},{"n":"Kitwe","r":4,"cap":0,"mega":0,"pop":404901,"lon":28.22,"lat":-12.81},{"n":"Livingstone","r":4,"cap":0,"mega":0,"pop":165480,"lon":25.86,"lat":-17.86},{"n":"Chitungwiza","r":4,"cap":0,"mega":0,"pop":340360,"lon":31.1,"lat":-18.0},{"n":"Douala","r":4,"cap":0,"mega":1,"pop":1906000,"lon":9.708,"lat":4.062},{"n":"Birmingham","r":4,"cap":0,"mega":1,"pop":2285000,"lon":-1.922,"lat":52.477},{"n":"Belfast","r":4,"cap":0,"mega":0,"pop":450406,"lon":-5.928,"lat":54.594},{"n":"Izmir","r":4,"cap":0,"mega":1,"pop":2587000,"lon":27.15,"lat":38.438},{"n":"Bursa","r":4,"cap":0,"mega":1,"pop":1492000,"lon":29.068,"lat":40.202},{"n":"Samsun","r":4,"cap":0,"mega":0,"pop":609339,"lon":36.344,"lat":41.28},{"n":"Konya","r":4,"cap":0,"mega":1,"pop":919000,"lon":32.473,"lat":37.877},{"n":"Adana","r":4,"cap":0,"mega":1,"pop":1293000,"lon":35.318,"lat":36.997},{"n":"Gulu","r":4,"cap":0,"mega":0,"pop":146858,"lon":32.28,"lat":2.78},{"n":"Kigali","r":4,"cap":1,"mega":1,"pop":860000,"lon":30.059,"lat":-1.952},{"n":"Cottica","r":4,"cap":0,"mega":0,"pop":29210,"lon":-54.233,"lat":3.85},{"n":"Cordoba","r":4,"cap":0,"mega":0,"pop":321376,"lon":-4.77,"lat":37.88},{"n":"Maradi","r":4,"cap":0,"mega":0,"pop":232555,"lon":7.096,"lat":13.492},{"n":"Tahoua","r":4,"cap":0,"mega":0,"pop":115956,"lon":5.26,"lat":14.9},{"n":"Constanta","r":4,"cap":0,"mega":0,"pop":303399,"lon":28.61,"lat":44.203},{"n":"Lulea","r":4,"cap":0,"mega":0,"pop":48638,"lon":22.158,"lat":65.597},{"n":"Sundsvall","r":4,"cap":0,"mega":0,"pop":73389,"lon":17.317,"lat":62.4},{"n":"Iasi","r":4,"cap":0,"mega":0,"pop":325914,"lon":27.575,"lat":47.168},{"n":"Surat Thani","r":4,"cap":0,"mega":0,"pop":157627,"lon":99.34,"lat":9.15},{"n":"Chiang Mai","r":4,"cap":0,"mega":0,"pop":397211,"lon":98.98,"lat":18.8},{"n":"Nakhon Ratchasima","r":4,"cap":0,"mega":0,"pop":334984,"lon":102.1,"lat":15.0},{"n":"Mbabane","r":4,"cap":1,"mega":0,"pop":90138,"lon":31.133,"lat":-26.317},{"n":"Piura","r":4,"cap":0,"mega":0,"pop":396932,"lon":-80.63,"lat":-5.21},{"n":"Arequipa","r":4,"cap":0,"mega":1,"pop":815000,"lon":-71.532,"lat":-16.418},{"n":"Chimbote","r":4,"cap":0,"mega":0,"pop":349846,"lon":-78.57,"lat":-9.07},{"n":"Pucallpa","r":4,"cap":0,"mega":0,"pop":310750,"lon":-74.535,"lat":-8.369},{"n":"Iquitos","r":4,"cap":0,"mega":0,"pop":458729,"lon":-73.25,"lat":-3.75},{"n":"Huancayo","r":4,"cap":0,"mega":0,"pop":412733,"lon":-75.2,"lat":-12.08},{"n":"Ciudad del Este","r":4,"cap":0,"mega":0,"pop":320872,"lon":-54.616,"lat":-25.517},{"n":"Ponta Delgada","r":4,"cap":0,"mega":0,"pop":61526,"lon":-25.667,"lat":37.748},{"n":"Vigo","r":4,"cap":0,"mega":0,"pop":378952,"lon":-8.73,"lat":42.22},{"n":"Bilbao","r":4,"cap":0,"mega":0,"pop":875552,"lon":-2.93,"lat":43.25},{"n":"Kaolack","r":4,"cap":0,"mega":0,"pop":277812,"lon":-16.1,"lat":14.15},{"n":"Kaedi","r":4,"cap":0,"mega":0,"pop":21656,"lon":-13.5,"lat":16.15},{"n":"Geneina","r":4,"cap":0,"mega":0,"pop":162981,"lon":22.44,"lat":13.45},{"n":"Medina","r":4,"cap":0,"mega":1,"pop":1010000,"lon":39.578,"lat":24.502},{"n":"Tabuk","r":4,"cap":0,"mega":0,"pop":547957,"lon":36.555,"lat":28.384},{"n":"Juba","r":4,"cap":0,"mega":0,"pop":111975,"lon":31.58,"lat":4.83},{"n":"Malakal","r":4,"cap":0,"mega":0,"pop":160765,"lon":31.656,"lat":9.537},{"n":"Omdurman","r":4,"cap":0,"mega":0,"pop":2395159,"lon":32.48,"lat":15.617},{"n":"El Obeid","r":4,"cap":0,"mega":0,"pop":393311,"lon":30.217,"lat":13.183},{"n":"The Hague","r":4,"cap":0,"mega":0,"pop":1406000,"lon":4.27,"lat":52.08},{"n":"Kristiansand","r":4,"cap":0,"mega":0,"pop":63814,"lon":7.975,"lat":58.146},{"n":"Ljubljana","r":4,"cap":1,"mega":0,"pop":314807,"lon":14.515,"lat":46.055},{"n":"Bratislava","r":4,"cap":1,"mega":0,"pop":423737,"lon":17.117,"lat":48.15},{"n":"Hammerfest","r":4,"cap":0,"mega":0,"pop":10000,"lon":23.688,"lat":70.661},{"n":"Doha","r":4,"cap":1,"mega":0,"pop":1450000,"lon":51.533,"lat":25.287},{"n":"Quetta","r":4,"cap":0,"mega":1,"pop":768000,"lon":67.023,"lat":30.222},{"n":"Larkana","r":4,"cap":0,"mega":0,"pop":364033,"lon":68.207,"lat":27.562},{"n":"Springbok","r":4,"cap":0,"mega":0,"pop":10438,"lon":17.883,"lat":-29.667},{"n":"Upington","r":4,"cap":0,"mega":0,"pop":71373,"lon":21.23,"lat":-28.46},{"n":"Worcester","r":4,"cap":0,"mega":0,"pop":127597,"lon":19.44,"lat":-33.64},{"n":"George","r":4,"cap":0,"mega":0,"pop":174582,"lon":22.45,"lat":-33.95},{"n":"Tete","r":4,"cap":0,"mega":0,"pop":129316,"lon":33.58,"lat":-16.17},{"n":"Pemba","r":4,"cap":0,"mega":0,"pop":110643,"lon":40.532,"lat":-12.983},{"n":"Nampula","r":4,"cap":0,"mega":0,"pop":388526,"lon":39.293,"lat":-15.136},{"n":"Welkom","r":4,"cap":0,"mega":0,"pop":431944,"lon":26.73,"lat":-27.97},{"n":"Xai-Xai","r":4,"cap":0,"mega":0,"pop":128805,"lon":33.64,"lat":-25.04},{"n":"Goroka","r":4,"cap":0,"mega":0,"pop":39699,"lon":145.385,"lat":-6.083},{"n":"Mt. Hagen","r":4,"cap":0,"mega":0,"pop":59064,"lon":144.217,"lat":-5.863},{"n":"Rabaul","r":4,"cap":0,"mega":0,"pop":8074,"lon":152.143,"lat":-4.205},{"n":"Lae","r":4,"cap":0,"mega":0,"pop":131052,"lon":146.99,"lat":-6.733},{"n":"David","r":4,"cap":0,"mega":0,"pop":110037,"lon":-82.433,"lat":8.433},{"n":"Oujda","r":4,"cap":0,"mega":0,"pop":409391,"lon":-1.91,"lat":34.69},{"n":"Safi","r":4,"cap":0,"mega":0,"pop":353476,"lon":-9.24,"lat":32.32},{"n":"Podgorica","r":4,"cap":1,"mega":0,"pop":145850,"lon":19.266,"lat":42.466},{"n":"Quelimane","r":4,"cap":0,"mega":0,"pop":188964,"lon":36.89,"lat":-17.88},{"n":"East London","r":4,"cap":0,"mega":0,"pop":338627,"lon":27.87,"lat":-32.97},{"n":"Middelburg","r":4,"cap":0,"mega":0,"pop":18164,"lon":25.01,"lat":-31.5},{"n":"Naltchik","r":4,"cap":0,"mega":0,"pop":307866,"lon":43.618,"lat":43.498},{"n":"Stavropol","r":4,"cap":0,"mega":0,"pop":363064,"lon":41.98,"lat":45.05},{"n":"Ugolnye Kopi","r":4,"cap":0,"mega":0,"pop":3367,"lon":177.7,"lat":64.733},{"n":"Kaliningrad","r":4,"cap":0,"mega":0,"pop":434954,"lon":20.497,"lat":54.7},{"n":"Pskov","r":4,"cap":0,"mega":0,"pop":201990,"lon":28.33,"lat":57.83},{"n":"Bryansk","r":4,"cap":0,"mega":0,"pop":427236,"lon":34.43,"lat":53.26},{"n":"Smolensk","r":4,"cap":0,"mega":0,"pop":320991,"lon":32.047,"lat":54.783},{"n":"Petrozavodsk","r":4,"cap":0,"mega":0,"pop":265025,"lon":34.361,"lat":61.781},{"n":"Tver","r":4,"cap":0,"mega":0,"pop":400212,"lon":35.89,"lat":56.86},{"n":"Vologda","r":4,"cap":0,"mega":0,"pop":294889,"lon":39.92,"lat":59.21},{"n":"Yaroslavl","r":4,"cap":0,"mega":0,"pop":606730,"lon":39.87,"lat":57.62},{"n":"Rostov","r":4,"cap":0,"mega":1,"pop":1052000,"lon":39.711,"lat":47.237},{"n":"Sochi","r":4,"cap":0,"mega":0,"pop":327608,"lon":39.73,"lat":43.59},{"n":"Krasnodar","r":4,"cap":0,"mega":0,"pop":649851,"lon":39.0,"lat":45.02},{"n":"Penza","r":4,"cap":0,"mega":0,"pop":512602,"lon":45.0,"lat":53.18},{"n":"Ryazan","r":4,"cap":0,"mega":0,"pop":520173,"lon":39.72,"lat":54.62},{"n":"Voronezh","r":4,"cap":0,"mega":1,"pop":844000,"lon":39.187,"lat":51.665},{"n":"Magnitogorsk","r":4,"cap":0,"mega":0,"pop":413351,"lon":58.98,"lat":53.423},{"n":"Chelyabinsk","r":4,"cap":0,"mega":1,"pop":1091000,"lon":61.437,"lat":55.157},{"n":"Vorkuta","r":4,"cap":0,"mega":0,"pop":80039,"lon":64.01,"lat":67.5},{"n":"Kirov","r":4,"cap":0,"mega":0,"pop":457437,"lon":49.67,"lat":58.59},{"n":"Nizhny Tagil","r":4,"cap":0,"mega":0,"pop":381116,"lon":59.975,"lat":57.92},{"n":"Astrakhan","r":4,"cap":0,"mega":0,"pop":502533,"lon":48.055,"lat":46.349},{"n":"Orenburg","r":4,"cap":0,"mega":0,"pop":550204,"lon":55.11,"lat":51.78},{"n":"Saratov","r":4,"cap":0,"mega":1,"pop":843000,"lon":46.028,"lat":51.582},{"n":"Ulyanovsk","r":4,"cap":0,"mega":0,"pop":640680,"lon":48.41,"lat":54.33},{"n":"Omsk","r":4,"cap":0,"mega":1,"pop":1135000,"lon":73.398,"lat":54.992},{"n":"Tyumen","r":4,"cap":0,"mega":0,"pop":519119,"lon":65.53,"lat":57.14},{"n":"Novokuznetsk","r":4,"cap":0,"mega":0,"pop":539616,"lon":87.115,"lat":53.75},{"n":"Kemerovo","r":4,"cap":0,"mega":0,"pop":477090,"lon":86.09,"lat":55.34},{"n":"Groznyy","r":4,"cap":0,"mega":0,"pop":226100,"lon":45.699,"lat":43.319},{"n":"Kandy","r":4,"cap":0,"mega":0,"pop":111701,"lon":80.67,"lat":7.28},{"n":"Sri Jawewardenepura Kotte","r":4,"cap":0,"mega":0,"pop":115826,"lon":79.95,"lat":6.9},{"n":"Daejeon","r":4,"cap":0,"mega":1,"pop":1468000,"lon":127.423,"lat":36.337},{"n":"Gwangju","r":4,"cap":0,"mega":1,"pop":1440000,"lon":126.908,"lat":35.173},{"n":"Busan","r":4,"cap":0,"mega":1,"pop":3480000,"lon":129.008,"lat":35.097},{"n":"Zamboanga","r":4,"cap":0,"mega":1,"pop":773000,"lon":122.078,"lat":6.922},{"n":"Laoag","r":4,"cap":0,"mega":0,"pop":207048,"lon":120.594,"lat":18.199},{"n":"Baguio City","r":4,"cap":0,"mega":0,"pop":447824,"lon":120.57,"lat":16.43},{"n":"General Santos","r":4,"cap":0,"mega":0,"pop":950530,"lon":125.175,"lat":6.111},{"n":"Ust-Ulimsk","r":4,"cap":0,"mega":0,"pop":100271,"lon":102.633,"lat":57.99},{"n":"Angarsk","r":4,"cap":0,"mega":0,"pop":243158,"lon":103.92,"lat":52.56},{"n":"Abakan","r":4,"cap":0,"mega":0,"pop":167289,"lon":91.445,"lat":53.704},{"n":"Norilsk","r":4,"cap":0,"mega":0,"pop":165873,"lon":88.225,"lat":69.34},{"n":"Khatanga","r":4,"cap":0,"mega":0,"pop":3205,"lon":102.465,"lat":72.041},{"n":"Kyzyl","r":4,"cap":0,"mega":0,"pop":108240,"lon":94.383,"lat":51.707},{"n":"Ulan Ude","r":4,"cap":0,"mega":0,"pop":360278,"lon":107.625,"lat":51.825},{"n":"Blagoveshchensk","r":4,"cap":0,"mega":0,"pop":221296,"lon":127.533,"lat":50.267},{"n":"Bukachacha","r":4,"cap":0,"mega":0,"pop":3372,"lon":116.917,"lat":52.983},{"n":"Dalnegorsk","r":4,"cap":0,"mega":0,"pop":8123,"lon":135.517,"lat":44.537},{"n":"Ambarchik","r":4,"cap":0,"mega":0,"pop":0,"lon":162.334,"lat":69.651},{"n":"Batagay","r":4,"cap":0,"mega":0,"pop":4266,"lon":134.635,"lat":67.656},{"n":"Chokurdakh","r":4,"cap":0,"mega":0,"pop":2506,"lon":147.895,"lat":70.618},{"n":"Ust Nera","r":4,"cap":0,"mega":0,"pop":9148,"lon":143.2,"lat":64.567},{"n":"Lensk","r":4,"cap":0,"mega":0,"pop":25589,"lon":114.947,"lat":60.725},{"n":"Aldan","r":4,"cap":0,"mega":0,"pop":24426,"lon":125.389,"lat":58.603},{"n":"Mirnyy","r":4,"cap":0,"mega":0,"pop":40308,"lon":113.961,"lat":62.54},{"n":"Zhigansk","r":4,"cap":0,"mega":0,"pop":3237,"lon":123.371,"lat":66.77},{"n":"Okhotsk","r":4,"cap":0,"mega":0,"pop":5570,"lon":143.217,"lat":59.383},{"n":"Khabarovsk","r":4,"cap":0,"mega":0,"pop":579000,"lon":135.12,"lat":48.455},{"n":"Okha","r":4,"cap":0,"mega":0,"pop":26560,"lon":142.948,"lat":53.574},{"n":"Yuzhno Sakhalinsk","r":4,"cap":0,"mega":0,"pop":176484,"lon":142.74,"lat":46.965},{"n":"Mexicali","r":4,"cap":0,"mega":1,"pop":885000,"lon":-115.482,"lat":32.652},{"n":"La Paz","r":4,"cap":0,"mega":0,"pop":189767,"lon":-110.32,"lat":24.14},{"n":"Torreon","r":4,"cap":0,"mega":1,"pop":1144000,"lon":-103.422,"lat":25.572},{"n":"Culiacan","r":4,"cap":0,"mega":1,"pop":809000,"lon":-107.382,"lat":24.832},{"n":"Nogales","r":4,"cap":0,"mega":0,"pop":178097,"lon":-110.945,"lat":31.305},{"n":"Hermosillo","r":4,"cap":0,"mega":0,"pop":595811,"lon":-110.954,"lat":29.099},{"n":"Guaymas","r":4,"cap":0,"mega":0,"pop":103449,"lon":-110.89,"lat":27.93},{"n":"San Luis Potosi","r":4,"cap":0,"mega":1,"pop":992000,"lon":-101.002,"lat":22.172},{"n":"Matamoros","r":4,"cap":0,"mega":0,"pop":538785,"lon":-97.5,"lat":25.88},{"n":"Nuevo Laredo","r":4,"cap":0,"mega":0,"pop":349550,"lon":-99.55,"lat":27.5},{"n":"Colima","r":4,"cap":0,"mega":0,"pop":223287,"lon":-103.72,"lat":19.23},{"n":"Campeche","r":4,"cap":0,"mega":0,"pop":205212,"lon":-90.5,"lat":19.83},{"n":"Oaxaca","r":4,"cap":0,"mega":0,"pop":530728,"lon":-96.67,"lat":17.083},{"n":"Leon","r":4,"cap":0,"mega":1,"pop":1488000,"lon":-101.702,"lat":21.152},{"n":"Maiduguri","r":4,"cap":0,"mega":1,"pop":896000,"lon":13.158,"lat":11.852},{"n":"Port Harcourt","r":4,"cap":0,"mega":1,"pop":1020000,"lon":7.008,"lat":4.812},{"n":"Makurdi","r":4,"cap":0,"mega":0,"pop":292645,"lon":8.53,"lat":7.73},{"n":"Ibadan","r":4,"cap":0,"mega":1,"pop":2628000,"lon":3.928,"lat":7.382},{"n":"Ogbomosho","r":4,"cap":0,"mega":1,"pop":951000,"lon":4.238,"lat":8.132},{"n":"Warri","r":4,"cap":0,"mega":0,"pop":830106,"lon":5.76,"lat":5.52},{"n":"Kaduna","r":4,"cap":0,"mega":1,"pop":1442000,"lon":7.438,"lat":10.522},{"n":"Gdansk","r":4,"cap":0,"mega":0,"pop":740000,"lon":18.64,"lat":54.36},{"n":"Krakow","r":4,"cap":0,"mega":1,"pop":756000,"lon":19.958,"lat":50.062},{"n":"Dalandzadgad","r":4,"cap":0,"mega":0,"pop":15093,"lon":104.44,"lat":43.584},{"n":"Wonsan","r":4,"cap":0,"mega":0,"pop":329207,"lon":127.431,"lat":39.16},{"n":"Sinuiju","r":4,"cap":0,"mega":0,"pop":288112,"lon":124.421,"lat":40.086},{"n":"Dund-Us","r":4,"cap":0,"mega":0,"pop":30500,"lon":91.633,"lat":48.017},{"n":"Choybalsan","r":4,"cap":0,"mega":0,"pop":33376,"lon":114.506,"lat":48.067},{"n":"Luderitz","r":4,"cap":0,"mega":0,"pop":15137,"lon":15.159,"lat":-26.648},{"n":"Walvis Bay","r":4,"cap":0,"mega":0,"pop":52058,"lon":14.505,"lat":-22.958},{"n":"Mwanza","r":4,"cap":0,"mega":0,"pop":493944,"lon":32.93,"lat":-2.52},{"n":"Morogoro","r":4,"cap":0,"mega":0,"pop":250902,"lon":37.66,"lat":-6.82},{"n":"Dodoma","r":4,"cap":0,"mega":0,"pop":218269,"lon":35.75,"lat":-6.183},{"n":"Arusha","r":4,"cap":0,"mega":0,"pop":341136,"lon":36.67,"lat":-3.36},{"n":"Bern","r":4,"cap":1,"mega":0,"pop":275329,"lon":7.467,"lat":46.917},{"n":"Malmo","r":4,"cap":0,"mega":0,"pop":269349,"lon":13.023,"lat":55.599},{"n":"Laayoune","r":4,"cap":0,"mega":0,"pop":188084,"lon":-13.2,"lat":27.15},{"n":"Ternate","r":4,"cap":0,"mega":0,"pop":187521,"lon":127.363,"lat":0.793},{"n":"Ambon","r":4,"cap":0,"mega":0,"pop":355596,"lon":128.2,"lat":-3.717},{"n":"Raba","r":4,"cap":0,"mega":0,"pop":106101,"lon":118.767,"lat":-8.45},{"n":"Jayapura","r":4,"cap":0,"mega":0,"pop":169341,"lon":140.7,"lat":-2.533},{"n":"Florence","r":4,"cap":0,"mega":0,"pop":1500000,"lon":11.25,"lat":43.78},{"n":"Catania","r":4,"cap":0,"mega":0,"pop":674542,"lon":15.08,"lat":37.5},{"n":"Pristina","r":4,"cap":1,"mega":0,"pop":465186,"lon":21.166,"lat":42.667},{"n":"Meru","r":4,"cap":0,"mega":0,"pop":47226,"lon":37.64,"lat":0.06},{"n":"Eldoret","r":4,"cap":0,"mega":0,"pop":353381,"lon":35.27,"lat":0.52},{"n":"Banda Aceh","r":4,"cap":0,"mega":0,"pop":513698,"lon":95.32,"lat":5.55},{"n":"George Town","r":4,"cap":0,"mega":0,"pop":2500000,"lon":100.329,"lat":5.414},{"n":"Zhangye","r":4,"cap":0,"mega":0,"pop":230728,"lon":100.45,"lat":38.93},{"n":"Wuwei","r":4,"cap":0,"mega":0,"pop":493092,"lon":102.641,"lat":37.928},{"n":"Dunhuang","r":4,"cap":0,"mega":0,"pop":140094,"lon":94.662,"lat":40.143},{"n":"Tianshui","r":4,"cap":0,"mega":1,"pop":1225000,"lon":105.918,"lat":34.602},{"n":"Dulan","r":4,"cap":0,"mega":0,"pop":100,"lon":98.267,"lat":36.167},{"n":"Golmud","r":4,"cap":0,"mega":0,"pop":107092,"lon":94.883,"lat":36.417},{"n":"Yulin","r":4,"cap":0,"mega":1,"pop":1127000,"lon":110.148,"lat":22.632},{"n":"Bose","r":4,"cap":0,"mega":0,"pop":175282,"lon":106.613,"lat":23.9},{"n":"Wuzhou","r":4,"cap":0,"mega":0,"pop":442315,"lon":111.32,"lat":23.48},{"n":"Liupanshui","r":4,"cap":0,"mega":0,"pop":1221000,"lon":104.831,"lat":26.596},{"n":"Quanzhou","r":4,"cap":0,"mega":1,"pop":1463000,"lon":118.578,"lat":24.902},{"n":"Hefei","r":4,"cap":0,"mega":1,"pop":2035000,"lon":117.278,"lat":31.852},{"n":"Suzhou","r":4,"cap":0,"mega":1,"pop":1964000,"lon":116.977,"lat":33.638},{"n":"Zhanjiang","r":4,"cap":0,"mega":1,"pop":1590000,"lon":110.378,"lat":21.202},{"n":"Shaoguan","r":4,"cap":0,"mega":0,"pop":720266,"lon":113.58,"lat":24.8},{"n":"Balikpapan","r":4,"cap":0,"mega":0,"pop":445905,"lon":116.83,"lat":-1.25},{"n":"Kuching","r":4,"cap":0,"mega":0,"pop":570407,"lon":110.33,"lat":1.53},{"n":"Antsiranana","r":4,"cap":0,"mega":0,"pop":82937,"lon":49.312,"lat":-12.277},{"n":"Fianarantsoa","r":4,"cap":0,"mega":0,"pop":184184,"lon":47.083,"lat":-21.433},{"n":"Mahajanga","r":4,"cap":0,"mega":0,"pop":154657,"lon":46.345,"lat":-15.67},{"n":"Toliara","r":4,"cap":0,"mega":0,"pop":115319,"lon":43.69,"lat":-23.357},{"n":"Surakarta","r":4,"cap":0,"mega":0,"pop":555308,"lon":110.825,"lat":-7.565},{"n":"Bandar Lampung","r":4,"cap":0,"mega":1,"pop":865000,"lon":105.268,"lat":-5.428},{"n":"Tanjungpandan","r":4,"cap":0,"mega":0,"pop":62374,"lon":107.65,"lat":-2.75},{"n":"Malang","r":4,"cap":0,"mega":1,"pop":805000,"lon":112.608,"lat":-7.978},{"n":"Kupang","r":4,"cap":0,"mega":0,"pop":282396,"lon":123.583,"lat":-10.179},{"n":"Parepare","r":4,"cap":0,"mega":0,"pop":87776,"lon":119.633,"lat":-4.017},{"n":"Cuenca","r":4,"cap":0,"mega":0,"pop":286878,"lon":-79.0,"lat":-2.9},{"n":"Santa Cruz","r":4,"cap":0,"mega":0,"pop":11262,"lon":-90.35,"lat":-0.533},{"n":"Puerto Limon","r":4,"cap":0,"mega":0,"pop":85001,"lon":-83.033,"lat":10.0},{"n":"Santiago de Cuba","r":4,"cap":0,"mega":0,"pop":555865,"lon":-75.821,"lat":20.025},{"n":"Santiago","r":4,"cap":0,"mega":0,"pop":1550753,"lon":-70.67,"lat":19.5},{"n":"Manizales","r":4,"cap":0,"mega":0,"pop":375848,"lon":-75.52,"lat":5.06},{"n":"Pasto","r":4,"cap":0,"mega":0,"pop":382236,"lon":-77.281,"lat":1.214},{"n":"Barranquilla","r":4,"cap":0,"mega":1,"pop":1798000,"lon":-74.802,"lat":10.962},{"n":"Roseau","r":4,"cap":1,"mega":0,"pop":23336,"lon":-61.387,"lat":15.301},{"n":"Mbandaka","r":4,"cap":0,"mega":0,"pop":274996,"lon":18.26,"lat":0.04},{"n":"Moundou","r":4,"cap":0,"mega":0,"pop":156705,"lon":16.09,"lat":8.55},{"n":"Suez","r":4,"cap":0,"mega":0,"pop":508335,"lon":32.55,"lat":30.005},{"n":"Bur Said","r":4,"cap":0,"mega":0,"pop":623864,"lon":32.29,"lat":31.26},{"n":"El Faiyum","r":4,"cap":0,"mega":0,"pop":316772,"lon":30.84,"lat":29.31},{"n":"Aswan","r":4,"cap":0,"mega":0,"pop":313442,"lon":32.899,"lat":24.088},{"n":"Asyut","r":4,"cap":0,"mega":0,"pop":420585,"lon":31.18,"lat":27.19},{"n":"Kisangani","r":4,"cap":0,"mega":0,"pop":578470,"lon":25.22,"lat":0.52},{"n":"Assab","r":4,"cap":0,"mega":0,"pop":105496,"lon":42.73,"lat":13.01},{"n":"Djibouti","r":4,"cap":1,"mega":0,"pop":923000,"lon":43.148,"lat":11.595},{"n":"Dresden","r":4,"cap":0,"mega":0,"pop":617515,"lon":13.75,"lat":51.05},{"n":"Xigaze","r":4,"cap":0,"mega":0,"pop":80000,"lon":88.883,"lat":29.25},{"n":"Shache","r":4,"cap":0,"mega":0,"pop":851374,"lon":77.25,"lat":38.426},{"n":"Yining","r":4,"cap":0,"mega":0,"pop":542507,"lon":81.35,"lat":43.9},{"n":"Altay","r":4,"cap":0,"mega":0,"pop":142000,"lon":88.117,"lat":47.867},{"n":"Putrajaya","r":4,"cap":0,"mega":0,"pop":67964,"lon":101.695,"lat":2.933},{"n":"Shizuishan","r":4,"cap":0,"mega":0,"pop":136570,"lon":106.769,"lat":39.233},{"n":"Yulin","r":4,"cap":0,"mega":0,"pop":155960,"lon":109.733,"lat":38.283},{"n":"Ankang","r":4,"cap":0,"mega":0,"pop":1100000,"lon":109.02,"lat":32.68},{"n":"Houma","r":4,"cap":0,"mega":0,"pop":102400,"lon":111.21,"lat":35.62},{"n":"Yueyang","r":4,"cap":0,"mega":1,"pop":826000,"lon":113.098,"lat":29.382},{"n":"Hengyang","r":4,"cap":0,"mega":1,"pop":1016000,"lon":112.588,"lat":26.882},{"n":"Mianyang","r":4,"cap":0,"mega":1,"pop":1396000,"lon":104.69,"lat":31.465},{"n":"Xichang","r":4,"cap":0,"mega":0,"pop":379993,"lon":102.3,"lat":27.88},{"n":"Baoshan","r":4,"cap":0,"mega":0,"pop":1000000,"lon":99.15,"lat":25.12},{"n":"Gejiu","r":4,"cap":0,"mega":0,"pop":149105,"lon":103.15,"lat":23.38},{"n":"Shijiazhuang","r":4,"cap":0,"mega":1,"pop":2417000,"lon":114.478,"lat":38.052},{"n":"Handan","r":4,"cap":0,"mega":1,"pop":1631000,"lon":114.478,"lat":36.582},{"n":"Anshan","r":4,"cap":0,"mega":1,"pop":1639000,"lon":122.938,"lat":41.117},{"n":"Dalian","r":4,"cap":0,"mega":1,"pop":3167000,"lon":121.628,"lat":38.925},{"n":"Qingdao","r":4,"cap":0,"mega":1,"pop":2866000,"lon":120.328,"lat":36.092},{"n":"Linyi","r":4,"cap":0,"mega":1,"pop":2082000,"lon":118.328,"lat":35.082},{"n":"Huaiyin","r":4,"cap":0,"mega":1,"pop":1264000,"lon":119.066,"lat":33.585},{"n":"Wenzhou","r":4,"cap":0,"mega":1,"pop":2350000,"lon":120.648,"lat":28.022},{"n":"Ningbo","r":4,"cap":0,"mega":1,"pop":1923000,"lon":121.548,"lat":29.882},{"n":"Fukuoka","r":4,"cap":0,"mega":1,"pop":2792000,"lon":130.408,"lat":33.597},{"n":"Miyazaki","r":4,"cap":0,"mega":0,"pop":324384,"lon":131.418,"lat":31.918},{"n":"Naha","r":4,"cap":0,"mega":0,"pop":905238,"lon":127.673,"lat":26.207},{"n":"Kochi","r":4,"cap":0,"mega":0,"pop":335570,"lon":133.538,"lat":33.562},{"n":"Gorontalo","r":4,"cap":0,"mega":0,"pop":365497,"lon":123.07,"lat":0.55},{"n":"Tongliao","r":4,"cap":0,"mega":1,"pop":884000,"lon":122.268,"lat":43.622},{"n":"Hohhot","r":4,"cap":0,"mega":1,"pop":1726000,"lon":111.658,"lat":40.822},{"n":"Chifeng","r":4,"cap":0,"mega":1,"pop":1277000,"lon":118.948,"lat":42.272},{"n":"Ulanhot","r":4,"cap":0,"mega":0,"pop":241894,"lon":122.08,"lat":46.08},{"n":"Hailar","r":4,"cap":0,"mega":0,"pop":231171,"lon":119.7,"lat":49.2},{"n":"Jiamusi","r":4,"cap":0,"mega":1,"pop":1020000,"lon":130.348,"lat":46.832},{"n":"Beian","r":4,"cap":0,"mega":0,"pop":154936,"lon":126.529,"lat":48.246},{"n":"Daqing","r":4,"cap":0,"mega":1,"pop":1693000,"lon":124.998,"lat":46.582},{"n":"Jixi","r":4,"cap":0,"mega":1,"pop":965000,"lon":130.968,"lat":45.302},{"n":"Nagoya","r":4,"cap":0,"mega":1,"pop":3230000,"lon":136.913,"lat":35.157},{"n":"Nagano","r":4,"cap":0,"mega":0,"pop":594311,"lon":138.17,"lat":36.65},{"n":"Kushiro","r":4,"cap":0,"mega":0,"pop":198566,"lon":144.375,"lat":42.975},{"n":"Hakodate","r":4,"cap":0,"mega":0,"pop":302984,"lon":140.74,"lat":41.795},{"n":"Kyoto","r":4,"cap":0,"mega":1,"pop":1805000,"lon":135.748,"lat":35.032},{"n":"Sendai","r":4,"cap":0,"mega":1,"pop":2250000,"lon":140.87,"lat":38.268},{"n":"Sakata","r":4,"cap":0,"mega":0,"pop":100446,"lon":139.85,"lat":38.92},{"n":"Bandundu","r":4,"cap":0,"mega":0,"pop":118211,"lon":17.38,"lat":-3.31},{"n":"Kananga","r":4,"cap":0,"mega":1,"pop":765000,"lon":22.398,"lat":-5.888},{"n":"Kasongo","r":4,"cap":0,"mega":0,"pop":63000,"lon":26.66,"lat":-4.45},{"n":"Mbuji-Mayi","r":4,"cap":0,"mega":1,"pop":1295000,"lon":23.598,"lat":-6.148},{"n":"Kalemie","r":4,"cap":0,"mega":0,"pop":206257,"lon":29.2,"lat":-5.933},{"n":"Butembo","r":4,"cap":0,"mega":0,"pop":286403,"lon":29.28,"lat":0.13},{"n":"Goma","r":4,"cap":0,"mega":0,"pop":144124,"lon":29.222,"lat":-1.679},{"n":"Mzuzu","r":4,"cap":0,"mega":0,"pop":110201,"lon":34.02,"lat":-11.46},{"n":"Blantyre","r":4,"cap":0,"mega":0,"pop":584877,"lon":34.99,"lat":-15.79},{"n":"Quetzaltenango","r":4,"cap":0,"mega":0,"pop":575263,"lon":-91.52,"lat":14.83},{"n":"Banjul","r":4,"cap":1,"mega":0,"pop":43094,"lon":-16.592,"lat":13.454},{"n":"Faridabad","r":4,"cap":0,"mega":1,"pop":1394000,"lon":77.315,"lat":28.435},{"n":"Srinagar","r":4,"cap":0,"mega":1,"pop":1140000,"lon":74.828,"lat":34.069},{"n":"Vijayawada","r":4,"cap":0,"mega":1,"pop":1137000,"lon":80.628,"lat":16.522},{"n":"Thiruvananthapuram","r":4,"cap":0,"mega":1,"pop":954000,"lon":76.925,"lat":8.516},{"n":"Kochi","r":4,"cap":0,"mega":1,"pop":1519000,"lon":76.222,"lat":10.017},{"n":"Cuttack","r":4,"cap":0,"mega":0,"pop":580000,"lon":85.89,"lat":20.47},{"n":"Hubballi","r":4,"cap":0,"mega":1,"pop":890000,"lon":75.123,"lat":15.362},{"n":"Mangaluru","r":4,"cap":0,"mega":0,"pop":776632,"lon":74.85,"lat":12.9},{"n":"Mysuru","r":4,"cap":0,"mega":1,"pop":887000,"lon":76.658,"lat":12.312},{"n":"Kalaburagi","r":4,"cap":0,"mega":0,"pop":483615,"lon":76.82,"lat":17.35},{"n":"Kolhapur","r":4,"cap":0,"mega":0,"pop":750000,"lon":74.22,"lat":16.7},{"n":"Nanded","r":4,"cap":0,"mega":0,"pop":623708,"lon":77.308,"lat":19.197},{"n":"Akola","r":4,"cap":0,"mega":0,"pop":503502,"lon":77.01,"lat":20.71},{"n":"Guwahati","r":4,"cap":0,"mega":1,"pop":983000,"lon":91.768,"lat":26.162},{"n":"Kayes","r":4,"cap":0,"mega":0,"pop":62521,"lon":13.28,"lat":-4.18},{"n":"Franceville","r":4,"cap":0,"mega":0,"pop":42967,"lon":13.583,"lat":-1.633},{"n":"Bordeaux","r":4,"cap":0,"mega":1,"pop":803000,"lon":-0.597,"lat":44.852},{"n":"Marseille","r":4,"cap":0,"mega":1,"pop":1400000,"lon":5.373,"lat":43.292},{"n":"Le Havre","r":4,"cap":0,"mega":0,"pop":242124,"lon":0.105,"lat":49.505},{"n":"Gao","r":4,"cap":0,"mega":0,"pop":116967,"lon":-0.05,"lat":16.267},{"n":"Coihaique","r":4,"cap":0,"mega":0,"pop":45787,"lon":-72.07,"lat":-45.57},{"n":"Arica","r":4,"cap":0,"mega":0,"pop":185999,"lon":-70.29,"lat":-18.5},{"n":"Copiapo","r":4,"cap":0,"mega":0,"pop":129280,"lon":-70.33,"lat":-27.372},{"n":"La Serena","r":4,"cap":0,"mega":0,"pop":154521,"lon":-71.25,"lat":-29.9},{"n":"Los Angeles","r":4,"cap":0,"mega":0,"pop":145239,"lon":-72.347,"lat":-37.48},{"n":"Narsarsuaq","r":4,"cap":0,"mega":0,"pop":145,"lon":-45.417,"lat":61.167},{"n":"Sisimiut","r":4,"cap":0,"mega":0,"pop":5227,"lon":-53.667,"lat":66.95},{"n":"Upernavik","r":4,"cap":0,"mega":0,"pop":1129,"lon":-56.142,"lat":72.709},{"n":"Qaanaaq","r":4,"cap":0,"mega":0,"pop":616,"lon":-69.23,"lat":77.467},{"n":"Nouadhibou","r":4,"cap":0,"mega":0,"pop":86738,"lon":-17.056,"lat":20.9},{"n":"Kayes","r":4,"cap":0,"mega":0,"pop":77207,"lon":-11.44,"lat":14.45},{"n":"Ayoun el Atrous","r":4,"cap":0,"mega":0,"pop":1423,"lon":-9.617,"lat":16.667},{"n":"Segou","r":4,"cap":0,"mega":0,"pop":122952,"lon":-6.26,"lat":13.44},{"n":"Skopje","r":4,"cap":1,"mega":0,"pop":494087,"lon":21.433,"lat":42.0},{"n":"Al Jawf","r":4,"cap":0,"mega":0,"pop":24132,"lon":23.29,"lat":24.2},{"n":"Tmassah","r":4,"cap":0,"mega":0,"pop":500,"lon":15.8,"lat":26.367},{"n":"Misratah","r":4,"cap":0,"mega":0,"pop":386120,"lon":15.1,"lat":32.38},{"n":"Zuwarah","r":4,"cap":0,"mega":0,"pop":180310,"lon":12.079,"lat":32.934},{"n":"Kirkuk","r":4,"cap":0,"mega":0,"pop":601433,"lon":44.392,"lat":35.472},{"n":"Mosul","r":4,"cap":0,"mega":1,"pop":1316000,"lon":43.143,"lat":36.347},{"n":"An Najaf","r":4,"cap":0,"mega":0,"pop":665552,"lon":44.335,"lat":32.0},{"n":"Bahir Dar","r":4,"cap":0,"mega":0,"pop":206748,"lon":37.383,"lat":11.6},{"n":"Mekele","r":4,"cap":0,"mega":0,"pop":95856,"lon":39.47,"lat":13.5},{"n":"Dire Dawa","r":4,"cap":0,"mega":0,"pop":252279,"lon":41.86,"lat":9.59},{"n":"Rovaniemi","r":4,"cap":0,"mega":0,"pop":34781,"lon":25.716,"lat":66.5},{"n":"Vaasa","r":4,"cap":0,"mega":0,"pop":57014,"lon":21.6,"lat":63.1},{"n":"Tampere","r":4,"cap":0,"mega":0,"pop":259279,"lon":23.75,"lat":61.5},{"n":"Aqtobe","r":4,"cap":0,"mega":0,"pop":262457,"lon":57.17,"lat":50.28},{"n":"Rudny","r":4,"cap":0,"mega":0,"pop":124000,"lon":63.13,"lat":52.953},{"n":"Qyzylorda","r":4,"cap":0,"mega":0,"pop":300000,"lon":65.465,"lat":44.8},{"n":"Atyrau","r":4,"cap":0,"mega":0,"pop":182753,"lon":51.92,"lat":47.113},{"n":"Ekibastuz","r":4,"cap":0,"mega":0,"pop":127868,"lon":75.32,"lat":51.73},{"n":"Pavlodar","r":4,"cap":0,"mega":0,"pop":329002,"lon":76.95,"lat":52.3},{"n":"Semey","r":4,"cap":0,"mega":0,"pop":311353,"lon":80.275,"lat":50.435},{"n":"Oskemen","r":4,"cap":0,"mega":0,"pop":319067,"lon":82.615,"lat":49.99},{"n":"Yazd","r":4,"cap":0,"mega":0,"pop":477905,"lon":54.37,"lat":31.92},{"n":"Ahvaz","r":4,"cap":0,"mega":1,"pop":996000,"lon":48.718,"lat":31.282},{"n":"Basra","r":4,"cap":0,"mega":1,"pop":870000,"lon":47.812,"lat":30.515},{"n":"Bandar-e-Abbas","r":4,"cap":0,"mega":0,"pop":461499,"lon":56.272,"lat":27.204},{"n":"Hamadan","r":4,"cap":0,"mega":0,"pop":528256,"lon":48.515,"lat":34.796},{"n":"Tabriz","r":4,"cap":0,"mega":1,"pop":1413000,"lon":46.299,"lat":38.088},{"n":"Ludhiana","r":4,"cap":0,"mega":1,"pop":1649000,"lon":75.87,"lat":30.93},{"n":"Kota","r":4,"cap":0,"mega":1,"pop":827000,"lon":75.833,"lat":25.182},{"n":"Jodhpur","r":4,"cap":0,"mega":1,"pop":995000,"lon":73.015,"lat":26.294},{"n":"Shymkent","r":4,"cap":0,"mega":0,"pop":465392,"lon":69.595,"lat":42.32},{"n":"Taraz","r":4,"cap":0,"mega":0,"pop":358153,"lon":71.365,"lat":42.9},{"n":"Lucknow","r":4,"cap":0,"mega":1,"pop":2695000,"lon":80.913,"lat":26.857},{"n":"Saharanpur","r":4,"cap":0,"mega":0,"pop":484873,"lon":77.55,"lat":29.97},{"n":"Ranchi","r":4,"cap":0,"mega":1,"pop":1044000,"lon":85.328,"lat":23.372},{"n":"Bhagalpur","r":4,"cap":0,"mega":0,"pop":361548,"lon":86.98,"lat":25.23},{"n":"Raipur","r":4,"cap":0,"mega":1,"pop":875000,"lon":81.633,"lat":21.237},{"n":"Jabalpur","r":4,"cap":0,"mega":1,"pop":1285000,"lon":79.953,"lat":23.177},{"n":"Indore","r":4,"cap":0,"mega":1,"pop":2026000,"lon":75.863,"lat":22.717},{"n":"Puducherry","r":4,"cap":0,"mega":0,"pop":227411,"lon":79.83,"lat":11.935},{"n":"Salem","r":4,"cap":0,"mega":1,"pop":873000,"lon":78.178,"lat":11.672},{"n":"Tiruchirappalli","r":4,"cap":0,"mega":1,"pop":951000,"lon":78.688,"lat":10.801},{"n":"Pointe-Noire","r":4,"cap":0,"mega":0,"pop":659084,"lon":11.88,"lat":-4.77},{"n":"Kankan","r":4,"cap":0,"mega":0,"pop":114009,"lon":-9.31,"lat":10.39},{"n":"Nzerekore","r":4,"cap":0,"mega":0,"pop":168121,"lon":-8.83,"lat":7.76},{"n":"Bouake","r":4,"cap":0,"mega":0,"pop":567481,"lon":-5.03,"lat":7.69},{"n":"St.-Denis","r":4,"cap":0,"mega":0,"pop":190047,"lon":55.448,"lat":-20.879},{"n":"Rio Branco","r":4,"cap":0,"mega":0,"pop":257642,"lon":-67.8,"lat":-9.967},{"n":"Sao Luis","r":4,"cap":0,"mega":1,"pop":1038000,"lon":-44.268,"lat":-2.514},{"n":"Porto Velho","r":4,"cap":0,"mega":0,"pop":306180,"lon":-63.9,"lat":-8.75},{"n":"Alvorada","r":4,"cap":0,"mega":0,"pop":10232,"lon":-49.082,"lat":-12.47},{"n":"Corumba","r":4,"cap":0,"mega":0,"pop":96520,"lon":-57.65,"lat":-19.016},{"n":"Belo Horizonte","r":4,"cap":0,"mega":1,"pop":5575000,"lon":-43.917,"lat":-19.913},{"n":"Montes Claros","r":4,"cap":0,"mega":0,"pop":332379,"lon":-43.86,"lat":-16.72},{"n":"Uberlandia","r":4,"cap":0,"mega":0,"pop":563536,"lon":-48.28,"lat":-18.9},{"n":"Colider","r":4,"cap":0,"mega":0,"pop":27139,"lon":-55.451,"lat":-10.817},{"n":"Alta Floresta","r":4,"cap":0,"mega":0,"pop":40466,"lon":-55.91,"lat":-9.9},{"n":"Cuiaba","r":4,"cap":0,"mega":1,"pop":806000,"lon":-56.087,"lat":-15.568},{"n":"Pelotas","r":4,"cap":0,"mega":0,"pop":320674,"lon":-52.33,"lat":-31.75},{"n":"Caxias do Sul","r":4,"cap":0,"mega":0,"pop":381270,"lon":-51.17,"lat":-29.18},{"n":"Ponta Grossa","r":4,"cap":0,"mega":0,"pop":292177,"lon":-50.16,"lat":-25.09},{"n":"Teresina","r":4,"cap":0,"mega":1,"pop":907000,"lon":-42.782,"lat":-5.093},{"n":"Maceio","r":4,"cap":0,"mega":1,"pop":1186000,"lon":-35.732,"lat":-9.618},{"n":"Vitoria da Conquista","r":4,"cap":0,"mega":0,"pop":308204,"lon":-40.84,"lat":-14.85},{"n":"Barreiras","r":4,"cap":0,"mega":0,"pop":158292,"lon":-45.0,"lat":-12.14},{"n":"Vila Velha","r":4,"cap":0,"mega":0,"pop":1209091,"lon":-40.29,"lat":-20.338},{"n":"Natal","r":4,"cap":0,"mega":1,"pop":1088000,"lon":-35.242,"lat":-5.778},{"n":"Thompson","r":4,"cap":0,"mega":0,"pop":13727,"lon":-97.867,"lat":55.75},{"n":"Brandon","r":4,"cap":0,"mega":0,"pop":28418,"lon":-99.95,"lat":49.833},{"n":"Fort Smith","r":4,"cap":0,"mega":0,"pop":518,"lon":-111.883,"lat":60.0},{"n":"Fort McMurray","r":4,"cap":0,"mega":0,"pop":21863,"lon":-111.383,"lat":56.733},{"n":"Peace River","r":4,"cap":0,"mega":0,"pop":5340,"lon":-117.283,"lat":56.233},{"n":"Fort St. John","r":4,"cap":0,"mega":0,"pop":18776,"lon":-120.833,"lat":56.25},{"n":"Iqaluit","r":4,"cap":0,"mega":0,"pop":6124,"lon":-68.5,"lat":63.75},{"n":"Cambridge Bay","r":4,"cap":0,"mega":0,"pop":1477,"lon":-105.033,"lat":69.117},{"n":"Kugluktuk","r":4,"cap":0,"mega":0,"pop":1302,"lon":-115.125,"lat":67.799},{"n":"Chesterfield Inlet","r":4,"cap":0,"mega":0,"pop":374,"lon":-90.7,"lat":63.338},{"n":"Arviat","r":4,"cap":0,"mega":0,"pop":1868,"lon":-94.059,"lat":61.109},{"n":"Taloyoak","r":4,"cap":0,"mega":0,"pop":774,"lon":-93.533,"lat":69.533},{"n":"Igloolik","r":4,"cap":0,"mega":0,"pop":1612,"lon":-81.794,"lat":69.257},{"n":"Dawson City","r":4,"cap":0,"mega":0,"pop":1319,"lon":-139.417,"lat":64.067},{"n":"Timmins","r":4,"cap":0,"mega":0,"pop":34974,"lon":-81.333,"lat":48.467},{"n":"North Bay","r":4,"cap":0,"mega":0,"pop":50170,"lon":-79.45,"lat":46.3},{"n":"Kuujjuarapik","r":4,"cap":0,"mega":0,"pop":1243,"lon":-77.766,"lat":55.281},{"n":"Kuujjuaq","r":4,"cap":0,"mega":0,"pop":1273,"lon":-68.4,"lat":58.1},{"n":"Sydney","r":4,"cap":0,"mega":0,"pop":37538,"lon":-60.18,"lat":46.066},{"n":"Labrador City","r":4,"cap":0,"mega":0,"pop":10313,"lon":-66.916,"lat":52.941},{"n":"Ebolowa","r":4,"cap":0,"mega":0,"pop":87875,"lon":11.15,"lat":2.9},{"n":"Bambari","r":4,"cap":0,"mega":0,"pop":62098,"lon":20.667,"lat":5.762},{"n":"Venice","r":4,"cap":0,"mega":0,"pop":270816,"lon":12.335,"lat":45.439},{"n":"El Calafate","r":4,"cap":0,"mega":0,"pop":8000,"lon":-72.3,"lat":-50.333},{"n":"San Juan","r":4,"cap":0,"mega":0,"pop":447048,"lon":-68.52,"lat":-31.55},{"n":"Rawson","r":4,"cap":0,"mega":0,"pop":26335,"lon":-65.1,"lat":-43.3},{"n":"Neuquen","r":4,"cap":0,"mega":0,"pop":242092,"lon":-68.06,"lat":-38.95},{"n":"Trinidad","r":4,"cap":0,"mega":0,"pop":84259,"lon":-64.9,"lat":-14.833},{"n":"Santa Rosa","r":4,"cap":0,"mega":0,"pop":111424,"lon":-64.3,"lat":-36.62},{"n":"San Carlos de Bariloche","r":4,"cap":0,"mega":0,"pop":95394,"lon":-71.3,"lat":-41.15},{"n":"Salta","r":4,"cap":0,"mega":0,"pop":512686,"lon":-65.417,"lat":-24.783},{"n":"Tucuman","r":4,"cap":0,"mega":1,"pop":830000,"lon":-65.219,"lat":-26.814},{"n":"Formosa","r":4,"cap":0,"mega":0,"pop":221383,"lon":-58.183,"lat":-26.173},{"n":"Santa Fe","r":4,"cap":0,"mega":0,"pop":489505,"lon":-60.69,"lat":-31.624},{"n":"Rosario","r":4,"cap":0,"mega":1,"pop":1203000,"lon":-60.668,"lat":-32.949},{"n":"Campinas","r":4,"cap":0,"mega":1,"pop":2791000,"lon":-47.056,"lat":-22.908},{"n":"Sorocaba","r":4,"cap":0,"mega":0,"pop":563281,"lon":-47.47,"lat":-23.49},{"n":"Ribeirao Preto","r":4,"cap":0,"mega":0,"pop":551267,"lon":-47.83,"lat":-21.17},{"n":"Petrolina","r":4,"cap":0,"mega":0,"pop":260985,"lon":-40.51,"lat":-9.38},{"n":"Bamenda","r":4,"cap":0,"mega":0,"pop":445299,"lon":10.15,"lat":5.96},{"n":"Garoua","r":4,"cap":0,"mega":0,"pop":436899,"lon":13.39,"lat":9.3},{"n":"Herat","r":4,"cap":0,"mega":0,"pop":481009,"lon":62.17,"lat":34.33},{"n":"Mazar-e Sharif","r":4,"cap":0,"mega":0,"pop":458151,"lon":67.1,"lat":36.7},{"n":"Battambang","r":4,"cap":0,"mega":0,"pop":154773,"lon":103.2,"lat":13.1},{"n":"Siem Reap","r":4,"cap":0,"mega":0,"pop":109398,"lon":103.85,"lat":13.367},{"n":"Malanje","r":4,"cap":0,"mega":0,"pop":125856,"lon":16.34,"lat":-9.54},{"n":"Benguela","r":4,"cap":0,"mega":0,"pop":151226,"lon":13.407,"lat":-12.578},{"n":"Lubango","r":4,"cap":0,"mega":0,"pop":125632,"lon":13.49,"lat":-14.91},{"n":"Namibe","r":4,"cap":0,"mega":0,"pop":132900,"lon":12.16,"lat":-15.19},{"n":"Tarija","r":4,"cap":0,"mega":0,"pop":159269,"lon":-64.75,"lat":-21.517},{"n":"Bridgetown","r":4,"cap":1,"mega":0,"pop":191152,"lon":-59.617,"lat":13.102},{"n":"Annaba","r":4,"cap":0,"mega":0,"pop":503524,"lon":7.76,"lat":36.92},{"n":"Parakou","r":4,"cap":0,"mega":0,"pop":188853,"lon":2.62,"lat":9.34},{"n":"Porto-Novo","r":4,"cap":0,"mega":0,"pop":300000,"lon":2.617,"lat":6.483},{"n":"Constantine","r":4,"cap":0,"mega":0,"pop":605179,"lon":6.6,"lat":36.36},{"n":"Brest","r":4,"cap":0,"mega":0,"pop":300715,"lon":23.7,"lat":52.1},{"n":"Khulna","r":4,"cap":0,"mega":1,"pop":1553000,"lon":89.558,"lat":22.842},{"n":"Francistown","r":4,"cap":0,"mega":0,"pop":89979,"lon":27.5,"lat":-21.17},{"n":"Mahalapye","r":4,"cap":0,"mega":0,"pop":50744,"lon":26.82,"lat":-23.1},{"n":"Serowe","r":4,"cap":0,"mega":0,"pop":48573,"lon":26.71,"lat":-22.39},{"n":"Katherine","r":4,"cap":0,"mega":0,"pop":10141,"lon":132.267,"lat":-14.467},{"n":"Busselton","r":4,"cap":0,"mega":0,"pop":10214,"lon":115.349,"lat":-33.656},{"n":"Mandurah","r":4,"cap":0,"mega":0,"pop":73356,"lon":115.747,"lat":-32.523},{"n":"Broome","r":4,"cap":0,"mega":0,"pop":13218,"lon":122.231,"lat":-17.962},{"n":"Kalgoorlie","r":4,"cap":0,"mega":0,"pop":36852,"lon":121.46,"lat":-30.735},{"n":"Albany","r":4,"cap":0,"mega":0,"pop":26445,"lon":117.892,"lat":-35.017},{"n":"Port Hedland","r":4,"cap":0,"mega":0,"pop":14288,"lon":118.606,"lat":-20.31},{"n":"Karratha","r":4,"cap":0,"mega":0,"pop":16796,"lon":116.87,"lat":-20.73},{"n":"Geraldton","r":4,"cap":0,"mega":0,"pop":27258,"lon":114.6,"lat":-28.767},{"n":"Griffith","r":4,"cap":0,"mega":0,"pop":15455,"lon":146.04,"lat":-34.29},{"n":"Orange","r":4,"cap":0,"mega":0,"pop":39329,"lon":149.1,"lat":-33.28},{"n":"Dubbo","r":4,"cap":0,"mega":0,"pop":31574,"lon":148.597,"lat":-32.26},{"n":"Armidale","r":4,"cap":0,"mega":0,"pop":22673,"lon":151.667,"lat":-30.512},{"n":"Broken Hill","r":4,"cap":0,"mega":0,"pop":17395,"lon":141.433,"lat":-31.95},{"n":"Port Lincoln","r":4,"cap":0,"mega":0,"pop":13630,"lon":135.867,"lat":-34.733},{"n":"Whyalla","r":4,"cap":0,"mega":0,"pop":22559,"lon":137.561,"lat":-33.025},{"n":"Portland","r":4,"cap":0,"mega":0,"pop":11808,"lon":141.59,"lat":-38.34},{"n":"Bendigo","r":4,"cap":0,"mega":0,"pop":81657,"lon":144.28,"lat":-36.76},{"n":"Wangaratta","r":4,"cap":0,"mega":0,"pop":14022,"lon":146.3,"lat":-36.36},{"n":"Windorah","r":4,"cap":0,"mega":0,"pop":158,"lon":142.65,"lat":-25.433},{"n":"Mount Isa","r":4,"cap":0,"mega":0,"pop":33200,"lon":139.49,"lat":-20.724},{"n":"Rockhampton","r":4,"cap":0,"mega":0,"pop":65850,"lon":150.52,"lat":-23.364},{"n":"Cairns","r":4,"cap":0,"mega":0,"pop":154225,"lon":145.763,"lat":-16.888},{"n":"Gold Coast","r":4,"cap":0,"mega":0,"pop":527660,"lon":153.448,"lat":-28.082},{"n":"Devonport","r":4,"cap":0,"mega":0,"pop":19317,"lon":146.331,"lat":-41.193},{"n":"Bobo Dioulasso","r":4,"cap":0,"mega":0,"pop":360106,"lon":-4.29,"lat":11.18},{"n":"Rajshahi","r":4,"cap":0,"mega":1,"pop":810000,"lon":88.603,"lat":24.377},{"n":"Mandalay","r":4,"cap":0,"mega":1,"pop":1300000,"lon":96.083,"lat":21.972},{"n":"Sittwe","r":4,"cap":0,"mega":0,"pop":179032,"lon":92.88,"lat":20.14},{"n":"Bujumbura","r":4,"cap":1,"mega":0,"pop":331700,"lon":29.36,"lat":-3.376},{"n":"Pago Pago","r":4,"cap":0,"mega":0,"pop":12576,"lon":-170.707,"lat":-14.277},{"n":"Kingstown","r":4,"cap":1,"mega":0,"pop":49485,"lon":-61.22,"lat":13.156},{"n":"Castries","r":4,"cap":1,"mega":0,"pop":37963,"lon":-60.993,"lat":14.008},{"n":"Basseterre","r":4,"cap":1,"mega":0,"pop":21887,"lon":-62.717,"lat":17.302},{"n":"Las Palmas","r":4,"cap":0,"mega":0,"pop":378495,"lon":-15.43,"lat":28.1},{"n":"Berbera","r":4,"cap":0,"mega":0,"pop":242344,"lon":45.016,"lat":10.436},{"n":"Port Louis","r":4,"cap":1,"mega":0,"pop":595491,"lon":57.5,"lat":-20.167},{"n":"Gaza","r":4,"cap":0,"mega":0,"pop":505700,"lon":34.445,"lat":31.53},{"n":"Saint George's","r":4,"cap":1,"mega":0,"pop":33734,"lon":-61.742,"lat":12.053},{"n":"Papeete","r":4,"cap":0,"mega":0,"pop":131695,"lon":-149.567,"lat":-17.533},{"n":"Manama","r":4,"cap":1,"mega":0,"pop":563920,"lon":50.583,"lat":26.236},{"n":"Freeport","r":4,"cap":0,"mega":0,"pop":25383,"lon":-78.7,"lat":26.533},{"n":"Saint John's","r":4,"cap":1,"mega":0,"pop":35499,"lon":-61.85,"lat":17.118},{"n":"Taichung","r":4,"cap":0,"mega":1,"pop":2629323,"lon":120.682,"lat":24.152},{"n":"Napier","r":4,"cap":0,"mega":0,"pop":57800,"lon":176.926,"lat":-39.49},{"n":"Manukau","r":4,"cap":0,"mega":0,"pop":375600,"lon":174.885,"lat":-37.0},{"n":"Hamilton","r":4,"cap":0,"mega":0,"pop":148200,"lon":175.29,"lat":-37.778},{"n":"Blenheim","r":4,"cap":0,"mega":0,"pop":30300,"lon":173.955,"lat":-41.514},{"n":"Dunedin","r":4,"cap":0,"mega":0,"pop":117700,"lon":170.491,"lat":-45.885},{"n":"Leh","r":4,"cap":0,"mega":0,"pop":30870,"lon":77.572,"lat":34.162},{"n":"Amaravati","r":4,"cap":0,"mega":0,"pop":5800000,"lon":80.524,"lat":16.534},{"n":"Kozhikode","r":3,"cap":0,"mega":1,"pop":953000,"lon":75.768,"lat":11.252},{"n":"Bhubaneshwar","r":3,"cap":0,"mega":1,"pop":844000,"lon":85.825,"lat":20.272},{"n":"Jamshedpur","r":3,"cap":0,"mega":1,"pop":1300000,"lon":86.196,"lat":22.789},{"n":"Montevideo","r":3,"cap":1,"mega":1,"pop":1513000,"lon":-56.187,"lat":-34.905},{"n":"Helena","r":3,"cap":0,"mega":0,"pop":38725,"lon":-112.035,"lat":46.593},{"n":"Bismarck","r":3,"cap":0,"mega":0,"pop":63871,"lon":-100.783,"lat":46.808},{"n":"Boise","r":3,"cap":0,"mega":0,"pop":338071,"lon":-116.227,"lat":43.609},{"n":"San Jose","r":3,"cap":0,"mega":1,"pop":1668000,"lon":-121.888,"lat":37.331},{"n":"Sacramento","r":3,"cap":0,"mega":1,"pop":1604000,"lon":-121.472,"lat":38.577},{"n":"Las Vegas","r":3,"cap":0,"mega":1,"pop":1823000,"lon":-115.151,"lat":36.165},{"n":"Santa Fe","r":3,"cap":0,"mega":0,"pop":92681,"lon":-105.937,"lat":35.687},{"n":"Portland","r":3,"cap":0,"mega":1,"pop":1875000,"lon":-122.682,"lat":45.522},{"n":"Salt Lake City","r":3,"cap":0,"mega":1,"pop":966000,"lon":-111.932,"lat":40.777},{"n":"Cheyenne","r":3,"cap":0,"mega":0,"pop":72927,"lon":-104.82,"lat":41.14},{"n":"Des Moines","r":3,"cap":0,"mega":0,"pop":380655,"lon":-93.62,"lat":41.58},{"n":"Omaha","r":3,"cap":0,"mega":0,"pop":877110,"lon":-96.01,"lat":41.24},{"n":"Oklahoma City","r":3,"cap":0,"mega":1,"pop":788000,"lon":-97.521,"lat":35.472},{"n":"Pierre","r":3,"cap":0,"mega":0,"pop":13879,"lon":-100.351,"lat":44.368},{"n":"San Antonio","r":3,"cap":0,"mega":1,"pop":1473000,"lon":-98.493,"lat":29.42},{"n":"San Cristobal","r":3,"cap":0,"mega":0,"pop":438798,"lon":-72.25,"lat":7.77},{"n":"Valencia","r":3,"cap":0,"mega":1,"pop":1770000,"lon":-67.982,"lat":10.232},{"n":"Jackson","r":3,"cap":0,"mega":0,"pop":250902,"lon":-90.185,"lat":32.299},{"n":"Raleigh","r":3,"cap":0,"mega":0,"pop":1163515,"lon":-78.647,"lat":35.773},{"n":"Cleveland","r":3,"cap":0,"mega":1,"pop":1890000,"lon":-81.697,"lat":41.472},{"n":"Cincinnati","r":3,"cap":0,"mega":1,"pop":1636000,"lon":-84.459,"lat":39.164},{"n":"Nashville","r":3,"cap":0,"mega":1,"pop":877000,"lon":-86.782,"lat":36.172},{"n":"Memphis","r":3,"cap":0,"mega":1,"pop":1081000,"lon":-90.029,"lat":35.145},{"n":"Norfolk","r":3,"cap":0,"mega":0,"pop":1047869,"lon":-76.28,"lat":36.85},{"n":"Milwaukee","r":3,"cap":0,"mega":1,"pop":1388000,"lon":-87.917,"lat":43.03},{"n":"Buffalo","r":3,"cap":0,"mega":1,"pop":1016000,"lon":-78.882,"lat":42.882},{"n":"Pittsburgh","r":3,"cap":0,"mega":1,"pop":1838000,"lon":-80.002,"lat":40.432},{"n":"Ciudad Guayana","r":3,"cap":0,"mega":0,"pop":746535,"lon":-62.62,"lat":8.37},{"n":"Lome","r":3,"cap":1,"mega":1,"pop":1452000,"lon":1.221,"lat":6.134},{"n":"Tunis","r":3,"cap":1,"mega":0,"pop":2412500,"lon":10.18,"lat":36.803},{"n":"Kodiak","r":3,"cap":0,"mega":0,"pop":9461,"lon":-152.407,"lat":57.79},{"n":"Cold Bay","r":3,"cap":0,"mega":0,"pop":200,"lon":-162.715,"lat":55.2},{"n":"Bethel","r":3,"cap":0,"mega":0,"pop":6228,"lon":-161.756,"lat":60.793},{"n":"Point Hope","r":3,"cap":0,"mega":0,"pop":461,"lon":-166.808,"lat":68.348},{"n":"Utqia\u0121vik","r":3,"cap":0,"mega":0,"pop":4336,"lon":-156.789,"lat":71.291},{"n":"Nome","r":3,"cap":0,"mega":0,"pop":3485,"lon":-165.406,"lat":64.506},{"n":"Valdez","r":3,"cap":0,"mega":0,"pop":4036,"lon":-146.348,"lat":61.136},{"n":"Juneau","r":3,"cap":0,"mega":0,"pop":30711,"lon":-134.401,"lat":58.303},{"n":"Fairbanks","r":3,"cap":0,"mega":0,"pop":56993,"lon":-147.711,"lat":64.837},{"n":"Prudhoe Bay","r":3,"cap":0,"mega":0,"pop":2500,"lon":-148.669,"lat":70.292},{"n":"Sevastapol","r":3,"cap":0,"mega":0,"pop":379200,"lon":33.524,"lat":44.604},{"n":"Abu Dhabi","r":3,"cap":1,"mega":0,"pop":603492,"lon":54.367,"lat":24.467},{"n":"Ashgabat","r":3,"cap":1,"mega":0,"pop":727700,"lon":58.383,"lat":37.95},{"n":"Samarqand","r":3,"cap":0,"mega":0,"pop":708000,"lon":66.945,"lat":39.67},{"n":"Lusaka","r":3,"cap":1,"mega":1,"pop":1328000,"lon":28.281,"lat":-15.415},{"n":"Harare","r":3,"cap":1,"mega":1,"pop":1572000,"lon":31.043,"lat":-17.816},{"n":"Bulawayo","r":3,"cap":0,"mega":0,"pop":699385,"lon":28.58,"lat":-20.17},{"n":"Dili","r":3,"cap":1,"mega":0,"pop":234331,"lon":125.579,"lat":-8.559},{"n":"Port Vila","r":3,"cap":1,"mega":0,"pop":44040,"lon":168.317,"lat":-17.733},{"n":"Tegucigalpa","r":3,"cap":1,"mega":1,"pop":946000,"lon":-87.219,"lat":14.104},{"n":"Georgetown","r":3,"cap":1,"mega":0,"pop":264350,"lon":-58.167,"lat":6.802},{"n":"Reykjavik","r":3,"cap":1,"mega":0,"pop":166212,"lon":-21.937,"lat":64.143},{"n":"Port-au-Prince","r":3,"cap":1,"mega":1,"pop":1998000,"lon":-72.338,"lat":18.543},{"n":"Glasgow","r":3,"cap":0,"mega":1,"pop":1160000,"lon":-4.239,"lat":55.862},{"n":"Kampala","r":3,"cap":1,"mega":1,"pop":1420000,"lon":32.581,"lat":0.319},{"n":"Aden","r":3,"cap":0,"mega":0,"pop":1000000,"lon":45.009,"lat":12.78},{"n":"Paramaribo","r":3,"cap":1,"mega":0,"pop":254169,"lon":-55.167,"lat":5.835},{"n":"Seville","r":3,"cap":0,"mega":0,"pop":1212045,"lon":-5.98,"lat":37.405},{"n":"Zinder","r":3,"cap":0,"mega":0,"pop":230358,"lon":8.983,"lat":13.8},{"n":"Niamey","r":3,"cap":1,"mega":1,"pop":915000,"lon":2.115,"lat":13.519},{"n":"Port Sudan","r":3,"cap":0,"mega":0,"pop":489725,"lon":37.216,"lat":19.616},{"n":"Dushanbe","r":3,"cap":1,"mega":0,"pop":1086244,"lon":68.774,"lat":38.56},{"n":"Cusco","r":3,"cap":0,"mega":0,"pop":361182,"lon":-71.972,"lat":-13.525},{"n":"Tacna","r":3,"cap":0,"mega":0,"pop":280098,"lon":-70.25,"lat":-18.0},{"n":"Trujillo","r":3,"cap":0,"mega":0,"pop":765171,"lon":-79.02,"lat":-8.12},{"n":"Ica","r":3,"cap":0,"mega":0,"pop":279420,"lon":-75.725,"lat":-14.068},{"n":"Asuncion","r":3,"cap":1,"mega":1,"pop":1870000,"lon":-57.626,"lat":-25.291},{"n":"Managua","r":3,"cap":1,"mega":1,"pop":920000,"lon":-86.27,"lat":12.155},{"n":"Freetown","r":3,"cap":1,"mega":1,"pop":827000,"lon":-13.236,"lat":8.472},{"n":"Agadez","r":3,"cap":0,"mega":0,"pop":117770,"lon":7.983,"lat":16.996},{"n":"Niyala","r":3,"cap":0,"mega":0,"pop":392373,"lon":24.89,"lat":12.06},{"n":"Wau","r":3,"cap":0,"mega":0,"pop":127384,"lon":27.99,"lat":7.7},{"n":"Dongola","r":3,"cap":0,"mega":0,"pop":26404,"lon":30.483,"lat":19.167},{"n":"Kassala","r":3,"cap":0,"mega":0,"pop":401477,"lon":36.39,"lat":15.46},{"n":"Tromso","r":3,"cap":0,"mega":0,"pop":52436,"lon":18.992,"lat":69.635},{"n":"Trondheim","r":3,"cap":0,"mega":0,"pop":147139,"lon":10.417,"lat":63.417},{"n":"Bergen","r":3,"cap":0,"mega":0,"pop":213585,"lon":5.325,"lat":60.391},{"n":"Islamabad","r":3,"cap":1,"mega":1,"pop":780000,"lon":73.081,"lat":33.689},{"n":"Multan","r":3,"cap":0,"mega":1,"pop":1522000,"lon":71.453,"lat":30.202},{"n":"Hyderabad","r":3,"cap":0,"mega":1,"pop":1459000,"lon":68.373,"lat":25.382},{"n":"Peshawar","r":3,"cap":0,"mega":1,"pop":1303000,"lon":71.533,"lat":34.007},{"n":"Kathmandu","r":3,"cap":1,"mega":1,"pop":895000,"lon":85.315,"lat":27.719},{"n":"Nacala","r":3,"cap":0,"mega":0,"pop":224795,"lon":40.715,"lat":-14.519},{"n":"Bloemfontein","r":3,"cap":1,"mega":0,"pop":463064,"lon":26.23,"lat":-29.12},{"n":"Pretoria","r":3,"cap":1,"mega":1,"pop":1338000,"lon":28.227,"lat":-25.705},{"n":"Port Moresby","r":3,"cap":1,"mega":0,"pop":283733,"lon":147.193,"lat":-9.465},{"n":"Honiara","r":3,"cap":1,"mega":0,"pop":76328,"lon":159.95,"lat":-9.438},{"n":"Panama City","r":3,"cap":1,"mega":1,"pop":1281000,"lon":-79.535,"lat":8.97},{"n":"Fez","r":3,"cap":0,"mega":1,"pop":1002000,"lon":-5.002,"lat":34.057},{"n":"Rabat","r":3,"cap":1,"mega":1,"pop":1705000,"lon":-6.836,"lat":34.025},{"n":"Marrakesh","r":3,"cap":0,"mega":1,"pop":872000,"lon":-8.002,"lat":31.632},{"n":"Chisinau","r":3,"cap":1,"mega":0,"pop":688134,"lon":28.858,"lat":47.005},{"n":"Beira","r":3,"cap":0,"mega":0,"pop":530604,"lon":34.87,"lat":-19.82},{"n":"Port Elizabeth","r":3,"cap":0,"mega":1,"pop":1021000,"lon":25.598,"lat":-33.968},{"n":"Maputo","r":3,"cap":1,"mega":1,"pop":1446000,"lon":32.587,"lat":-25.953},{"n":"Tomsk","r":3,"cap":0,"mega":0,"pop":535479,"lon":84.975,"lat":56.495},{"n":"Anadyr","r":3,"cap":0,"mega":0,"pop":10332,"lon":177.475,"lat":64.737},{"n":"Murmansk","r":3,"cap":0,"mega":0,"pop":319263,"lon":33.1,"lat":68.97},{"n":"Archangel","r":3,"cap":0,"mega":0,"pop":356051,"lon":40.545,"lat":64.575},{"n":"Nizhny Novgorod","r":3,"cap":0,"mega":1,"pop":1278000,"lon":43.998,"lat":56.335},{"n":"Volgograd","r":3,"cap":0,"mega":1,"pop":984000,"lon":44.498,"lat":48.712},{"n":"Ufa","r":3,"cap":0,"mega":1,"pop":1018000,"lon":56.038,"lat":54.792},{"n":"Yekaterinburg","r":3,"cap":0,"mega":1,"pop":1313000,"lon":60.598,"lat":56.852},{"n":"Samara","r":3,"cap":0,"mega":1,"pop":1137000,"lon":50.149,"lat":53.197},{"n":"Kazan","r":3,"cap":0,"mega":1,"pop":1115000,"lon":49.124,"lat":55.752},{"n":"Surgut","r":3,"cap":0,"mega":0,"pop":400000,"lon":73.425,"lat":61.26},{"n":"Barnaul","r":3,"cap":0,"mega":0,"pop":599579,"lon":83.745,"lat":53.355},{"n":"Novosibirsk","r":3,"cap":0,"mega":1,"pop":1389000,"lon":82.958,"lat":55.032},{"n":"Mogadishu","r":3,"cap":1,"mega":1,"pop":1100000,"lon":45.365,"lat":2.069},{"n":"Muscat","r":3,"cap":1,"mega":0,"pop":734697,"lon":58.378,"lat":23.585},{"n":"Colombo","r":3,"cap":1,"mega":0,"pop":217000,"lon":79.858,"lat":6.932},{"n":"Cebu","r":3,"cap":0,"mega":1,"pop":815000,"lon":123.898,"lat":10.322},{"n":"Iloilo","r":3,"cap":0,"mega":0,"pop":387681,"lon":122.545,"lat":10.705},{"n":"Davao","r":3,"cap":0,"mega":1,"pop":1402000,"lon":125.628,"lat":7.112},{"n":"Bratsk","r":3,"cap":0,"mega":0,"pop":246348,"lon":101.615,"lat":56.157},{"n":"Irkutsk","r":3,"cap":0,"mega":0,"pop":586695,"lon":104.284,"lat":52.285},{"n":"Krasnoyarsk","r":3,"cap":0,"mega":1,"pop":925000,"lon":92.864,"lat":56.016},{"n":"Dikson","r":3,"cap":0,"mega":0,"pop":1113,"lon":80.545,"lat":73.507},{"n":"Chita","r":3,"cap":0,"mega":0,"pop":308500,"lon":113.465,"lat":52.055},{"n":"Vladivostok","r":3,"cap":0,"mega":0,"pop":587022,"lon":131.91,"lat":43.13},{"n":"Nizhneyansk","r":3,"cap":0,"mega":0,"pop":400,"lon":136.067,"lat":71.433},{"n":"Yakutsk","r":3,"cap":0,"mega":0,"pop":235600,"lon":129.735,"lat":62.035},{"n":"Tiksi","r":3,"cap":0,"mega":0,"pop":5700,"lon":128.835,"lat":71.627},{"n":"Magadan","r":3,"cap":0,"mega":0,"pop":95282,"lon":150.81,"lat":59.575},{"n":"Tijuana","r":3,"cap":0,"mega":1,"pop":1553000,"lon":-117.082,"lat":32.502},{"n":"Chihuahua","r":3,"cap":0,"mega":1,"pop":793000,"lon":-106.087,"lat":28.647},{"n":"Mazatlan","r":3,"cap":0,"mega":0,"pop":368204,"lon":-106.42,"lat":23.221},{"n":"Tampico","r":3,"cap":0,"mega":0,"pop":859419,"lon":-97.87,"lat":22.3},{"n":"Acapulco","r":3,"cap":0,"mega":0,"pop":715584,"lon":-99.916,"lat":16.85},{"n":"Veracruz","r":3,"cap":0,"mega":0,"pop":578963,"lon":-96.16,"lat":19.177},{"n":"Tuxtla Gutierrez","r":3,"cap":0,"mega":0,"pop":481128,"lon":-93.15,"lat":16.75},{"n":"Cancun","r":3,"cap":0,"mega":0,"pop":542043,"lon":-86.83,"lat":21.17},{"n":"Merida","r":3,"cap":0,"mega":1,"pop":965000,"lon":-89.619,"lat":20.969},{"n":"Enugu","r":3,"cap":0,"mega":0,"pop":688862,"lon":7.5,"lat":6.45},{"n":"Sokoto","r":3,"cap":0,"mega":0,"pop":732178,"lon":5.24,"lat":13.06},{"n":"Perm","r":3,"cap":0,"mega":1,"pop":997000,"lon":56.248,"lat":58.002},{"n":"Erdenet","r":3,"cap":0,"mega":0,"pop":79647,"lon":104.118,"lat":49.053},{"n":"Ulaanbaatar","r":3,"cap":1,"mega":1,"pop":885000,"lon":106.915,"lat":47.919},{"n":"Mbeya","r":3,"cap":0,"mega":0,"pop":291649,"lon":33.43,"lat":-8.89},{"n":"Windhoek","r":3,"cap":1,"mega":0,"pop":268132,"lon":17.084,"lat":-22.57},{"n":"Grootfontein","r":3,"cap":0,"mega":0,"pop":24099,"lon":18.117,"lat":-19.567},{"n":"Zanzibar","r":3,"cap":0,"mega":0,"pop":403658,"lon":39.2,"lat":-6.16},{"n":"Valencia","r":3,"cap":0,"mega":1,"pop":808000,"lon":-0.402,"lat":39.487},{"n":"Palana","r":3,"cap":0,"mega":0,"pop":3671,"lon":159.95,"lat":59.084},{"n":"Petropavlovsk Kamchatskiy","r":3,"cap":0,"mega":0,"pop":187282,"lon":158.623,"lat":53.062},{"n":"Abuja","r":3,"cap":1,"mega":1,"pop":1576000,"lon":7.49,"lat":9.055},{"n":"Padang","r":3,"cap":0,"mega":1,"pop":855000,"lon":100.358,"lat":-0.958},{"n":"Bissau","r":3,"cap":1,"mega":0,"pop":403339,"lon":-15.598,"lat":11.865},{"n":"Palermo","r":3,"cap":0,"mega":1,"pop":863000,"lon":13.348,"lat":38.127},{"n":"Amman","r":3,"cap":1,"mega":1,"pop":1060000,"lon":35.931,"lat":31.952},{"n":"Vilnius","r":3,"cap":1,"mega":0,"pop":542366,"lon":25.317,"lat":54.683},{"n":"Riga","r":3,"cap":1,"mega":0,"pop":742572,"lon":24.1,"lat":56.95},{"n":"Bishkek","r":3,"cap":1,"mega":1,"pop":837000,"lon":74.583,"lat":42.875},{"n":"Jiayuguan","r":3,"cap":0,"mega":0,"pop":148279,"lon":98.3,"lat":39.82},{"n":"Xining","r":3,"cap":0,"mega":1,"pop":1048000,"lon":101.768,"lat":36.622},{"n":"Guilin","r":3,"cap":0,"mega":1,"pop":987000,"lon":110.278,"lat":25.282},{"n":"Huainan","r":3,"cap":0,"mega":1,"pop":1451000,"lon":116.978,"lat":32.632},{"n":"Shantou","r":3,"cap":0,"mega":1,"pop":1601000,"lon":116.668,"lat":23.372},{"n":"Tarakan","r":3,"cap":0,"mega":0,"pop":193069,"lon":117.633,"lat":3.3},{"n":"Mombasa","r":3,"cap":0,"mega":1,"pop":882000,"lon":39.688,"lat":-4.038},{"n":"Maseru","r":3,"cap":1,"mega":0,"pop":361324,"lon":27.483,"lat":-29.317},{"n":"Antananarivo","r":3,"cap":1,"mega":1,"pop":1697000,"lon":47.515,"lat":-18.915},{"n":"Semarang","r":3,"cap":0,"mega":1,"pop":1396000,"lon":110.418,"lat":-6.965},{"n":"Palembang","r":3,"cap":0,"mega":1,"pop":1749000,"lon":104.748,"lat":-2.978},{"n":"Bandjarmasin","r":3,"cap":0,"mega":0,"pop":603576,"lon":114.58,"lat":-3.33},{"n":"Makassar","r":3,"cap":0,"mega":1,"pop":1262000,"lon":119.43,"lat":-5.138},{"n":"Lyon","r":3,"cap":0,"mega":1,"pop":1423000,"lon":4.828,"lat":45.772},{"n":"Quito","r":3,"cap":1,"mega":1,"pop":1701000,"lon":-78.502,"lat":-0.213},{"n":"San Jose","r":3,"cap":1,"mega":1,"pop":1284000,"lon":-84.079,"lat":9.93},{"n":"San Salvador","r":3,"cap":1,"mega":1,"pop":1433000,"lon":-89.216,"lat":13.703},{"n":"Kingston","r":3,"cap":1,"mega":0,"pop":937700,"lon":-76.767,"lat":17.977},{"n":"Cartagena","r":3,"cap":0,"mega":1,"pop":887000,"lon":-75.516,"lat":10.402},{"n":"Mitu","r":3,"cap":0,"mega":0,"pop":5917,"lon":-70.174,"lat":1.198},{"n":"Bumba","r":3,"cap":0,"mega":0,"pop":160539,"lon":22.46,"lat":2.19},{"n":"Ndjamena","r":3,"cap":1,"mega":1,"pop":989000,"lon":15.047,"lat":12.115},{"n":"Abeche","r":3,"cap":0,"mega":0,"pop":158317,"lon":20.83,"lat":13.84},{"n":"Malabo","r":3,"cap":1,"mega":0,"pop":155963,"lon":8.783,"lat":3.75},{"n":"Luxor","r":3,"cap":0,"mega":0,"pop":609248,"lon":32.65,"lat":25.7},{"n":"Asmara","r":3,"cap":1,"mega":0,"pop":620802,"lon":38.933,"lat":15.333},{"n":"Zagreb","r":3,"cap":1,"mega":0,"pop":722526,"lon":16.0,"lat":45.8},{"n":"Tallinn","r":3,"cap":1,"mega":0,"pop":394024,"lon":24.728,"lat":59.434},{"n":"Lhasa","r":3,"cap":0,"mega":0,"pop":219599,"lon":91.1,"lat":29.645},{"n":"Hami","r":3,"cap":0,"mega":0,"pop":580000,"lon":93.515,"lat":42.827},{"n":"Hotan","r":3,"cap":0,"mega":0,"pop":408894,"lon":79.927,"lat":37.1},{"n":"Kashgar","r":3,"cap":0,"mega":0,"pop":506640,"lon":75.97,"lat":39.476},{"n":"Yinchuan","r":3,"cap":0,"mega":1,"pop":991000,"lon":106.271,"lat":38.47},{"n":"Pingxiang","r":3,"cap":0,"mega":1,"pop":961000,"lon":113.848,"lat":27.622},{"n":"Nagasaki","r":3,"cap":0,"mega":0,"pop":435455,"lon":129.885,"lat":32.765},{"n":"Qiqihar","r":3,"cap":0,"mega":1,"pop":1641000,"lon":123.988,"lat":47.347},{"n":"Kikwit","r":3,"cap":0,"mega":0,"pop":637736,"lon":18.85,"lat":-5.03},{"n":"Matadi","r":3,"cap":0,"mega":0,"pop":245862,"lon":13.45,"lat":-5.817},{"n":"Kolwezi","r":3,"cap":0,"mega":0,"pop":418000,"lon":25.472,"lat":-10.717},{"n":"Lubumbashi","r":3,"cap":0,"mega":1,"pop":1352000,"lon":27.478,"lat":-11.678},{"n":"Lilongwe","r":3,"cap":1,"mega":0,"pop":646750,"lon":33.783,"lat":-13.983},{"n":"Guatemala","r":3,"cap":1,"mega":1,"pop":1024000,"lon":-90.529,"lat":14.623},{"n":"Cayenne","r":3,"cap":0,"mega":0,"pop":61550,"lon":-52.33,"lat":4.933},{"n":"Libreville","r":3,"cap":1,"mega":0,"pop":578156,"lon":9.458,"lat":0.385},{"n":"Vishakhapatnam","r":3,"cap":0,"mega":1,"pop":1529000,"lon":83.22,"lat":17.683},{"n":"Suva","r":3,"cap":1,"mega":0,"pop":175399,"lon":178.442,"lat":-18.133},{"n":"Port-Gentil","r":3,"cap":0,"mega":0,"pop":116836,"lon":8.78,"lat":-0.72},{"n":"Timbuktu","r":3,"cap":0,"mega":0,"pop":68872,"lon":-3.017,"lat":16.767},{"n":"Punta Arenas","r":3,"cap":0,"mega":0,"pop":117430,"lon":-70.94,"lat":-53.165},{"n":"Iquique","r":3,"cap":0,"mega":0,"pop":227499,"lon":-70.13,"lat":-20.25},{"n":"Antofagasta","r":3,"cap":0,"mega":0,"pop":309832,"lon":-70.4,"lat":-23.65},{"n":"Valparaiso","r":3,"cap":0,"mega":1,"pop":854000,"lon":-71.617,"lat":-33.048},{"n":"Valdivia","r":3,"cap":0,"mega":0,"pop":159599,"lon":-73.233,"lat":-39.822},{"n":"Concepcion","r":3,"cap":0,"mega":0,"pop":889725,"lon":-73.05,"lat":-36.83},{"n":"Puerto Montt","r":3,"cap":0,"mega":0,"pop":174629,"lon":-72.93,"lat":-41.47},{"n":"Nuuk","r":3,"cap":0,"mega":0,"pop":14798,"lon":-51.733,"lat":64.198},{"n":"Nouakchott","r":3,"cap":1,"mega":0,"pop":742144,"lon":-15.975,"lat":18.086},{"n":"Bamako","r":3,"cap":1,"mega":1,"pop":1494000,"lon":-8.002,"lat":12.652},{"n":"Atar","r":3,"cap":0,"mega":0,"pop":44265,"lon":-13.05,"lat":20.517},{"n":"Djenne","r":3,"cap":0,"mega":0,"pop":32944,"lon":-4.55,"lat":13.9},{"n":"Sabha","r":3,"cap":0,"mega":0,"pop":100249,"lon":14.433,"lat":27.033},{"n":"Banghazi","r":3,"cap":0,"mega":1,"pop":1180000,"lon":20.065,"lat":32.119},{"n":"Thessaloniki","r":3,"cap":0,"mega":1,"pop":828000,"lon":22.883,"lat":40.698},{"n":"Beirut","r":3,"cap":1,"mega":1,"pop":1846000,"lon":35.508,"lat":33.874},{"n":"Tbilisi","r":3,"cap":1,"mega":1,"pop":1100000,"lon":44.789,"lat":41.727},{"n":"Gonder","r":3,"cap":0,"mega":0,"pop":156230,"lon":37.46,"lat":12.61},{"n":"Astana","r":3,"cap":1,"mega":0,"pop":345604,"lon":71.428,"lat":51.181},{"n":"Qaraghandy","r":3,"cap":0,"mega":0,"pop":451800,"lon":73.115,"lat":49.885},{"n":"Almaty","r":3,"cap":0,"mega":1,"pop":1209000,"lon":76.913,"lat":43.327},{"n":"Isfahan","r":3,"cap":0,"mega":1,"pop":1628000,"lon":51.698,"lat":32.702},{"n":"Shiraz","r":3,"cap":0,"mega":1,"pop":1240000,"lon":52.568,"lat":29.632},{"n":"Amritsar","r":3,"cap":0,"mega":1,"pop":1212000,"lon":74.868,"lat":31.642},{"n":"Varanasi","r":3,"cap":0,"mega":1,"pop":1352000,"lon":82.998,"lat":25.332},{"n":"Asansol","r":3,"cap":0,"mega":1,"pop":1328000,"lon":86.981,"lat":23.685},{"n":"Bhilai","r":3,"cap":0,"mega":1,"pop":1097000,"lon":81.372,"lat":21.211},{"n":"Bhopal","r":3,"cap":0,"mega":1,"pop":1727000,"lon":77.408,"lat":23.252},{"n":"Madurai","r":3,"cap":0,"mega":1,"pop":1294000,"lon":78.118,"lat":9.922},{"n":"Coimbatore","r":3,"cap":0,"mega":1,"pop":1696000,"lon":76.948,"lat":11.002},{"n":"Vientiane","r":3,"cap":1,"mega":0,"pop":754000,"lon":102.6,"lat":17.967},{"n":"Brazzaville","r":3,"cap":1,"mega":1,"pop":1355000,"lon":15.283,"lat":-4.257},{"n":"Conakry","r":3,"cap":1,"mega":1,"pop":1494000,"lon":-13.682,"lat":9.533},{"n":"Yamoussoukro","r":3,"cap":1,"mega":0,"pop":206499,"lon":-5.276,"lat":6.818},{"n":"Cruzeiro do Sul","r":3,"cap":0,"mega":0,"pop":56862,"lon":-72.67,"lat":-7.63},{"n":"Leticia","r":3,"cap":0,"mega":0,"pop":59575,"lon":-69.955,"lat":-4.201},{"n":"Manaus","r":3,"cap":0,"mega":1,"pop":1753000,"lon":-60.002,"lat":-3.098},{"n":"Caxias","r":3,"cap":0,"mega":0,"pop":134640,"lon":-43.35,"lat":-4.833},{"n":"Santarem","r":3,"cap":0,"mega":0,"pop":230428,"lon":-54.7,"lat":-2.433},{"n":"Maraba","r":3,"cap":0,"mega":0,"pop":166182,"lon":-49.116,"lat":-5.35},{"n":"Vilhena","r":3,"cap":0,"mega":0,"pop":63231,"lon":-60.117,"lat":-12.717},{"n":"Ji-Parana","r":3,"cap":0,"mega":0,"pop":65016,"lon":-61.967,"lat":-10.833},{"n":"Campo Grande","r":3,"cap":0,"mega":1,"pop":778000,"lon":-54.619,"lat":-20.448},{"n":"Florianopolis","r":3,"cap":0,"mega":1,"pop":1023000,"lon":-48.522,"lat":-27.578},{"n":"Feira de Santana","r":3,"cap":0,"mega":0,"pop":481911,"lon":-38.97,"lat":-12.25},{"n":"Winnipeg","r":3,"cap":0,"mega":0,"pop":632063,"lon":-97.166,"lat":49.883},{"n":"Churchill","r":3,"cap":0,"mega":0,"pop":1000,"lon":-94.166,"lat":58.766},{"n":"Regina","r":3,"cap":0,"mega":0,"pop":176183,"lon":-104.617,"lat":50.45},{"n":"Saskatoon","r":3,"cap":0,"mega":0,"pop":198958,"lon":-106.67,"lat":52.17},{"n":"Calgary","r":3,"cap":0,"mega":1,"pop":1110000,"lon":-114.082,"lat":51.085},{"n":"Prince Rupert","r":3,"cap":0,"mega":0,"pop":14708,"lon":-130.33,"lat":54.317},{"n":"Victoria","r":3,"cap":0,"mega":0,"pop":289625,"lon":-123.35,"lat":48.433},{"n":"Arctic Bay","r":3,"cap":0,"mega":0,"pop":604,"lon":-85.167,"lat":73.033},{"n":"Resolute","r":3,"cap":0,"mega":0,"pop":250,"lon":-94.9,"lat":74.683},{"n":"Repulse Bay","r":3,"cap":0,"mega":0,"pop":1000,"lon":-86.283,"lat":66.529},{"n":"Yellowknife","r":3,"cap":0,"mega":0,"pop":19234,"lon":-114.397,"lat":62.442},{"n":"Fort Good Hope","r":3,"cap":0,"mega":0,"pop":597,"lon":-128.633,"lat":66.267},{"n":"Whitehorse","r":3,"cap":0,"mega":0,"pop":23276,"lon":-135.05,"lat":60.717},{"n":"Boa Vista","r":3,"cap":0,"mega":0,"pop":235150,"lon":-60.666,"lat":2.816},{"n":"Macapa","r":3,"cap":0,"mega":0,"pop":499166,"lon":-51.05,"lat":0.033},{"n":"Ottawa","r":3,"cap":1,"mega":1,"pop":1145000,"lon":-75.702,"lat":45.419},{"n":"Fort Severn","r":3,"cap":0,"mega":0,"pop":125,"lon":-87.65,"lat":55.983},{"n":"Thunder Bay","r":3,"cap":0,"mega":0,"pop":99334,"lon":-89.275,"lat":48.446},{"n":"Quebec","r":3,"cap":0,"mega":0,"pop":624177,"lon":-71.246,"lat":46.84},{"n":"Halifax","r":3,"cap":0,"mega":0,"pop":359111,"lon":-63.6,"lat":44.65},{"n":"St. John's","r":3,"cap":0,"mega":0,"pop":131469,"lon":-52.681,"lat":47.585},{"n":"Nain","r":3,"cap":0,"mega":0,"pop":1151,"lon":-61.686,"lat":56.547},{"n":"Charlottetown","r":3,"cap":0,"mega":0,"pop":42402,"lon":-63.131,"lat":46.249},{"n":"Ndele","r":3,"cap":0,"mega":0,"pop":11764,"lon":20.653,"lat":8.409},{"n":"Belgrade","r":3,"cap":1,"mega":1,"pop":1099000,"lon":20.466,"lat":44.821},{"n":"Obo","r":3,"cap":0,"mega":0,"pop":12887,"lon":26.5,"lat":5.4},{"n":"Bandar Seri Begawan","r":3,"cap":1,"mega":0,"pop":296500,"lon":114.933,"lat":4.883},{"n":"Puerto Deseado","r":3,"cap":0,"mega":0,"pop":3305,"lon":-65.9,"lat":-47.75},{"n":"Rio Gallegos","r":3,"cap":0,"mega":0,"pop":85700,"lon":-69.217,"lat":-51.633},{"n":"Comodoro Rivadavia","r":3,"cap":0,"mega":0,"pop":140850,"lon":-67.5,"lat":-45.87},{"n":"Mendoza","r":3,"cap":0,"mega":1,"pop":893000,"lon":-68.819,"lat":-32.881},{"n":"Sucre","r":3,"cap":1,"mega":0,"pop":224838,"lon":-65.26,"lat":-19.041},{"n":"Riberalta","r":3,"cap":0,"mega":0,"pop":74014,"lon":-66.1,"lat":-10.983},{"n":"Bahia Blanca","r":3,"cap":0,"mega":0,"pop":281536,"lon":-62.265,"lat":-38.74},{"n":"Mar del Plata","r":3,"cap":0,"mega":0,"pop":555897,"lon":-57.58,"lat":-38.0},{"n":"Cordoba","r":3,"cap":0,"mega":1,"pop":1452000,"lon":-64.184,"lat":-31.398},{"n":"Posadas","r":3,"cap":0,"mega":0,"pop":357119,"lon":-55.885,"lat":-27.358},{"n":"Belmopan","r":3,"cap":1,"mega":0,"pop":15220,"lon":-88.767,"lat":17.252},{"n":"Bangui","r":3,"cap":1,"mega":0,"pop":831925,"lon":18.558,"lat":4.367},{"n":"Maroua","r":3,"cap":0,"mega":0,"pop":319941,"lon":14.325,"lat":10.596},{"n":"Yaounde","r":3,"cap":1,"mega":1,"pop":1611000,"lon":11.515,"lat":3.869},{"n":"Tirana","r":3,"cap":1,"mega":0,"pop":895350,"lon":19.819,"lat":41.328},{"n":"Yerevan","r":3,"cap":1,"mega":1,"pop":1102000,"lon":44.512,"lat":40.183},{"n":"Baku","r":3,"cap":1,"mega":1,"pop":2122300,"lon":49.86,"lat":40.397},{"n":"Kandahar","r":3,"cap":0,"mega":0,"pop":715542,"lon":65.695,"lat":31.61},{"n":"Phnom Penh","r":3,"cap":1,"mega":1,"pop":1466000,"lon":104.915,"lat":11.552},{"n":"Menongue","r":3,"cap":0,"mega":0,"pop":13030,"lon":17.7,"lat":-14.667},{"n":"Huambo","r":3,"cap":0,"mega":1,"pop":1100000,"lon":15.758,"lat":-12.748},{"n":"La Paz","r":3,"cap":1,"mega":1,"pop":1590000,"lon":-68.152,"lat":-16.496},{"n":"Santa Cruz","r":3,"cap":0,"mega":1,"pop":2102998,"lon":-63.228,"lat":-17.752},{"n":"Oran","r":3,"cap":0,"mega":1,"pop":798000,"lon":-0.622,"lat":35.712},{"n":"Cotonou","r":3,"cap":1,"mega":1,"pop":762000,"lon":2.404,"lat":6.363},{"n":"Tamanrasset","r":3,"cap":0,"mega":0,"pop":76000,"lon":5.523,"lat":22.785},{"n":"Ghardaia","r":3,"cap":0,"mega":0,"pop":125480,"lon":3.67,"lat":32.49},{"n":"Sofia","r":3,"cap":1,"mega":1,"pop":1185000,"lon":23.315,"lat":42.685},{"n":"Minsk","r":3,"cap":1,"mega":1,"pop":1805000,"lon":27.565,"lat":53.902},{"n":"Thimphu","r":3,"cap":1,"mega":0,"pop":98676,"lon":89.639,"lat":27.473},{"n":"Gaborone","r":3,"cap":1,"mega":0,"pop":208411,"lon":25.912,"lat":-24.646},{"n":"Darwin","r":3,"cap":0,"mega":0,"pop":93080,"lon":130.85,"lat":-12.425},{"n":"Alice Springs","r":3,"cap":0,"mega":0,"pop":27710,"lon":133.88,"lat":-23.701},{"n":"Canberra","r":3,"cap":1,"mega":0,"pop":327700,"lon":149.129,"lat":-35.283},{"n":"Newcastle","r":3,"cap":0,"mega":0,"pop":540796,"lon":151.77,"lat":-32.93},{"n":"Adelaide","r":3,"cap":0,"mega":1,"pop":1145000,"lon":138.598,"lat":-34.933},{"n":"Townsville","r":3,"cap":0,"mega":0,"pop":138954,"lon":146.77,"lat":-19.25},{"n":"Brisbane","r":3,"cap":0,"mega":1,"pop":1860000,"lon":153.033,"lat":-27.453},{"n":"Hobart","r":3,"cap":0,"mega":0,"pop":80870,"lon":147.295,"lat":-42.85},{"n":"Ouagadougou","r":3,"cap":1,"mega":1,"pop":1149000,"lon":-1.527,"lat":12.372},{"n":"Sarajevo","r":3,"cap":1,"mega":0,"pop":696731,"lon":18.383,"lat":43.85},{"n":"Naypyidaw","r":3,"cap":1,"mega":1,"pop":930000,"lon":96.117,"lat":19.769},{"n":"San Juan","r":3,"cap":0,"mega":1,"pop":2478905,"lon":-66.13,"lat":18.44},{"n":"Stanley","r":3,"cap":0,"mega":0,"pop":2213,"lon":-57.85,"lat":-51.7},{"n":"Hamilton","r":3,"cap":1,"mega":0,"pop":52320,"lon":-64.784,"lat":32.294},{"n":"Nukualofa","r":3,"cap":1,"mega":0,"pop":42620,"lon":-175.221,"lat":-21.139},{"n":"Hargeysa","r":3,"cap":1,"mega":0,"pop":477876,"lon":44.065,"lat":9.56},{"n":"Victoria","r":3,"cap":1,"mega":0,"pop":33576,"lon":55.45,"lat":-4.617},{"n":"Sao Tome","r":3,"cap":1,"mega":0,"pop":88219,"lon":6.73,"lat":0.337},{"n":"Apia","r":3,"cap":1,"mega":0,"pop":61916,"lon":-171.769,"lat":-13.836},{"n":"Valletta","r":3,"cap":1,"mega":0,"pop":368250,"lon":14.515,"lat":35.9},{"n":"Male","r":3,"cap":1,"mega":0,"pop":112927,"lon":73.509,"lat":4.172},{"n":"Jerusalem","r":3,"cap":1,"mega":0,"pop":1029300,"lon":35.207,"lat":31.778},{"n":"Praia","r":3,"cap":1,"mega":0,"pop":113364,"lon":-23.517,"lat":14.917},{"n":"Nassau","r":3,"cap":1,"mega":0,"pop":227940,"lon":-77.35,"lat":25.083},{"n":"Nicosia","r":3,"cap":1,"mega":0,"pop":224300,"lon":33.367,"lat":35.167},{"n":"Kaohsiung","r":3,"cap":0,"mega":1,"pop":2769072,"lon":120.267,"lat":22.633},{"n":"Wellington","r":3,"cap":1,"mega":0,"pop":393400,"lon":174.777,"lat":-41.292},{"n":"Christchurch","r":3,"cap":0,"mega":0,"pop":363200,"lon":172.63,"lat":-43.535},{"n":"Shenzhen","r":2,"cap":0,"mega":1,"pop":7581000,"lon":114.061,"lat":22.548},{"n":"Zibo","r":2,"cap":0,"mega":1,"pop":3061000,"lon":117.996,"lat":36.821},{"n":"Minneapolis","r":2,"cap":0,"mega":1,"pop":2616000,"lon":-93.254,"lat":44.982},{"n":"Honolulu","r":2,"cap":0,"mega":1,"pop":786000,"lon":-157.858,"lat":21.303},{"n":"Seattle","r":2,"cap":0,"mega":1,"pop":3074000,"lon":-122.315,"lat":47.6},{"n":"Phoenix","r":2,"cap":0,"mega":1,"pop":3551000,"lon":-112.067,"lat":33.448},{"n":"San Diego","r":2,"cap":0,"mega":1,"pop":2916000,"lon":-117.151,"lat":32.719},{"n":"St. Louis","r":2,"cap":0,"mega":1,"pop":2199000,"lon":-90.242,"lat":38.637},{"n":"New Orleans","r":2,"cap":0,"mega":1,"pop":785000,"lon":-90.088,"lat":29.969},{"n":"Dallas","r":2,"cap":0,"mega":1,"pop":4798000,"lon":-96.795,"lat":32.772},{"n":"Maracaibo","r":2,"cap":0,"mega":1,"pop":2072000,"lon":-71.662,"lat":10.732},{"n":"Boston","r":2,"cap":0,"mega":1,"pop":4467000,"lon":-71.072,"lat":42.332},{"n":"Tampa","r":2,"cap":0,"mega":1,"pop":2314000,"lon":-82.461,"lat":27.949},{"n":"Philadelphia","r":2,"cap":0,"mega":1,"pop":5492000,"lon":-75.18,"lat":39.946},{"n":"Detroit","r":2,"cap":0,"mega":1,"pop":4101000,"lon":-83.051,"lat":42.334},{"n":"Anchorage","r":2,"cap":0,"mega":0,"pop":260283,"lon":-149.887,"lat":61.214},{"n":"Hanoi","r":2,"cap":1,"mega":1,"pop":4378000,"lon":105.848,"lat":21.035},{"n":"Ho Chi Minh City","r":2,"cap":0,"mega":1,"pop":5314000,"lon":106.703,"lat":10.762},{"n":"Ankara","r":2,"cap":1,"mega":1,"pop":3716000,"lon":32.862,"lat":39.929},{"n":"Budapest","r":2,"cap":1,"mega":1,"pop":1679000,"lon":19.081,"lat":47.502},{"n":"Sanaa","r":2,"cap":1,"mega":1,"pop":2008000,"lon":44.205,"lat":15.357},{"n":"Barcelona","r":2,"cap":0,"mega":1,"pop":4920000,"lon":2.181,"lat":41.385},{"n":"Bucharest","r":2,"cap":1,"mega":1,"pop":1942000,"lon":26.098,"lat":44.435},{"n":"Aleppo","r":2,"cap":0,"mega":1,"pop":2738000,"lon":37.168,"lat":36.232},{"n":"Damascus","r":2,"cap":1,"mega":1,"pop":2466000,"lon":36.298,"lat":33.502},{"n":"Zurich","r":2,"cap":0,"mega":1,"pop":1108000,"lon":8.548,"lat":47.382},{"n":"Lisbon","r":2,"cap":1,"mega":1,"pop":2812000,"lon":-9.147,"lat":38.725},{"n":"Khartoum","r":2,"cap":1,"mega":1,"pop":4754000,"lon":32.532,"lat":15.59},{"n":"Jeddah","r":2,"cap":0,"mega":1,"pop":3012000,"lon":39.217,"lat":21.519},{"n":"Makkah","r":2,"cap":0,"mega":1,"pop":1385000,"lon":39.818,"lat":21.432},{"n":"Oslo","r":2,"cap":1,"mega":1,"pop":835000,"lon":10.748,"lat":59.919},{"n":"Lahore","r":2,"cap":0,"mega":1,"pop":6577000,"lon":74.348,"lat":31.562},{"n":"Karachi","r":2,"cap":0,"mega":1,"pop":12130000,"lon":66.988,"lat":24.872},{"n":"Durban","r":2,"cap":0,"mega":1,"pop":2729000,"lon":31.011,"lat":-29.861},{"n":"St. Petersburg","r":2,"cap":0,"mega":1,"pop":4553000,"lon":30.314,"lat":59.941},{"n":"Guadalajara","r":2,"cap":0,"mega":1,"pop":4198000,"lon":-103.332,"lat":20.672},{"n":"Puebla","r":2,"cap":0,"mega":1,"pop":2195000,"lon":-98.204,"lat":19.033},{"n":"Kano","r":2,"cap":0,"mega":1,"pop":3140000,"lon":8.518,"lat":12.002},{"n":"Warsaw","r":2,"cap":1,"mega":1,"pop":1707000,"lon":21.005,"lat":52.231},{"n":"Pyongyang","r":2,"cap":1,"mega":1,"pop":3300000,"lon":125.753,"lat":39.021},{"n":"Dar es Salaam","r":2,"cap":1,"mega":1,"pop":2930000,"lon":39.266,"lat":-6.798},{"n":"Medan","r":2,"cap":0,"mega":1,"pop":2115000,"lon":98.648,"lat":3.582},{"n":"Dublin","r":2,"cap":1,"mega":1,"pop":1059000,"lon":-6.257,"lat":53.347},{"n":"Monrovia","r":2,"cap":1,"mega":1,"pop":1041000,"lon":-10.8,"lat":6.315},{"n":"Naples","r":2,"cap":0,"mega":1,"pop":2250000,"lon":14.243,"lat":40.842},{"n":"Milan","r":2,"cap":0,"mega":1,"pop":2945000,"lon":9.203,"lat":45.472},{"n":"Kuala Lumpur","r":2,"cap":1,"mega":1,"pop":1448000,"lon":101.689,"lat":3.14},{"n":"Lanzhou","r":2,"cap":0,"mega":1,"pop":2561000,"lon":103.79,"lat":36.058},{"n":"Nanning","r":2,"cap":0,"mega":1,"pop":2167000,"lon":108.318,"lat":22.822},{"n":"Guiyang","r":2,"cap":0,"mega":1,"pop":3662000,"lon":106.718,"lat":26.582},{"n":"Chongqing","r":2,"cap":0,"mega":1,"pop":6461000,"lon":106.593,"lat":29.567},{"n":"Fuzhou","r":2,"cap":0,"mega":1,"pop":2606000,"lon":119.298,"lat":26.082},{"n":"Guangzhou","r":2,"cap":0,"mega":1,"pop":8829000,"lon":113.262,"lat":23.125},{"n":"Dongguan","r":2,"cap":0,"mega":1,"pop":4528000,"lon":113.75,"lat":23.022},{"n":"Bandung","r":2,"cap":0,"mega":1,"pop":2394000,"lon":107.568,"lat":-6.948},{"n":"Surabaya","r":2,"cap":0,"mega":1,"pop":2845000,"lon":112.749,"lat":-7.247},{"n":"Guayaquil","r":2,"cap":0,"mega":1,"pop":2514000,"lon":-79.922,"lat":-2.218},{"n":"Medellin","r":2,"cap":0,"mega":1,"pop":3297000,"lon":-75.577,"lat":6.277},{"n":"Cali","r":2,"cap":0,"mega":1,"pop":2254000,"lon":-76.502,"lat":3.402},{"n":"Havana","r":2,"cap":1,"mega":1,"pop":2174000,"lon":-82.366,"lat":23.134},{"n":"Alexandria","r":2,"cap":0,"mega":1,"pop":4165000,"lon":29.948,"lat":31.202},{"n":"Frankfurt","r":2,"cap":0,"mega":0,"pop":2895000,"lon":8.675,"lat":50.1},{"n":"Hamburg","r":2,"cap":0,"mega":1,"pop":1757000,"lon":9.998,"lat":53.552},{"n":"Munich","r":2,"cap":0,"mega":1,"pop":1275000,"lon":11.573,"lat":48.132},{"n":"Prague","r":2,"cap":1,"mega":1,"pop":1162000,"lon":14.423,"lat":50.087},{"n":"Kuwait City","r":2,"cap":1,"mega":1,"pop":2063000,"lon":47.976,"lat":29.372},{"n":"Xian","r":2,"cap":0,"mega":1,"pop":4009000,"lon":108.893,"lat":34.277},{"n":"Taiyuan","r":2,"cap":0,"mega":1,"pop":2913000,"lon":112.543,"lat":37.877},{"n":"Wuhan","r":2,"cap":0,"mega":1,"pop":7243000,"lon":114.268,"lat":30.582},{"n":"Changsha","r":2,"cap":0,"mega":1,"pop":2604000,"lon":112.968,"lat":28.202},{"n":"Kunming","r":2,"cap":0,"mega":1,"pop":2931000,"lon":102.697,"lat":25.043},{"n":"Zhengzhou","r":2,"cap":0,"mega":1,"pop":2636000,"lon":113.663,"lat":34.757},{"n":"Shenyeng","r":2,"cap":0,"mega":1,"pop":4787000,"lon":123.448,"lat":41.807},{"n":"Jinan","r":2,"cap":0,"mega":1,"pop":2798000,"lon":116.993,"lat":36.677},{"n":"Tianjin","r":2,"cap":0,"mega":1,"pop":7180000,"lon":117.197,"lat":39.083},{"n":"Nanchang","r":2,"cap":0,"mega":1,"pop":2350000,"lon":115.878,"lat":28.682},{"n":"Nanjing","r":2,"cap":0,"mega":1,"pop":3679000,"lon":118.778,"lat":32.052},{"n":"Hangzhou","r":2,"cap":0,"mega":1,"pop":3007000,"lon":120.168,"lat":30.252},{"n":"Hiroshima","r":2,"cap":0,"mega":1,"pop":2045000,"lon":132.441,"lat":34.39},{"n":"Changchun","r":2,"cap":0,"mega":1,"pop":3183000,"lon":125.338,"lat":43.867},{"n":"Baotou","r":2,"cap":0,"mega":1,"pop":2036000,"lon":109.82,"lat":40.654},{"n":"Harbin","r":2,"cap":0,"mega":1,"pop":3621000,"lon":126.648,"lat":45.752},{"n":"Sapporo","r":2,"cap":0,"mega":1,"pop":2544000,"lon":141.338,"lat":43.077},{"n":"Santo Domingo","r":2,"cap":1,"mega":1,"pop":2154000,"lon":-69.93,"lat":18.471},{"n":"Accra","r":2,"cap":1,"mega":1,"pop":2121000,"lon":-0.219,"lat":5.552},{"n":"Delhi","r":2,"cap":0,"mega":1,"pop":15926000,"lon":77.228,"lat":28.672},{"n":"Hyderabad","r":2,"cap":0,"mega":1,"pop":6376000,"lon":78.478,"lat":17.402},{"n":"Pune","r":2,"cap":0,"mega":1,"pop":4672000,"lon":73.852,"lat":18.516},{"n":"Nagpur","r":2,"cap":0,"mega":1,"pop":2454000,"lon":79.088,"lat":21.172},{"n":"Tripoli","r":2,"cap":1,"mega":1,"pop":2189000,"lon":13.18,"lat":32.892},{"n":"Tel Aviv-Yafo","r":2,"cap":0,"mega":1,"pop":3112000,"lon":34.768,"lat":32.082},{"n":"Helsinki","r":2,"cap":1,"mega":1,"pop":1115000,"lon":24.932,"lat":60.164},{"n":"Mashhad","r":2,"cap":0,"mega":1,"pop":2469000,"lon":59.568,"lat":36.272},{"n":"Jaipur","r":2,"cap":0,"mega":1,"pop":2917000,"lon":75.808,"lat":26.923},{"n":"Kanpur","r":2,"cap":0,"mega":1,"pop":3162000,"lon":80.318,"lat":26.462},{"n":"Patna","r":2,"cap":0,"mega":1,"pop":2158000,"lon":85.128,"lat":25.627},{"n":"Chennai","r":2,"cap":0,"mega":1,"pop":7163000,"lon":80.278,"lat":13.092},{"n":"Ahmedabad","r":2,"cap":0,"mega":1,"pop":5375000,"lon":72.578,"lat":23.032},{"n":"Surat","r":2,"cap":0,"mega":1,"pop":3842000,"lon":72.838,"lat":21.202},{"n":"Kobenhavn","r":2,"cap":1,"mega":1,"pop":1085000,"lon":12.562,"lat":55.681},{"n":"Abidjan","r":2,"cap":1,"mega":1,"pop":3802000,"lon":-4.02,"lat":5.323},{"n":"Belem","r":2,"cap":0,"mega":1,"pop":2167000,"lon":-48.482,"lat":-1.448},{"n":"Brasilia","r":2,"cap":1,"mega":1,"pop":3716996,"lon":-47.918,"lat":-15.781},{"n":"Porto Alegre","r":2,"cap":0,"mega":1,"pop":3917000,"lon":-51.202,"lat":-30.048},{"n":"Curitiba","r":2,"cap":0,"mega":1,"pop":3084000,"lon":-49.322,"lat":-25.418},{"n":"Fortaleza","r":2,"cap":0,"mega":1,"pop":3602319,"lon":-38.582,"lat":-3.748},{"n":"Salvador","r":2,"cap":0,"mega":1,"pop":3484000,"lon":-38.482,"lat":-12.968},{"n":"Edmonton","r":2,"cap":0,"mega":1,"pop":1058000,"lon":-113.502,"lat":53.552},{"n":"Montreal","r":2,"cap":0,"mega":1,"pop":3678000,"lon":-73.585,"lat":45.502},{"n":"Goiania","r":2,"cap":0,"mega":1,"pop":2022000,"lon":-49.302,"lat":-16.718},{"n":"Recife","r":2,"cap":0,"mega":1,"pop":3651000,"lon":-34.909,"lat":-8.061},{"n":"Brussels","r":2,"cap":1,"mega":1,"pop":1743000,"lon":4.331,"lat":50.835},{"n":"Dhaka","r":2,"cap":1,"mega":1,"pop":12797394,"lon":90.407,"lat":23.725},{"n":"Luanda","r":2,"cap":1,"mega":1,"pop":5172900,"lon":13.232,"lat":-8.836},{"n":"Algiers","r":2,"cap":1,"mega":1,"pop":3354000,"lon":3.049,"lat":36.765},{"n":"Chattogram","r":2,"cap":0,"mega":1,"pop":4529000,"lon":91.798,"lat":22.332},{"n":"Perth","r":2,"cap":0,"mega":1,"pop":1532000,"lon":115.838,"lat":-31.953},{"n":"Rangoon","r":2,"cap":0,"mega":1,"pop":4088000,"lon":96.165,"lat":16.785},{"n":"San Francisco","r":1,"cap":0,"mega":1,"pop":3450000,"lon":-122.4,"lat":37.784},{"n":"Denver","r":1,"cap":0,"mega":1,"pop":2313000,"lon":-104.986,"lat":39.741},{"n":"Houston","r":1,"cap":0,"mega":1,"pop":4459000,"lon":-95.348,"lat":29.741},{"n":"Miami","r":1,"cap":0,"mega":1,"pop":5585000,"lon":-80.226,"lat":25.79},{"n":"Atlanta","r":1,"cap":0,"mega":1,"pop":4506000,"lon":-84.368,"lat":33.739},{"n":"Chicago","r":1,"cap":0,"mega":1,"pop":8990000,"lon":-87.635,"lat":41.848},{"n":"Caracas","r":1,"cap":1,"mega":1,"pop":2985000,"lon":-66.919,"lat":10.503},{"n":"Kiev","r":1,"cap":1,"mega":1,"pop":2709000,"lon":30.515,"lat":50.435},{"n":"Dubai","r":1,"cap":0,"mega":1,"pop":1379000,"lon":55.287,"lat":25.215},{"n":"Tashkent","r":1,"cap":1,"mega":1,"pop":2184000,"lon":69.269,"lat":41.304},{"n":"Madrid","r":1,"cap":1,"mega":1,"pop":5567000,"lon":-3.685,"lat":40.402},{"n":"Geneva","r":1,"cap":0,"mega":0,"pop":1240000,"lon":6.14,"lat":46.21},{"n":"Stockholm","r":1,"cap":1,"mega":1,"pop":1264000,"lon":18.066,"lat":59.324},{"n":"Bangkok","r":1,"cap":1,"mega":1,"pop":6704000,"lon":100.515,"lat":13.752},{"n":"Lima","r":1,"cap":1,"mega":1,"pop":8012000,"lon":-77.052,"lat":-12.046},{"n":"Dakar","r":1,"cap":1,"mega":1,"pop":2604000,"lon":-17.475,"lat":14.718},{"n":"Johannesburg","r":1,"cap":0,"mega":1,"pop":3435000,"lon":28.028,"lat":-26.168},{"n":"Amsterdam","r":1,"cap":1,"mega":1,"pop":1031000,"lon":4.915,"lat":52.352},{"n":"Casablanca","r":1,"cap":0,"mega":1,"pop":3181000,"lon":-7.618,"lat":33.602},{"n":"Seoul","r":1,"cap":1,"mega":1,"pop":9796000,"lon":126.998,"lat":37.568},{"n":"Manila","r":1,"cap":1,"mega":1,"pop":11100000,"lon":120.98,"lat":14.606},{"n":"Monterrey","r":1,"cap":0,"mega":1,"pop":3712000,"lon":-100.332,"lat":25.672},{"n":"Berlin","r":1,"cap":1,"mega":1,"pop":3406000,"lon":13.4,"lat":52.524},{"n":"Urumqi","r":1,"cap":0,"mega":1,"pop":3575000,"lon":87.573,"lat":43.807},{"n":"Chengdu","r":1,"cap":0,"mega":1,"pop":4123000,"lon":104.068,"lat":30.672},{"n":"Osaka","r":1,"cap":0,"mega":1,"pop":11294000,"lon":135.504,"lat":34.691},{"n":"Kinshasa","r":1,"cap":1,"mega":1,"pop":7843000,"lon":15.313,"lat":-4.328},{"n":"New Delhi","r":1,"cap":1,"mega":0,"pop":317797,"lon":77.2,"lat":28.6},{"n":"Bengaluru","r":1,"cap":0,"mega":1,"pop":6787000,"lon":77.558,"lat":12.972},{"n":"Athens","r":1,"cap":1,"mega":1,"pop":3242000,"lon":23.731,"lat":37.985},{"n":"Baghdad","r":1,"cap":1,"mega":1,"pop":5054000,"lon":44.392,"lat":33.341},{"n":"Addis Ababa","r":1,"cap":1,"mega":1,"pop":3100000,"lon":38.698,"lat":9.035},{"n":"Tehran","r":1,"cap":1,"mega":1,"pop":7873000,"lon":51.422,"lat":35.674},{"n":"Vancouver","r":1,"cap":0,"mega":1,"pop":2313328,"lon":-123.124,"lat":49.275},{"n":"Toronto","r":1,"cap":0,"mega":1,"pop":5213000,"lon":-79.389,"lat":43.665},{"n":"Buenos Aires","r":1,"cap":1,"mega":1,"pop":12795000,"lon":-58.433,"lat":-34.611},{"n":"Kabul","r":1,"cap":1,"mega":1,"pop":3277000,"lon":69.181,"lat":34.519},{"n":"Vienna","r":1,"cap":1,"mega":1,"pop":2400000,"lon":16.365,"lat":48.202},{"n":"Melbourne","r":1,"cap":0,"mega":1,"pop":4170000,"lon":144.973,"lat":-37.818},{"n":"Taipei","r":1,"cap":1,"mega":1,"pop":6900273,"lon":121.568,"lat":25.036},{"n":"Auckland","r":1,"cap":0,"mega":1,"pop":1377200,"lon":174.763,"lat":-36.848},{"n":"Los Angeles","r":0,"cap":0,"mega":1,"pop":12500000,"lon":-118.232,"lat":34.049},{"n":"Washington, D.C.","r":0,"cap":1,"mega":1,"pop":4338000,"lon":-77.011,"lat":38.901},{"n":"New York","r":0,"cap":0,"mega":1,"pop":19040000,"lon":-73.996,"lat":40.722},{"n":"London","r":0,"cap":1,"mega":1,"pop":8567000,"lon":-0.119,"lat":51.502},{"n":"Istanbul","r":0,"cap":0,"mega":1,"pop":10061000,"lon":28.974,"lat":41.018},{"n":"Riyadh","r":0,"cap":1,"mega":1,"pop":4465000,"lon":46.72,"lat":24.634},{"n":"Cape Town","r":0,"cap":1,"mega":1,"pop":3215000,"lon":18.433,"lat":-33.918},{"n":"Moscow","r":0,"cap":1,"mega":1,"pop":10452000,"lon":37.614,"lat":55.754},{"n":"Mexico City","r":0,"cap":1,"mega":1,"pop":19028000,"lon":-99.133,"lat":19.444},{"n":"Lagos","r":0,"cap":0,"mega":1,"pop":9466000,"lon":3.39,"lat":6.445},{"n":"Rome","r":0,"cap":1,"mega":1,"pop":3339000,"lon":12.481,"lat":41.898},{"n":"Beijing","r":0,"cap":1,"mega":1,"pop":11106000,"lon":116.394,"lat":39.902},{"n":"Nairobi","r":0,"cap":1,"mega":1,"pop":3010000,"lon":36.815,"lat":-1.281},{"n":"Jakarta","r":0,"cap":1,"mega":1,"pop":9125000,"lon":106.827,"lat":-6.172},{"n":"Bogota","r":0,"cap":1,"mega":1,"pop":7772000,"lon":-74.085,"lat":4.598},{"n":"Cairo","r":0,"cap":1,"mega":1,"pop":11893000,"lon":31.248,"lat":30.052},{"n":"Shanghai","r":0,"cap":0,"mega":1,"pop":14987000,"lon":121.435,"lat":31.218},{"n":"Tokyo","r":0,"cap":1,"mega":1,"pop":35676000,"lon":139.749,"lat":35.687},{"n":"Mumbai","r":0,"cap":0,"mega":1,"pop":18978000,"lon":72.876,"lat":19.068},{"n":"Paris","r":0,"cap":1,"mega":1,"pop":9904000,"lon":2.353,"lat":48.858},{"n":"Santiago","r":0,"cap":1,"mega":1,"pop":5720000,"lon":-70.651,"lat":-33.44},{"n":"Kolkata","r":0,"cap":0,"mega":1,"pop":14787000,"lon":88.369,"lat":22.57},{"n":"Rio de Janeiro","r":0,"cap":0,"mega":1,"pop":11748000,"lon":-43.212,"lat":-22.907},{"n":"Sao Paulo","r":0,"cap":0,"mega":1,"pop":18845000,"lon":-46.627,"lat":-23.557},{"n":"Sydney","r":0,"cap":0,"mega":1,"pop":4630000,"lon":151.213,"lat":-33.871},{"n":"Singapore","r":0,"cap":1,"mega":1,"pop":5183700,"lon":103.854,"lat":1.295},{"n":"Hong Kong","r":0,"cap":0,"mega":1,"pop":7206000,"lon":114.183,"lat":22.307}]} \ No newline at end of file diff --git a/app/src/main/assets/world_land.geojson b/app/src/main/assets/world_land.geojson new file mode 100644 index 00000000..f842c94d --- /dev/null +++ b/app/src/main/assets/world_land.geojson @@ -0,0 +1 @@ +{"type":"GeometryCollection","geometries":[{"type":"Polygon","coordinates":[[[-59.57,-80.04],[-59.87,-80.55],[-60.16,-81.0],[-62.26,-80.86],[-64.49,-80.92],[-65.74,-80.59],[-65.74,-80.55],[-66.29,-80.26],[-64.04,-80.29],[-61.88,-80.39],[-61.14,-79.98],[-60.61,-79.63],[-59.57,-80.04]]]},{"type":"Polygon","coordinates":[[[-159.21,-79.5],[-161.13,-79.63],[-162.44,-79.28],[-163.03,-78.93],[-163.07,-78.87],[-163.71,-78.6],[-163.71,-78.6],[-163.11,-78.22],[-161.25,-78.38],[-160.25,-78.69],[-159.48,-79.05],[-159.21,-79.5]]]},{"type":"Polygon","coordinates":[[[-45.15,-78.05],[-43.92,-78.48],[-43.49,-79.09],[-43.37,-79.52],[-43.33,-80.03],[-44.88,-80.34],[-46.51,-80.59],[-48.39,-80.83],[-50.48,-81.03],[-52.85,-80.97],[-54.16,-80.63],[-53.99,-80.22],[-51.85,-79.95],[-50.99,-79.61],[-50.36,-79.18],[-49.91,-78.81],[-49.31,-78.46],[-48.66,-78.05],[-48.66,-78.05],[-48.15,-78.05],[-46.66,-77.83],[-45.15,-78.05]]]},{"type":"Polygon","coordinates":[[[-121.21,-73.5],[-119.92,-73.66],[-118.72,-73.48],[-119.29,-73.83],[-120.23,-74.09],[-121.62,-74.01],[-122.62,-73.66],[-122.62,-73.66],[-122.41,-73.32],[-121.21,-73.5]]]},{"type":"Polygon","coordinates":[[[-125.56,-73.48],[-124.03,-73.87],[-124.62,-73.83],[-125.91,-73.74],[-127.28,-73.46],[-127.28,-73.46],[-126.56,-73.25],[-125.56,-73.48]]]},{"type":"Polygon","coordinates":[[[-98.98,-71.93],[-97.88,-72.07],[-96.79,-71.95],[-96.2,-72.52],[-96.98,-72.44],[-98.2,-72.48],[-99.43,-72.44],[-100.78,-72.5],[-101.8,-72.31],[-102.33,-71.89],[-102.33,-71.89],[-101.7,-71.72],[-100.43,-71.85],[-98.98,-71.93]]]},{"type":"Polygon","coordinates":[[[-68.45,-70.96],[-68.33,-71.41],[-68.51,-71.8],[-68.78,-72.17],[-69.96,-72.31],[-71.08,-72.5],[-72.39,-72.48],[-71.9,-72.09],[-73.07,-72.23],[-74.19,-72.37],[-74.95,-72.07],[-75.01,-71.66],[-73.92,-71.27],[-73.92,-71.27],[-73.23,-71.15],[-72.07,-71.19],[-71.78,-70.68],[-71.72,-70.31],[-71.74,-69.51],[-71.17,-69.04],[-70.25,-68.88],[-69.72,-69.25],[-69.49,-69.62],[-69.06,-70.07],[-68.73,-70.51],[-68.45,-70.96]]]},{"type":"Polygon","coordinates":[[[-58.61,-64.15],[-59.05,-64.37],[-59.79,-64.21],[-60.61,-64.31],[-61.3,-64.54],[-62.02,-64.8],[-62.51,-65.09],[-62.65,-65.48],[-62.59,-65.86],[-62.12,-66.19],[-62.81,-66.43],[-63.75,-66.5],[-64.29,-66.84],[-64.88,-67.15],[-65.51,-67.58],[-65.67,-67.95],[-65.31,-68.37],[-64.78,-68.68],[-63.96,-68.91],[-63.2,-69.23],[-62.79,-69.62],[-62.57,-69.99],[-62.28,-70.38],[-61.81,-70.72],[-61.51,-71.09],[-61.38,-72.01],[-61.08,-72.38],[-61.0,-72.77],[-60.69,-73.17],[-60.83,-73.7],[-61.38,-74.11],[-61.96,-74.44],[-63.3,-74.58],[-63.75,-74.93],[-64.35,-75.26],[-65.86,-75.64],[-67.19,-75.79],[-68.45,-76.01],[-69.8,-76.22],[-70.6,-76.63],[-72.21,-76.67],[-73.97,-76.63],[-75.56,-76.71],[-77.24,-76.71],[-76.93,-77.1],[-75.4,-77.28],[-74.28,-77.56],[-73.66,-77.91],[-74.77,-78.22],[-76.5,-78.12],[-77.93,-78.38],[-77.98,-78.79],[-78.02,-79.18],[-76.85,-79.51],[-76.63,-79.89],[-75.36,-80.26],[-73.24,-80.42],[-71.44,-80.69],[-70.01,-81.0],[-68.19,-81.32],[-65.7,-81.47],[-63.26,-81.75],[-61.55,-82.04],[-59.69,-82.38],[-58.71,-82.85],[-58.22,-83.22],[-57.01,-82.87],[-55.36,-82.57],[-53.62,-82.26],[-51.54,-82.0],[-49.76,-81.73],[-47.27,-81.71],[-44.83,-81.85],[-42.81,-82.08],[-42.16,-81.65],[-40.77,-81.36],[-38.24,-81.34],[-36.27,-81.12],[-34.39,-80.91],[-32.31,-80.77],[-30.1,-80.59],[-28.55,-80.34],[-29.25,-79.99],[-29.69,-79.63],[-29.69,-79.26],[-31.62,-79.3],[-33.68,-79.46],[-35.64,-79.46],[-35.91,-79.08],[-35.78,-78.34],[-35.33,-78.12],[-33.9,-77.89],[-32.21,-77.65],[-31.0,-77.36],[-29.78,-77.07],[-28.88,-76.67],[-27.51,-76.5],[-26.16,-76.36],[-25.47,-76.28],[-23.93,-76.24],[-22.46,-76.11],[-21.22,-75.91],[-20.01,-75.67],[-18.91,-75.44],[-17.52,-75.13],[-16.64,-74.79],[-15.7,-74.5],[-15.41,-74.11],[-16.47,-73.87],[-16.11,-73.46],[-15.45,-73.15],[-14.41,-72.95],[-13.31,-72.72],[-12.29,-72.4],[-11.51,-72.01],[-11.02,-71.54],[-10.3,-71.27],[-9.1,-71.32],[-8.61,-71.66],[-7.42,-71.7],[-7.38,-71.32],[-6.87,-70.93],[-5.79,-71.03],[-5.54,-71.4],[-4.34,-71.46],[-3.05,-71.29],[-1.8,-71.17],[-0.66,-71.23],[-0.23,-71.64],[0.87,-71.3],[1.89,-71.13],[3.02,-70.99],[4.14,-70.85],[5.16,-70.62],[6.27,-70.46],[7.14,-70.25],[7.74,-69.89],[8.49,-70.15],[9.53,-70.01],[10.25,-70.48],[10.82,-70.83],[11.95,-70.64],[12.4,-70.25],[13.42,-69.97],[14.73,-70.03],[15.13,-70.4],[15.95,-70.03],[17.03,-69.91],[18.2,-69.87],[19.26,-69.89],[20.38,-70.01],[21.45,-70.07],[21.92,-70.4],[22.57,-70.7],[23.67,-70.52],[24.84,-70.48],[25.98,-70.48],[27.09,-70.46],[28.09,-70.32],[29.15,-70.21],[30.03,-69.93],[30.97,-69.76],[31.99,-69.66],[32.75,-69.38],[33.3,-68.84],[33.87,-68.5],[34.91,-68.66],[35.3,-69.01],[36.16,-69.25],[37.2,-69.17],[37.91,-69.52],[38.65,-69.78],[39.67,-69.54],[40.02,-69.11],[40.92,-68.93],[41.96,-68.6],[42.94,-68.46],[44.11,-68.27],[44.9,-68.05],[45.72,-67.82],[46.5,-67.6],[47.44,-67.72],[48.34,-67.37],[48.99,-67.09],[49.93,-67.11],[50.75,-66.88],[50.95,-66.52],[51.79,-66.25],[52.61,-66.05],[53.61,-65.9],[54.53,-65.82],[55.41,-65.88],[56.36,-65.97],[57.16,-66.25],[57.26,-66.68],[58.14,-67.01],[58.74,-67.29],[59.94,-67.41],[60.61,-67.68],[61.43,-67.95],[62.39,-68.01],[63.19,-67.82],[64.05,-67.41],[64.99,-67.62],[65.97,-67.74],[66.91,-67.86],[67.89,-67.93],[68.89,-67.93],[69.71,-68.97],[69.67,-69.23],[69.56,-69.68],[68.6,-69.93],[67.81,-70.31],[67.95,-70.7],[69.07,-70.68],[68.93,-71.07],[68.42,-71.44],[67.95,-71.85],[68.71,-72.17],[69.87,-72.26],[71.02,-72.09],[71.57,-71.7],[71.91,-71.32],[72.45,-71.01],[73.08,-70.72],[73.34,-70.36],[73.86,-69.87],[74.49,-69.78],[75.63,-69.74],[76.63,-69.62],[77.64,-69.46],[78.13,-69.07],[78.43,-68.7],[79.11,-68.33],[80.09,-68.07],[80.94,-67.88],[81.48,-67.54],[82.05,-67.37],[82.78,-67.21],[83.78,-67.31],[84.68,-67.21],[85.66,-67.09],[86.75,-67.15],[87.48,-66.88],[87.99,-66.21],[88.36,-66.48],[88.83,-66.95],[89.67,-67.15],[90.63,-67.23],[91.59,-67.11],[92.61,-67.19],[93.55,-67.21],[94.18,-67.11],[95.02,-67.17],[95.78,-67.39],[96.68,-67.25],[97.76,-67.25],[98.68,-67.11],[99.72,-67.25],[100.38,-66.92],[100.89,-66.58],[101.58,-66.31],[102.83,-65.56],[103.48,-65.7],[104.24,-65.97],[104.91,-66.33],[106.18,-66.93],[107.16,-66.95],[108.08,-66.95],[109.16,-66.84],[110.24,-66.7],[111.06,-66.43],[111.74,-66.13],[112.86,-66.09],[113.6,-65.88],[114.39,-66.07],[114.9,-66.39],[115.6,-66.7],[116.7,-66.66],[117.38,-66.92],[118.58,-67.17],[119.83,-67.27],[120.87,-67.19],[121.65,-66.88],[122.32,-66.56],[123.22,-66.48],[124.12,-66.62],[125.16,-66.72],[126.1,-66.56],[127.0,-66.56],[127.88,-66.66],[128.8,-66.76],[129.7,-66.58],[130.78,-66.43],[131.8,-66.39],[132.94,-66.39],[133.86,-66.29],[134.76,-66.21],[135.03,-65.72],[135.07,-65.31],[135.7,-65.58],[135.87,-66.03],[136.21,-66.45],[136.62,-66.78],[137.46,-66.95],[138.6,-66.9],[139.91,-66.88],[140.81,-66.82],[142.12,-66.82],[143.06,-66.8],[144.37,-66.84],[145.49,-66.92],[146.2,-67.23],[146.0,-67.6],[146.65,-67.9],[147.72,-68.13],[148.84,-68.39],[150.13,-68.56],[151.48,-68.72],[152.5,-68.87],[153.64,-68.89],[154.28,-68.56],[155.17,-68.84],[155.93,-69.15],[156.81,-69.38],[158.03,-69.48],[159.18,-69.6],[159.67,-69.99],[160.81,-70.23],[161.57,-70.58],[162.69,-70.74],[163.84,-70.72],[164.92,-70.78],[166.11,-70.76],[167.31,-70.83],[168.43,-70.97],[169.46,-71.21],[170.5,-71.4],[171.21,-71.7],[171.09,-72.09],[170.56,-72.44],[170.11,-72.89],[169.76,-73.24],[169.29,-73.66],[167.98,-73.81],[167.39,-74.17],[166.09,-74.38],[165.64,-74.77],[164.96,-75.15],[164.23,-75.46],[163.82,-75.87],[163.57,-76.24],[163.47,-76.69],[163.49,-77.07],[164.06,-77.46],[164.27,-77.83],[164.74,-78.18],[166.6,-78.32],[167.0,-78.75],[165.19,-78.91],[163.67,-79.12],[161.77,-79.16],[160.92,-79.73],[160.75,-80.2],[160.32,-80.57],[159.79,-80.95],[161.12,-81.28],[161.63,-81.69],[162.49,-82.06],[163.71,-82.4],[165.1,-82.71],[166.6,-83.02],[168.9,-83.34],[169.4,-83.83],[172.28,-84.04],[172.48,-84.12],[173.22,-84.41],[175.99,-84.16],[178.28,-84.47],[180,-84.71],[180,-90],[-180,-90],[-180,-84.71],[-179.94,-84.72],[-179.06,-84.14],[-177.26,-84.45],[-177.14,-84.42],[-176.86,-84.33],[-176.52,-84.23],[-176.23,-84.14],[-176.08,-84.1],[-175.93,-84.1],[-175.83,-84.12],[-174.38,-84.53],[-173.12,-84.12],[-172.89,-84.06],[-169.95,-83.88],[-169.0,-84.12],[-168.53,-84.24],[-167.02,-84.57],[-164.18,-84.83],[-161.93,-85.14],[-158.07,-85.37],[-155.19,-85.1],[-150.94,-85.3],[-148.53,-85.61],[-145.89,-85.32],[-143.11,-85.04],[-142.89,-84.57],[-146.83,-84.53],[-150.06,-84.3],[-150.9,-83.9],[-153.59,-83.69],[-153.41,-83.24],[-153.04,-82.83],[-152.67,-82.45],[-152.86,-82.04],[-154.53,-81.77],[-155.29,-81.42],[-156.84,-81.1],[-154.41,-81.16],[-152.1,-81.0],[-150.65,-81.34],[-148.87,-81.04],[-147.22,-80.67],[-146.42,-80.34],[-146.77,-79.93],[-148.06,-79.65],[-149.53,-79.36],[-151.59,-79.3],[-153.39,-79.16],[-155.33,-79.06],[-155.98,-78.69],[-157.27,-78.38],[-158.05,-78.03],[-158.37,-76.89],[-157.88,-76.99],[-156.97,-77.3],[-155.33,-77.2],[-153.74,-77.07],[-152.92,-77.5],[-151.33,-77.4],[-150.0,-77.18],[-148.75,-76.91],[-147.61,-76.58],[-146.1,-76.48],[-146.14,-76.11],[-146.5,-75.73],[-146.2,-75.38],[-144.91,-75.2],[-144.32,-75.54],[-142.79,-75.34],[-141.64,-75.09],[-140.21,-75.07],[-138.86,-74.97],[-137.51,-74.73],[-136.43,-74.52],[-135.21,-74.3],[-134.43,-74.36],[-133.75,-74.44],[-132.26,-74.3],[-130.93,-74.48],[-129.55,-74.46],[-128.24,-74.32],[-126.89,-74.42],[-125.4,-74.52],[-124.01,-74.48],[-122.56,-74.5],[-121.07,-74.52],[-119.7,-74.48],[-118.68,-74.19],[-117.47,-74.03],[-116.22,-74.24],[-115.02,-74.07],[-113.94,-73.71],[-113.3,-74.03],[-112.95,-74.38],[-112.3,-74.71],[-111.26,-74.42],[-110.07,-74.79],[-108.71,-74.91],[-107.56,-75.18],[-106.15,-75.13],[-104.88,-74.95],[-103.37,-74.99],[-102.02,-75.13],[-100.65,-75.3],[-100.12,-74.87],[-100.76,-74.54],[-101.25,-74.19],[-102.55,-74.11],[-103.11,-73.73],[-103.33,-73.36],[-103.68,-72.62],[-102.92,-72.75],[-101.61,-72.81],[-100.31,-72.75],[-99.14,-72.91],[-98.12,-73.21],[-97.69,-73.56],[-96.34,-73.62],[-95.04,-73.48],[-93.67,-73.28],[-92.44,-73.17],[-91.42,-73.4],[-90.09,-73.32],[-89.23,-72.56],[-88.42,-73.01],[-87.27,-73.19],[-86.01,-73.09],[-85.19,-73.48],[-83.88,-73.52],[-82.67,-73.64],[-81.47,-73.85],[-80.69,-73.48],[-80.3,-73.13],[-79.3,-73.52],[-77.93,-73.42],[-76.91,-73.64],[-76.22,-73.97],[-74.89,-73.87],[-73.85,-73.66],[-72.83,-73.4],[-71.62,-73.26],[-70.21,-73.15],[-68.94,-73.01],[-67.96,-72.79],[-67.37,-72.48],[-67.13,-72.05],[-67.25,-71.64],[-67.56,-71.25],[-67.92,-70.85],[-68.23,-70.46],[-68.49,-70.11],[-68.54,-69.72],[-68.45,-69.33],[-67.98,-68.95],[-67.58,-68.54],[-67.43,-68.15],[-67.62,-67.72],[-67.74,-67.33],[-67.25,-66.88],[-66.7,-66.58],[-66.06,-66.21],[-65.37,-65.9],[-64.57,-65.6],[-64.18,-65.17],[-63.63,-64.9],[-63.0,-64.64],[-62.04,-64.58],[-61.41,-64.27],[-60.71,-64.07],[-59.89,-63.96],[-59.16,-63.7],[-58.59,-63.39],[-57.81,-63.27],[-57.22,-63.53],[-57.6,-63.86],[-58.61,-64.15]]]},{"type":"Polygon","coordinates":[[[-67.75,-53.85],[-66.45,-54.45],[-65.05,-54.7],[-65.5,-55.2],[-66.45,-55.25],[-66.96,-54.9],[-67.29,-55.3],[-68.15,-55.61],[-69.23,-55.5],[-69.96,-55.2],[-71.01,-55.05],[-72.26,-54.5],[-73.29,-53.96],[-74.66,-52.84],[-73.84,-53.05],[-72.43,-53.72],[-71.11,-54.07],[-70.59,-53.62],[-70.27,-52.93],[-69.35,-52.52],[-68.63,-52.64],[-68.63,-52.64],[-68.25,-53.1],[-67.75,-53.85]]]},{"type":"Polygon","coordinates":[[[-58.55,-51.1],[-57.75,-51.55],[-58.05,-51.9],[-59.4,-52.2],[-59.85,-51.85],[-60.7,-52.3],[-61.2,-51.85],[-60,-51.25],[-59.15,-51.5],[-58.55,-51.1]]]},{"type":"Polygon","coordinates":[[[70.28,-49.71],[68.75,-49.77],[68.72,-49.24],[68.87,-48.83],[68.94,-48.62],[69.58,-48.94],[70.53,-49.06],[70.56,-49.26],[70.28,-49.71]]]},{"type":"Polygon","coordinates":[[[145.4,-40.79],[146.36,-41.14],[146.91,-41.0],[147.69,-40.81],[148.29,-40.88],[148.36,-42.06],[148.02,-42.41],[147.91,-43.21],[147.56,-42.94],[146.87,-43.63],[146.66,-43.58],[146.05,-43.55],[145.43,-42.69],[145.3,-42.03],[144.72,-41.16],[144.74,-40.7],[145.4,-40.79]]]},{"type":"Polygon","coordinates":[[[173.02,-40.92],[173.25,-41.33],[173.96,-40.93],[174.25,-41.35],[174.25,-41.77],[173.88,-42.23],[173.22,-42.97],[172.71,-43.37],[173.08,-43.85],[172.31,-43.87],[171.45,-44.24],[171.19,-44.9],[170.62,-45.91],[169.83,-46.36],[169.33,-46.64],[168.41,-46.62],[167.76,-46.29],[166.68,-46.22],[166.51,-45.85],[167.05,-45.11],[168.3,-44.12],[168.95,-43.94],[169.67,-43.56],[170.52,-43.03],[171.13,-42.51],[171.57,-41.77],[171.95,-41.51],[172.1,-40.96],[172.8,-40.49],[173.02,-40.92]]]},{"type":"Polygon","coordinates":[[[174.61,-36.16],[175.34,-37.21],[175.36,-36.53],[175.81,-36.8],[175.96,-37.56],[176.76,-37.88],[177.44,-37.96],[178.01,-37.58],[178.52,-37.7],[178.27,-38.58],[177.97,-39.17],[177.21,-39.15],[176.94,-39.45],[177.03,-39.88],[176.89,-40.07],[176.51,-40.6],[176.01,-41.29],[175.24,-41.69],[175.07,-41.43],[174.65,-41.28],[175.23,-40.46],[174.9,-39.91],[173.82,-39.51],[173.85,-39.15],[174.57,-38.8],[174.74,-38.03],[174.7,-37.38],[174.29,-36.71],[174.32,-36.53],[173.84,-36.12],[173.05,-35.24],[172.64,-34.53],[173.01,-34.45],[173.55,-35.01],[174.33,-35.27],[174.61,-36.16]]]},{"type":"Polygon","coordinates":[[[167.12,-22.16],[166.74,-22.4],[166.19,-22.13],[165.47,-21.68],[164.83,-21.15],[164.17,-20.44],[164.03,-20.11],[164.46,-20.12],[165.02,-20.46],[165.46,-20.8],[165.78,-21.08],[166.6,-21.7],[167.12,-22.16]]]},{"type":"Polygon","coordinates":[[[178.37,-17.34],[178.72,-17.63],[178.55,-18.15],[177.93,-18.29],[177.38,-18.16],[177.29,-17.72],[177.67,-17.38],[178.13,-17.5],[178.37,-17.34]]]},{"type":"Polygon","coordinates":[[[179.36,-16.8],[178.73,-17.01],[178.6,-16.64],[179.1,-16.43],[179.41,-16.38],[180,-16.07],[180,-16.56],[179.36,-16.8]]]},{"type":"Polygon","coordinates":[[[-179.92,-16.5],[-180,-16.56],[-180,-16.07],[-179.79,-16.02],[-179.92,-16.5]]]},{"type":"Polygon","coordinates":[[[167.84,-16.47],[167.52,-16.6],[167.18,-16.16],[167.22,-15.89],[167.84,-16.47]]]},{"type":"Polygon","coordinates":[[[167.11,-14.93],[167.27,-15.74],[167.0,-15.61],[166.79,-15.67],[166.65,-15.39],[166.63,-14.63],[167.11,-14.93]]]},{"type":"Polygon","coordinates":[[[50.06,-13.56],[50.22,-14.76],[50.48,-15.23],[50.38,-15.71],[50.2,-16.0],[49.86,-15.41],[49.67,-15.71],[49.86,-16.45],[49.77,-16.88],[49.5,-17.11],[49.44,-17.95],[49.04,-19.12],[48.55,-20.5],[47.93,-22.39],[47.55,-23.78],[47.1,-24.94],[46.28,-25.18],[45.41,-25.6],[44.83,-25.35],[44.04,-24.99],[43.76,-24.46],[43.7,-23.57],[43.35,-22.78],[43.25,-22.06],[43.43,-21.34],[43.89,-21.16],[43.9,-20.83],[44.37,-20.07],[44.46,-19.44],[44.23,-18.96],[44.04,-18.33],[43.96,-17.41],[44.31,-16.85],[44.45,-16.22],[44.94,-16.18],[45.5,-15.97],[45.87,-15.79],[46.31,-15.78],[46.88,-15.21],[47.71,-14.59],[48.01,-14.09],[47.87,-13.66],[48.29,-13.78],[48.85,-13.09],[48.86,-12.49],[49.19,-12.04],[49.54,-12.47],[49.81,-12.9],[50.06,-13.56]]]},{"type":"Polygon","coordinates":[[[143.56,-13.76],[143.92,-14.55],[144.56,-14.17],[144.89,-14.59],[145.37,-14.98],[145.27,-15.43],[145.49,-16.29],[145.64,-16.78],[145.89,-16.91],[146.16,-17.76],[146.06,-18.28],[146.39,-18.96],[147.47,-19.48],[148.18,-19.96],[148.85,-20.39],[148.72,-20.63],[149.29,-21.26],[149.68,-22.34],[150.08,-22.12],[150.48,-22.56],[150.73,-22.4],[150.9,-23.46],[151.61,-24.08],[152.07,-24.46],[152.86,-25.27],[153.14,-26.07],[153.16,-26.64],[153.09,-27.26],[153.57,-28.11],[153.51,-29.0],[153.34,-29.46],[153.07,-30.35],[153.09,-30.92],[152.89,-31.64],[152.45,-32.55],[151.71,-33.04],[151.34,-33.82],[151.01,-34.31],[150.71,-35.17],[150.33,-35.67],[150.08,-36.42],[149.95,-37.11],[150.0,-37.43],[149.42,-37.77],[148.3,-37.81],[147.38,-38.22],[146.92,-38.61],[146.32,-39.04],[145.49,-38.59],[144.88,-38.42],[145.03,-37.9],[144.49,-38.09],[143.61,-38.81],[142.75,-38.54],[142.18,-38.38],[141.61,-38.31],[140.64,-38.02],[139.99,-37.4],[139.81,-36.64],[139.57,-36.14],[139.08,-35.73],[138.12,-35.61],[138.45,-35.13],[138.21,-34.38],[137.72,-35.08],[136.83,-35.26],[137.35,-34.71],[137.5,-34.13],[137.89,-33.64],[137.81,-32.9],[137.0,-33.75],[136.37,-34.09],[135.99,-34.89],[135.21,-34.48],[135.24,-33.95],[134.61,-33.22],[134.09,-32.85],[134.27,-32.62],[132.99,-32.01],[132.29,-31.98],[131.33,-31.5],[129.54,-31.59],[128.24,-31.95],[127.1,-32.28],[126.15,-32.22],[125.09,-32.73],[124.22,-32.96],[124.03,-33.48],[123.66,-33.89],[122.81,-33.91],[122.18,-34.0],[121.3,-33.82],[120.58,-33.93],[119.89,-33.98],[119.3,-34.51],[119.01,-34.46],[118.51,-34.75],[118.02,-35.06],[117.3,-35.03],[116.63,-35.03],[115.56,-34.39],[115.03,-34.2],[115.05,-33.62],[115.55,-33.49],[115.71,-33.26],[115.68,-32.9],[115.8,-32.21],[115.69,-31.61],[115.16,-30.6],[115.0,-30.03],[115.04,-29.46],[114.64,-28.81],[114.62,-28.52],[114.17,-28.12],[114.05,-27.33],[113.48,-26.54],[113.34,-26.12],[113.78,-26.55],[113.44,-25.62],[113.94,-25.91],[114.23,-26.3],[114.22,-25.79],[113.72,-25.0],[113.63,-24.68],[113.39,-24.38],[113.5,-23.81],[113.71,-23.56],[113.84,-23.06],[113.74,-22.48],[114.15,-21.76],[114.23,-22.52],[114.65,-21.83],[115.46,-21.5],[115.95,-21.07],[116.71,-20.7],[117.17,-20.62],[117.44,-20.75],[118.23,-20.37],[118.84,-20.26],[118.99,-20.04],[119.25,-19.95],[119.81,-19.98],[120.86,-19.68],[121.4,-19.24],[121.66,-18.71],[122.24,-18.2],[122.29,-17.8],[122.31,-17.25],[123.01,-16.41],[123.43,-17.27],[123.86,-17.07],[123.5,-16.6],[123.82,-16.11],[124.26,-16.33],[124.38,-15.57],[124.93,-15.08],[125.17,-14.68],[125.67,-14.51],[125.69,-14.23],[126.13,-14.35],[126.14,-14.1],[126.58,-13.95],[127.07,-13.82],[127.8,-14.28],[128.36,-14.87],[128.99,-14.88],[129.62,-14.97],[129.41,-14.42],[129.89,-13.62],[130.34,-13.36],[130.18,-13.11],[130.62,-12.54],[131.22,-12.18],[131.74,-12.3],[132.58,-12.11],[132.56,-11.6],[131.82,-11.27],[132.36,-11.13],[133.02,-11.38],[133.55,-11.79],[134.39,-12.04],[134.68,-11.94],[135.3,-12.25],[135.88,-11.96],[136.26,-12.05],[136.49,-11.86],[136.95,-12.35],[136.69,-12.89],[136.31,-13.29],[135.96,-13.32],[136.08,-13.72],[135.78,-14.22],[135.43,-14.72],[135.5,-15.0],[136.3,-15.55],[137.07,-15.87],[137.58,-16.22],[138.3,-16.81],[138.59,-16.81],[139.11,-17.06],[139.26,-17.37],[140.22,-17.71],[140.88,-17.37],[141.07,-16.83],[141.27,-16.39],[141.4,-15.84],[141.7,-15.04],[141.56,-14.56],[141.64,-14.27],[141.52,-13.7],[141.65,-12.94],[141.84,-12.74],[141.69,-12.41],[141.93,-11.88],[142.12,-11.33],[142.14,-11.04],[142.52,-10.67],[142.8,-11.16],[142.87,-11.78],[143.12,-11.91],[143.16,-12.33],[143.52,-12.83],[143.6,-13.4],[143.56,-13.76]]]},{"type":"Polygon","coordinates":[[[162.12,-10.48],[162.4,-10.83],[161.7,-10.82],[161.32,-10.2],[161.92,-10.45],[162.12,-10.48]]]},{"type":"Polygon","coordinates":[[[120.72,-10.24],[120.3,-10.26],[118.97,-9.56],[119.9,-9.36],[120.43,-9.67],[120.78,-9.97],[120.72,-10.24]]]},{"type":"Polygon","coordinates":[[[160.85,-9.87],[160.46,-9.9],[159.85,-9.79],[159.64,-9.64],[159.7,-9.24],[160.36,-9.4],[160.69,-9.61],[160.85,-9.87]]]},{"type":"Polygon","coordinates":[[[161.68,-9.6],[161.53,-9.78],[160.79,-8.92],[160.58,-8.32],[160.92,-8.32],[161.28,-9.12],[161.68,-9.6]]]},{"type":"Polygon","coordinates":[[[124.44,-10.14],[123.58,-10.36],[123.46,-10.24],[123.55,-9.9],[123.98,-9.29],[124.97,-8.89],[125.09,-8.66],[125.95,-8.43],[126.64,-8.4],[126.96,-8.27],[127.34,-8.4],[126.97,-8.67],[125.93,-9.11],[125.09,-9.39],[124.44,-10.14]]]},{"type":"Polygon","coordinates":[[[117.9,-8.1],[118.26,-8.36],[118.88,-8.28],[119.13,-8.71],[117.97,-8.91],[117.28,-9.04],[116.74,-9.03],[117.08,-8.46],[117.63,-8.45],[117.9,-8.1]]]},{"type":"Polygon","coordinates":[[[122.9,-8.09],[122.76,-8.65],[121.25,-8.93],[119.92,-8.81],[119.92,-8.44],[120.72,-8.24],[121.34,-8.54],[122.01,-8.46],[122.9,-8.09]]]},{"type":"Polygon","coordinates":[[[159.88,-8.34],[159.92,-8.54],[159.13,-8.11],[158.59,-7.75],[158.21,-7.42],[158.36,-7.32],[158.82,-7.56],[159.64,-8.02],[159.88,-8.34]]]},{"type":"Polygon","coordinates":[[[157.54,-7.35],[157.34,-7.4],[156.9,-7.18],[156.49,-6.77],[156.54,-6.6],[157.14,-7.02],[157.54,-7.35]]]},{"type":"Polygon","coordinates":[[[108.62,-6.78],[110.54,-6.88],[110.76,-6.47],[112.61,-6.95],[112.98,-7.59],[114.48,-7.78],[115.71,-8.37],[114.56,-8.75],[113.46,-8.35],[112.56,-8.38],[111.52,-8.3],[110.59,-8.12],[109.43,-7.74],[108.69,-7.64],[108.28,-7.77],[106.45,-7.35],[106.28,-6.92],[105.37,-6.85],[106.05,-5.9],[107.27,-5.95],[108.07,-6.35],[108.49,-6.42],[108.62,-6.78]]]},{"type":"Polygon","coordinates":[[[134.72,-6.21],[134.21,-6.9],[134.11,-6.14],[134.29,-5.78],[134.5,-5.45],[134.73,-5.74],[134.72,-6.21]]]},{"type":"Polygon","coordinates":[[[155.88,-6.82],[155.6,-6.92],[155.17,-6.54],[154.73,-5.9],[154.51,-5.14],[154.65,-5.04],[154.76,-5.34],[155.06,-5.57],[155.55,-6.2],[156.02,-6.54],[155.88,-6.82]]]},{"type":"Polygon","coordinates":[[[151.98,-5.48],[151.46,-5.56],[151.3,-5.84],[150.75,-6.08],[150.24,-6.32],[149.71,-6.32],[148.89,-6.03],[148.32,-5.75],[148.4,-5.44],[149.3,-5.58],[149.85,-5.51],[150.0,-5.03],[150.14,-5.0],[150.24,-5.53],[150.81,-5.46],[151.09,-5.11],[151.65,-4.76],[151.54,-4.17],[152.14,-4.15],[152.34,-4.31],[152.32,-4.87],[151.98,-5.48]]]},{"type":"Polygon","coordinates":[[[127.25,-3.46],[126.87,-3.79],[126.18,-3.61],[125.99,-3.18],[127.0,-3.13],[127.25,-3.46]]]},{"type":"Polygon","coordinates":[[[130.47,-3.09],[130.83,-3.86],[129.99,-3.45],[129.16,-3.36],[128.59,-3.43],[127.9,-3.39],[128.14,-2.84],[129.37,-2.8],[130.47,-3.09]]]},{"type":"Polygon","coordinates":[[[153.14,-4.5],[152.83,-4.77],[152.64,-4.18],[152.41,-3.79],[151.95,-3.46],[151.38,-3.04],[150.66,-2.74],[150.94,-2.5],[151.48,-2.78],[151.82,-3.0],[152.24,-3.24],[152.64,-3.66],[153.02,-3.98],[153.14,-4.5]]]},{"type":"Polygon","coordinates":[[[134.14,-1.15],[134.42,-2.77],[135.46,-3.37],[136.29,-2.31],[137.44,-1.7],[138.33,-1.7],[139.18,-2.05],[139.93,-2.41],[141.0,-2.6],[142.74,-3.29],[144.58,-3.86],[145.27,-4.37],[145.83,-4.88],[145.98,-5.47],[147.65,-6.08],[147.89,-6.61],[146.97,-6.72],[147.19,-7.39],[148.08,-8.04],[148.73,-9.1],[149.31,-9.07],[149.27,-9.51],[150.04,-9.68],[149.74,-9.87],[150.8,-10.29],[150.69,-10.58],[150.03,-10.65],[149.78,-10.39],[148.92,-10.28],[147.91,-10.13],[147.14,-9.49],[146.57,-8.94],[146.05,-8.07],[144.74,-7.63],[143.9,-7.92],[143.29,-8.25],[143.41,-8.98],[142.63,-9.33],[142.07,-9.16],[141.03,-9.12],[140.14,-8.3],[139.13,-8.1],[138.88,-8.38],[137.61,-8.41],[138.04,-7.6],[138.67,-7.32],[138.41,-6.23],[137.93,-5.39],[135.99,-4.55],[135.16,-4.46],[133.66,-3.54],[133.37,-4.02],[132.98,-4.11],[132.76,-3.75],[132.75,-3.31],[131.99,-2.82],[133.07,-2.46],[133.78,-2.48],[133.7,-2.21],[132.23,-2.21],[131.84,-1.62],[130.94,-1.43],[130.52,-0.94],[131.87,-0.7],[132.38,-0.37],[133.99,-0.78],[134.14,-1.15]]]},{"type":"Polygon","coordinates":[[[125.24,1.42],[124.44,0.43],[123.69,0.24],[122.72,0.43],[121.06,0.38],[120.18,0.24],[120.04,-0.52],[120.94,-1.41],[121.48,-0.96],[123.34,-0.62],[123.26,-1.08],[122.82,-0.93],[122.39,-1.52],[121.51,-1.9],[122.45,-3.19],[122.27,-3.53],[123.17,-4.68],[123.16,-5.34],[122.63,-5.63],[122.24,-5.28],[122.72,-4.46],[121.74,-4.85],[121.49,-4.57],[121.62,-4.19],[120.9,-3.6],[120.97,-2.63],[120.31,-2.93],[120.39,-4.1],[120.43,-5.53],[119.8,-5.67],[119.37,-5.38],[119.65,-4.46],[119.5,-3.49],[119.08,-3.49],[118.77,-2.8],[119.18,-2.15],[119.32,-1.35],[119.83,0.15],[120.04,0.57],[120.89,1.31],[121.67,1.01],[122.93,0.88],[124.08,0.92],[125.07,1.64],[125.24,1.42]]]},{"type":"Polygon","coordinates":[[[128.69,1.13],[128.64,0.26],[128.12,0.36],[127.97,-0.25],[128.38,-0.78],[128.1,-0.9],[127.7,-0.27],[127.4,1.01],[127.6,1.81],[127.93,2.17],[128.0,1.63],[128.59,1.54],[128.69,1.13]]]},{"type":"Polygon","coordinates":[[[105.82,-5.85],[104.71,-5.87],[103.87,-5.04],[102.58,-4.22],[102.16,-3.61],[101.4,-2.8],[100.9,-2.05],[100.14,-0.65],[99.26,0.18],[98.97,1.04],[98.6,1.82],[97.7,2.45],[97.18,3.31],[96.42,3.87],[95.38,4.97],[95.29,5.48],[95.94,5.44],[97.48,5.25],[98.37,4.27],[99.14,3.59],[99.69,3.17],[100.64,2.1],[101.66,2.08],[102.5,1.4],[103.08,0.56],[103.84,0.1],[103.44,-0.71],[104.01,-1.06],[104.37,-1.08],[104.54,-1.78],[104.89,-2.34],[105.62,-2.43],[106.11,-3.06],[105.86,-4.31],[105.82,-5.85]]]},{"type":"Polygon","coordinates":[[[117.88,1.83],[119.0,0.9],[117.81,0.78],[117.48,0.1],[117.52,-0.8],[116.56,-1.49],[116.53,-2.48],[116.15,-4.01],[116.0,-3.66],[114.86,-4.11],[114.47,-3.5],[113.76,-3.44],[113.26,-3.12],[112.07,-3.48],[111.7,-2.99],[111.05,-3.05],[110.22,-2.93],[110.07,-1.59],[109.57,-1.31],[109.09,-0.46],[108.95,0.42],[109.07,1.34],[109.66,2.01],[110.4,1.66],[111.17,1.85],[111.37,2.7],[111.8,2.89],[113.0,3.1],[113.71,3.89],[114.2,4.53],[114.6,4.9],[115.45,5.45],[116.22,6.14],[116.73,6.92],[117.13,6.93],[117.64,6.42],[117.69,5.99],[118.35,5.71],[119.18,5.41],[119.11,5.02],[118.44,4.97],[118.62,4.48],[117.88,4.14],[117.31,3.23],[118.05,2.29],[117.88,1.83]]]},{"type":"Polygon","coordinates":[[[126.38,8.41],[126.48,7.75],[126.54,7.19],[126.2,6.27],[125.83,7.29],[125.36,6.79],[125.68,6.05],[125.4,5.58],[124.22,6.16],[123.94,6.89],[124.24,7.36],[123.61,7.83],[123.3,7.42],[122.83,7.46],[122.09,6.9],[121.92,7.19],[122.31,8.03],[122.94,8.32],[123.49,8.69],[123.84,8.24],[124.6,8.51],[124.76,8.96],[125.47,8.99],[125.41,9.76],[126.22,9.29],[126.31,8.78],[126.38,8.41]]]},{"type":"Polygon","coordinates":[[[81.22,6.2],[80.35,5.97],[79.87,6.76],[79.7,8.2],[80.15,9.82],[80.84,9.27],[81.3,8.56],[81.79,7.52],[81.64,6.48],[81.22,6.2]]]},{"type":"Polygon","coordinates":[[[-60.94,10.11],[-61.77,10],[-61.95,10.09],[-61.66,10.37],[-61.68,10.76],[-61.1,10.89],[-60.9,10.86],[-60.94,10.11]]]},{"type":"Polygon","coordinates":[[[123.98,10.28],[123.62,9.95],[123.31,9.32],[123.0,9.02],[122.38,9.71],[122.59,9.98],[122.84,10.26],[122.95,10.88],[123.5,10.94],[123.34,10.27],[124.08,11.23],[123.98,10.28]]]},{"type":"Polygon","coordinates":[[[118.5,9.32],[117.17,8.37],[117.66,9.07],[118.39,9.68],[118.99,10.38],[119.51,11.37],[119.69,10.55],[119.03,10.0],[118.5,9.32]]]},{"type":"Polygon","coordinates":[[[121.88,11.89],[122.48,11.58],[123.12,11.58],[123.1,11.17],[122.64,10.74],[122.0,10.44],[121.97,10.91],[122.04,11.42],[121.88,11.89]]]},{"type":"Polygon","coordinates":[[[125.5,12.16],[125.78,11.05],[125.01,11.31],[125.03,10.98],[125.28,10.36],[124.8,10.13],[124.76,10.84],[124.46,10.89],[124.3,11.5],[124.89,11.42],[124.88,11.79],[124.27,12.56],[125.23,12.54],[125.5,12.16]]]},{"type":"Polygon","coordinates":[[[121.53,13.07],[121.26,12.21],[120.83,12.7],[120.32,13.47],[121.18,13.43],[121.53,13.07]]]},{"type":"Polygon","coordinates":[[[121.32,18.5],[121.94,18.22],[122.25,18.48],[122.34,18.22],[122.17,17.81],[122.52,17.09],[122.25,16.26],[121.66,15.93],[121.51,15.12],[121.73,14.33],[122.26,14.22],[122.7,14.34],[123.95,13.78],[123.86,13.24],[124.18,13.0],[124.08,12.54],[123.3,13.03],[122.93,13.55],[122.67,13.19],[122.03,13.78],[121.13,13.64],[120.63,13.86],[120.68,14.27],[120.99,14.53],[120.69,14.76],[120.56,14.4],[120.07,14.97],[119.92,15.41],[119.88,16.36],[120.29,16.03],[120.39,17.6],[120.72,18.51],[121.32,18.5]]]},{"type":"Polygon","coordinates":[[[-65.59,18.23],[-65.85,17.98],[-66.6,17.98],[-67.18,17.95],[-67.24,18.37],[-67.1,18.52],[-66.28,18.51],[-65.77,18.43],[-65.59,18.23]]]},{"type":"Polygon","coordinates":[[[-76.9,17.87],[-77.21,17.7],[-77.77,17.86],[-78.34,18.23],[-78.22,18.45],[-77.8,18.52],[-77.57,18.49],[-76.9,18.4],[-76.37,18.16],[-76.2,17.89],[-76.9,17.87]]]},{"type":"Polygon","coordinates":[[[-72.58,19.87],[-71.71,19.71],[-71.59,19.88],[-70.81,19.88],[-70.21,19.62],[-69.95,19.65],[-69.77,19.29],[-69.22,19.31],[-69.25,19.02],[-68.81,18.98],[-68.32,18.61],[-68.69,18.21],[-69.16,18.42],[-69.62,18.38],[-69.95,18.43],[-70.13,18.25],[-70.52,18.18],[-70.67,18.43],[-71.0,18.28],[-71.4,17.6],[-71.66,17.76],[-71.71,18.04],[-72.37,18.21],[-72.84,18.15],[-73.45,18.22],[-73.92,18.03],[-74.46,18.34],[-74.37,18.66],[-73.45,18.53],[-72.69,18.45],[-72.33,18.67],[-72.79,19.1],[-72.78,19.48],[-73.42,19.64],[-73.19,19.92],[-72.58,19.87]]]},{"type":"Polygon","coordinates":[[[110.34,18.68],[109.48,18.2],[108.66,18.51],[108.63,19.37],[109.12,19.82],[110.21,20.1],[110.79,20.08],[111.01,19.7],[110.57,19.26],[110.34,18.68]]]},{"type":"Polygon","coordinates":[[[-155.54,19.08],[-155.69,18.92],[-155.94,19.06],[-155.91,19.34],[-156.07,19.7],[-156.02,19.81],[-155.85,19.98],[-155.92,20.17],[-155.86,20.27],[-155.79,20.25],[-155.4,20.08],[-155.22,19.99],[-155.06,19.86],[-154.81,19.51],[-154.83,19.45],[-155.22,19.24],[-155.54,19.08]]]},{"type":"Polygon","coordinates":[[[-156.08,20.64],[-156.41,20.57],[-156.59,20.78],[-156.7,20.86],[-156.71,20.93],[-156.61,21.01],[-156.26,20.92],[-156.0,20.76],[-156.08,20.64]]]},{"type":"Polygon","coordinates":[[[-156.76,21.18],[-156.79,21.07],[-157.33,21.1],[-157.25,21.22],[-156.76,21.18]]]},{"type":"Polygon","coordinates":[[[-157.65,21.32],[-157.71,21.26],[-157.78,21.28],[-158.13,21.31],[-158.25,21.54],[-158.29,21.58],[-158.03,21.72],[-157.94,21.65],[-157.65,21.32]]]},{"type":"Polygon","coordinates":[[[-159.35,21.98],[-159.46,21.88],[-159.8,22.07],[-159.75,22.14],[-159.6,22.24],[-159.37,22.21],[-159.35,21.98]]]},{"type":"Polygon","coordinates":[[[-79.68,22.77],[-79.28,22.4],[-78.35,22.51],[-77.99,22.28],[-77.15,21.66],[-76.52,21.21],[-76.19,21.22],[-75.6,21.02],[-75.67,20.74],[-74.93,20.69],[-74.18,20.28],[-74.3,20.05],[-74.96,19.92],[-75.63,19.87],[-76.32,19.95],[-77.76,19.86],[-77.09,20.41],[-77.49,20.67],[-78.14,20.74],[-78.48,21.03],[-78.72,21.6],[-79.28,21.56],[-80.22,21.83],[-80.52,22.04],[-81.82,22.19],[-82.17,22.39],[-81.8,22.64],[-82.78,22.69],[-83.49,22.17],[-83.91,22.15],[-84.05,21.91],[-84.55,21.8],[-84.97,21.9],[-84.45,22.2],[-84.23,22.57],[-83.78,22.79],[-83.27,22.98],[-82.51,23.08],[-82.27,23.19],[-81.4,23.12],[-80.62,23.11],[-79.68,22.77]]]},{"type":"Polygon","coordinates":[[[-77.53,23.76],[-77.78,23.71],[-78.03,24.29],[-78.41,24.58],[-78.19,25.21],[-77.89,25.17],[-77.54,24.34],[-77.53,23.76]]]},{"type":"Polygon","coordinates":[[[121.18,22.79],[120.75,21.97],[120.22,22.81],[120.11,23.56],[120.69,24.54],[121.5,25.3],[121.95,25.0],[121.78,24.39],[121.18,22.79]]]},{"type":"Polygon","coordinates":[[[-77.82,26.58],[-78.91,26.42],[-78.98,26.79],[-78.51,26.87],[-77.85,26.84],[-77.82,26.58]]]},{"type":"Polygon","coordinates":[[[-77,26.59],[-77.17,25.88],[-77.36,26.01],[-77.34,26.53],[-77.79,26.93],[-77.79,27.04],[-77,26.59]]]},{"type":"Polygon","coordinates":[[[134.64,34.15],[134.77,33.81],[134.2,33.2],[133.79,33.52],[133.28,33.29],[133.01,32.7],[132.36,32.99],[132.37,33.46],[132.92,34.06],[133.49,33.94],[133.9,34.36],[134.64,34.15]]]},{"type":"Polygon","coordinates":[[[34.58,35.67],[33.9,35.25],[33.97,35.06],[34.0,34.98],[32.98,34.57],[32.49,34.7],[32.26,35.1],[32.73,35.14],[32.8,35.15],[32.95,35.39],[33.67,35.37],[34.58,35.67]]]},{"type":"Polygon","coordinates":[[[23.7,35.71],[24.25,35.37],[25.03,35.42],[25.77,35.35],[25.75,35.18],[26.29,35.3],[26.16,35.0],[24.72,34.92],[24.74,35.08],[23.51,35.28],[23.7,35.71]]]},{"type":"Polygon","coordinates":[[[15.52,38.23],[15.16,37.44],[15.31,37.13],[15.1,36.62],[14.34,37.0],[13.83,37.1],[12.43,37.61],[12.57,38.13],[13.74,38.03],[14.76,38.14],[15.52,38.23]]]},{"type":"Polygon","coordinates":[[[9.21,41.21],[9.81,40.5],[9.67,39.18],[9.21,39.24],[8.81,38.91],[8.43,39.17],[8.39,40.38],[8.16,40.95],[8.71,40.9],[9.21,41.21]]]},{"type":"Polygon","coordinates":[[[140.98,37.14],[140.6,36.34],[140.77,35.84],[140.25,35.14],[138.98,34.67],[137.22,34.61],[135.79,33.46],[135.12,33.85],[135.08,34.6],[133.34,34.38],[132.16,33.9],[130.99,33.89],[132.0,33.15],[131.33,31.45],[130.69,31.03],[130.2,31.42],[130.45,32.32],[129.81,32.61],[129.41,33.3],[130.35,33.6],[130.88,34.23],[131.88,34.75],[132.62,35.43],[134.61,35.73],[135.68,35.53],[136.72,37.3],[137.39,36.83],[138.86,37.83],[139.43,38.22],[140.05,39.44],[139.88,40.56],[140.31,41.2],[141.37,41.38],[141.91,39.99],[141.88,39.18],[140.96,38.17],[140.98,37.14]]]},{"type":"Polygon","coordinates":[[[9.56,42.15],[9.23,41.38],[8.78,41.58],[8.54,42.26],[8.75,42.63],[9.39,43.01],[9.56,42.15]]]},{"type":"Polygon","coordinates":[[[143.91,44.17],[144.61,43.96],[145.32,44.38],[145.54,43.26],[144.06,42.99],[143.18,42.0],[141.61,42.68],[141.07,41.58],[139.96,41.57],[139.82,42.56],[140.31,43.33],[141.38,43.39],[141.67,44.77],[141.97,45.55],[143.14,44.51],[143.91,44.17]]]},{"type":"Polygon","coordinates":[[[-63.66,46.55],[-62.94,46.42],[-62.01,46.44],[-62.5,46.03],[-62.87,45.97],[-64.14,46.39],[-64.39,46.73],[-64.01,47.04],[-63.66,46.55]]]},{"type":"Polygon","coordinates":[[[-61.81,49.11],[-62.29,49.09],[-63.59,49.4],[-64.52,49.87],[-64.17,49.96],[-62.86,49.71],[-61.84,49.29],[-61.81,49.11]]]},{"type":"Polygon","coordinates":[[[-123.51,48.51],[-124.01,48.37],[-125.66,48.83],[-125.95,49.18],[-126.85,49.53],[-127.03,49.81],[-128.06,49.99],[-128.44,50.54],[-128.36,50.77],[-127.31,50.55],[-126.7,50.4],[-125.76,50.3],[-125.42,49.95],[-124.92,49.48],[-123.92,49.06],[-123.51,48.51]]]},{"type":"Polygon","coordinates":[[[-56.13,50.69],[-56.8,49.81],[-56.14,50.15],[-55.47,49.94],[-55.82,49.59],[-54.94,49.31],[-54.47,49.56],[-53.48,49.25],[-53.79,48.52],[-53.09,48.69],[-52.96,48.16],[-52.65,47.54],[-53.07,46.66],[-53.52,46.62],[-54.18,46.81],[-53.96,47.63],[-54.24,47.75],[-55.4,46.88],[-56.0,46.92],[-55.29,47.39],[-56.25,47.63],[-57.33,47.57],[-59.27,47.6],[-59.42,47.9],[-58.8,48.25],[-59.23,48.52],[-58.39,49.13],[-57.36,50.72],[-56.74,51.29],[-55.87,51.63],[-55.41,51.59],[-55.6,51.32],[-56.13,50.69]]]},{"type":"Polygon","coordinates":[[[-132.71,54.04],[-132.71,54.04],[-132.71,54.04],[-132.71,54.04],[-131.75,54.12],[-132.05,52.98],[-131.18,52.18],[-131.58,52.18],[-132.18,52.64],[-132.55,53.1],[-133.05,53.41],[-133.24,53.85],[-133.18,54.17],[-132.71,54.04]]]},{"type":"Polygon","coordinates":[[[143.65,50.75],[144.65,48.98],[143.17,49.31],[142.56,47.86],[143.53,46.84],[143.51,46.14],[142.75,46.74],[142.09,45.97],[141.91,46.81],[142.02,47.78],[141.9,48.86],[142.14,49.62],[142.18,50.95],[141.59,51.94],[141.68,53.3],[142.61,53.76],[142.21,54.23],[142.65,54.37],[142.91,53.7],[143.26,52.74],[143.24,51.76],[143.65,50.75]]]},{"type":"Polygon","coordinates":[[[-6.79,52.26],[-8.56,51.67],[-9.98,51.82],[-9.17,52.86],[-9.69,53.88],[-8.33,54.66],[-7.57,55.13],[-6.73,55.17],[-5.66,54.55],[-6.2,53.87],[-6.03,53.15],[-6.79,52.26]]]},{"type":"Polygon","coordinates":[[[12.69,55.61],[12.09,54.8],[11.04,55.36],[10.9,55.78],[12.37,56.11],[12.69,55.61]]]},{"type":"Polygon","coordinates":[[[-153.01,57.12],[-154.01,56.73],[-154.52,56.99],[-154.67,57.46],[-153.76,57.82],[-153.23,57.97],[-152.56,57.9],[-152.14,57.59],[-153.01,57.12]]]},{"type":"Polygon","coordinates":[[[-3.01,58.63],[-4.07,57.55],[-3.06,57.69],[-1.96,57.68],[-2.22,56.87],[-3.12,55.97],[-2.09,55.91],[-1.11,54.62],[-0.43,54.46],[0.18,53.33],[0.47,52.93],[1.68,52.74],[1.56,52.1],[1.05,51.81],[1.45,51.29],[0.55,50.77],[-0.79,50.77],[-2.49,50.5],[-2.96,50.7],[-3.62,50.23],[-4.54,50.34],[-5.25,49.96],[-5.78,50.16],[-4.31,51.21],[-3.41,51.43],[-4.98,51.59],[-5.27,51.99],[-4.22,52.3],[-4.77,52.84],[-4.58,53.5],[-3.09,53.4],[-2.95,53.98],[-3.63,54.62],[-4.84,54.79],[-5.08,55.06],[-4.72,55.51],[-5.05,55.78],[-5.59,55.31],[-5.64,56.28],[-6.15,56.79],[-5.79,57.82],[-5.01,58.63],[-4.21,58.55],[-3.01,58.63]]]},{"type":"Polygon","coordinates":[[[-165.58,59.91],[-166.19,59.75],[-166.85,59.94],[-167.46,60.21],[-166.47,60.38],[-165.67,60.29],[-165.58,59.91]]]},{"type":"Polygon","coordinates":[[[-79.27,62.16],[-79.66,61.63],[-80.1,61.72],[-80.36,62.02],[-80.32,62.09],[-79.93,62.39],[-79.52,62.36],[-79.27,62.16]]]},{"type":"Polygon","coordinates":[[[-81.9,62.71],[-83.07,62.16],[-83.77,62.18],[-83.99,62.45],[-83.25,62.91],[-81.88,62.9],[-81.9,62.71]]]},{"type":"Polygon","coordinates":[[[-171.73,63.78],[-171.11,63.59],[-170.49,63.69],[-169.68,63.43],[-168.69,63.3],[-168.77,63.19],[-169.53,62.98],[-170.29,63.19],[-170.67,63.38],[-171.55,63.32],[-171.79,63.41],[-171.73,63.78]]]},{"type":"Polygon","coordinates":[[[-85.16,65.66],[-84.98,65.22],[-84.46,65.37],[-83.88,65.11],[-82.79,64.77],[-81.64,64.46],[-81.55,63.98],[-80.82,64.06],[-80.1,63.73],[-80.99,63.41],[-82.55,63.65],[-83.11,64.1],[-84.1,63.57],[-85.52,63.05],[-85.87,63.64],[-87.22,63.54],[-86.35,64.04],[-86.22,64.82],[-85.88,65.74],[-85.16,65.66]]]},{"type":"Polygon","coordinates":[[[-14.51,66.46],[-14.74,65.81],[-13.61,65.13],[-14.91,64.36],[-17.79,63.68],[-18.66,63.5],[-19.97,63.64],[-22.76,63.96],[-21.78,64.4],[-23.96,64.89],[-22.18,65.08],[-22.23,65.38],[-24.33,65.61],[-23.65,66.26],[-22.13,66.41],[-20.58,65.73],[-19.06,66.28],[-17.8,65.99],[-16.17,66.53],[-14.51,66.46]]]},{"type":"Polygon","coordinates":[[[-75.87,67.15],[-76.99,67.1],[-77.24,67.59],[-76.81,68.15],[-75.9,68.29],[-75.11,68.01],[-75.1,67.58],[-75.22,67.44],[-75.87,67.15]]]},{"type":"Polygon","coordinates":[[[-175.01,66.58],[-174.34,66.34],[-174.57,67.06],[-171.86,66.91],[-169.9,65.98],[-170.89,65.54],[-172.53,65.44],[-172.56,64.46],[-172.96,64.25],[-173.89,64.28],[-174.65,64.63],[-175.98,64.92],[-176.21,65.36],[-177.22,65.52],[-178.36,65.39],[-178.9,65.74],[-178.69,66.11],[-179.88,65.87],[-179.43,65.4],[-180,64.98],[-180,68.96],[-177.55,68.2],[-174.93,67.21],[-175.01,66.58]]]},{"type":"Polygon","coordinates":[[[-95.65,69.11],[-96.27,68.76],[-97.62,69.06],[-98.43,68.95],[-99.8,69.4],[-98.92,69.71],[-98.22,70.14],[-97.16,69.86],[-96.56,69.68],[-96.26,69.49],[-95.65,69.11]]]},{"type":"Polygon","coordinates":[[[180,70.83],[178.9,70.78],[178.73,71.1],[180,71.52],[180,70.83]]]},{"type":"Polygon","coordinates":[[[-178.69,70.89],[-180,70.83],[-180,71.52],[-179.87,71.56],[-179.02,71.56],[-177.58,71.27],[-177.66,71.13],[-178.69,70.89]]]},{"type":"Polygon","coordinates":[[[-90.55,69.5],[-90.55,68.48],[-89.22,69.26],[-88.02,68.62],[-88.32,67.87],[-87.35,67.2],[-86.31,67.92],[-85.58,68.78],[-85.52,69.88],[-84.1,69.81],[-82.62,69.66],[-81.28,69.16],[-81.22,68.67],[-81.96,68.13],[-81.26,67.6],[-81.39,67.11],[-83.34,66.41],[-84.74,66.26],[-85.77,66.56],[-86.07,66.06],[-87.03,65.21],[-87.32,64.78],[-88.48,64.1],[-89.91,64.03],[-90.7,63.61],[-90.77,62.96],[-91.93,62.84],[-93.16,62.02],[-94.24,60.9],[-94.63,60.11],[-94.68,58.95],[-93.22,58.78],[-92.76,57.85],[-92.3,57.09],[-90.9,57.28],[-89.04,56.85],[-88.04,56.47],[-87.32,56.0],[-86.07,55.72],[-85.01,55.3],[-83.36,55.24],[-82.27,55.15],[-82.44,54.28],[-82.12,53.28],[-81.4,52.16],[-79.91,51.21],[-79.14,51.53],[-78.6,52.56],[-79.12,54.14],[-79.83,54.67],[-78.23,55.14],[-77.1,55.84],[-76.54,56.53],[-76.62,57.2],[-77.3,58.05],[-78.52,58.8],[-77.34,59.85],[-77.77,60.76],[-78.11,62.32],[-77.41,62.55],[-75.7,62.28],[-74.67,62.18],[-73.84,62.44],[-72.91,62.11],[-71.68,61.53],[-71.37,61.14],[-69.59,61.06],[-69.62,60.22],[-69.29,58.96],[-68.37,58.8],[-67.65,58.21],[-66.2,58.77],[-65.25,59.87],[-64.58,60.34],[-63.8,59.44],[-62.5,58.17],[-61.4,56.97],[-61.8,56.34],[-60.47,55.78],[-59.57,55.2],[-57.98,54.95],[-57.33,54.63],[-56.94,53.78],[-56.16,53.65],[-55.76,53.27],[-55.68,52.15],[-56.41,51.77],[-57.13,51.42],[-58.77,51.06],[-60.03,50.24],[-61.72,50.08],[-63.86,50.29],[-65.36,50.3],[-66.4,50.23],[-67.24,49.51],[-68.51,49.07],[-69.95,47.74],[-71.1,46.82],[-70.26,46.99],[-68.65,48.3],[-66.55,49.13],[-65.06,49.23],[-64.17,48.74],[-65.12,48.07],[-64.8,46.99],[-64.47,46.24],[-63.17,45.74],[-61.52,45.88],[-60.52,47.01],[-60.45,46.28],[-59.8,45.92],[-61.04,45.27],[-63.25,44.67],[-64.25,44.27],[-65.36,43.55],[-66.12,43.62],[-66.16,44.47],[-64.43,45.29],[-66.03,45.26],[-67.14,45.14],[-66.96,44.81],[-68.03,44.33],[-69.06,43.98],[-70.12,43.68],[-70.69,43.03],[-70.81,42.87],[-70.83,42.34],[-70.49,41.81],[-70.08,41.78],[-70.18,42.15],[-69.88,41.92],[-69.97,41.64],[-70.64,41.48],[-71.12,41.49],[-71.86,41.32],[-72.29,41.27],[-72.88,41.22],[-73.71,40.93],[-72.24,41.12],[-71.95,40.93],[-73.34,40.63],[-73.98,40.63],[-73.95,40.75],[-74.26,40.47],[-73.96,40.43],[-74.18,39.71],[-74.91,38.94],[-74.98,39.2],[-75.2,39.25],[-75.53,39.5],[-75.32,38.96],[-75.08,38.78],[-75.06,38.4],[-75.38,38.02],[-75.94,37.22],[-76.03,37.26],[-75.72,37.94],[-76.23,38.32],[-76.35,39.15],[-76.54,38.72],[-76.33,38.08],[-76.96,38.23],[-76.3,37.92],[-76.26,36.97],[-75.97,36.9],[-75.87,36.55],[-75.73,35.55],[-76.36,34.81],[-77.4,34.51],[-78.05,33.93],[-78.55,33.86],[-79.06,33.49],[-79.2,33.16],[-80.3,32.51],[-80.86,32.03],[-81.34,31.44],[-81.49,30.73],[-81.31,30.04],[-80.98,29.18],[-80.54,28.47],[-80.53,28.04],[-80.06,26.88],[-80.09,26.21],[-80.13,25.82],[-80.38,25.21],[-80.68,25.08],[-81.17,25.2],[-81.33,25.64],[-81.71,25.87],[-82.24,26.73],[-82.71,27.5],[-82.86,27.89],[-82.65,28.55],[-82.93,29.1],[-83.71,29.94],[-84.1,30.09],[-85.11,29.64],[-85.29,29.69],[-85.77,30.15],[-86.4,30.4],[-87.53,30.27],[-88.42,30.38],[-89.18,30.32],[-89.6,30.18],[-89.41,29.89],[-89.43,29.49],[-89.22,29.29],[-89.41,29.16],[-89.78,29.31],[-90.15,29.12],[-90.88,29.15],[-91.63,29.68],[-92.5,29.55],[-93.23,29.78],[-93.85,29.71],[-94.69,29.48],[-95.6,28.74],[-96.59,28.31],[-97.14,27.83],[-97.37,27.38],[-97.38,26.69],[-97.33,26.21],[-97.14,25.87],[-97.14,25.87],[-97.14,25.87],[-97.53,24.99],[-97.7,24.27],[-97.78,22.93],[-97.87,22.44],[-97.7,21.9],[-97.39,21.41],[-97.19,20.64],[-96.53,19.89],[-96.29,19.32],[-95.9,18.83],[-94.84,18.56],[-94.43,18.14],[-93.55,18.42],[-92.79,18.52],[-92.04,18.7],[-91.41,18.88],[-90.77,19.28],[-90.53,19.87],[-90.45,20.71],[-90.28,21.0],[-89.6,21.26],[-88.54,21.49],[-87.66,21.46],[-87.05,21.54],[-86.81,21.33],[-86.85,20.85],[-87.38,20.26],[-87.62,19.65],[-87.44,19.47],[-87.59,19.04],[-87.84,18.26],[-88.09,18.52],[-88.3,18.5],[-88.3,18.35],[-88.11,18.35],[-88.12,18.08],[-88.29,17.64],[-88.2,17.49],[-88.3,17.13],[-88.24,17.04],[-88.36,16.53],[-88.55,16.27],[-88.73,16.23],[-88.93,15.89],[-88.6,15.71],[-88.52,15.86],[-88.22,15.73],[-88.12,15.69],[-87.9,15.86],[-87.62,15.88],[-87.52,15.8],[-87.37,15.85],[-86.9,15.76],[-86.44,15.78],[-86.12,15.89],[-86.0,16.01],[-85.68,15.95],[-85.44,15.89],[-85.18,15.91],[-84.98,16.0],[-84.53,15.86],[-84.37,15.84],[-84.06,15.65],[-83.77,15.42],[-83.41,15.27],[-83.15,15.0],[-83.23,14.9],[-83.28,14.68],[-83.18,14.31],[-83.41,13.97],[-83.52,13.57],[-83.55,13.13],[-83.5,12.87],[-83.47,12.42],[-83.63,12.32],[-83.72,11.89],[-83.65,11.63],[-83.86,11.37],[-83.81,11.1],[-83.66,10.94],[-83.4,10.4],[-83.02,9.99],[-82.55,9.57],[-82.19,9.21],[-82.21,9.0],[-81.81,8.95],[-81.71,9.03],[-81.44,8.79],[-80.95,8.86],[-80.52,9.11],[-79.91,9.31],[-79.57,9.61],[-79.02,9.55],[-79.06,9.45],[-78.5,9.42],[-78.06,9.25],[-77.73,8.95],[-77.35,8.67],[-76.84,8.64],[-76.09,9.34],[-75.67,9.44],[-75.66,9.77],[-75.48,10.62],[-74.91,11.08],[-74.28,11.1],[-74.2,11.31],[-73.41,11.23],[-72.63,11.73],[-72.24,11.96],[-71.75,12.44],[-71.4,12.38],[-71.14,12.11],[-71.33,11.78],[-71.36,11.54],[-71.95,11.42],[-71.62,10.97],[-71.63,10.45],[-72.07,9.87],[-71.7,9.07],[-71.26,9.14],[-71.04,9.86],[-71.35,10.21],[-71.4,10.97],[-70.16,11.38],[-70.29,11.85],[-69.94,12.16],[-69.58,11.46],[-68.88,11.44],[-68.23,10.89],[-68.19,10.55],[-67.3,10.55],[-66.23,10.65],[-65.66,10.2],[-64.89,10.08],[-64.33,10.39],[-64.32,10.64],[-63.08,10.7],[-61.88,10.72],[-62.73,10.42],[-62.39,9.95],[-61.59,9.87],[-60.83,9.38],[-60.67,8.58],[-60.15,8.6],[-59.76,8.37],[-59.1,8.0],[-58.48,7.35],[-58.45,6.83],[-58.08,6.81],[-57.54,6.32],[-57.15,5.97],[-55.95,5.77],[-55.84,5.95],[-55.03,6.03],[-53.96,5.76],[-53.62,5.65],[-52.88,5.41],[-51.82,4.57],[-51.66,4.16],[-51.32,4.2],[-51.07,3.65],[-50.51,1.9],[-49.97,1.74],[-49.95,1.05],[-50.7,0.22],[-50.39,-0.08],[-48.62,-0.24],[-48.58,-1.24],[-47.82,-0.58],[-46.57,-0.94],[-44.91,-1.55],[-44.42,-2.14],[-44.58,-2.69],[-43.42,-2.38],[-41.47,-2.91],[-39.98,-2.87],[-38.5,-3.7],[-37.22,-4.82],[-36.45,-5.11],[-35.6,-5.15],[-35.24,-5.46],[-34.9,-6.74],[-34.73,-7.34],[-35.13,-9.0],[-35.64,-9.65],[-37.05,-11.04],[-37.68,-12.17],[-38.42,-13.04],[-38.67,-13.06],[-38.95,-13.79],[-38.88,-15.67],[-39.16,-17.21],[-39.27,-17.87],[-39.58,-18.26],[-39.76,-19.6],[-40.77,-20.9],[-40.94,-21.94],[-41.75,-22.37],[-41.99,-22.97],[-43.07,-22.97],[-44.65,-23.35],[-45.35,-23.8],[-46.47,-24.09],[-47.65,-24.89],[-48.5,-25.88],[-48.64,-26.62],[-48.47,-27.18],[-48.66,-28.19],[-48.89,-28.67],[-49.59,-29.22],[-50.7,-30.98],[-51.58,-31.78],[-52.26,-32.25],[-52.71,-33.2],[-53.37,-33.77],[-53.81,-34.4],[-54.94,-34.95],[-55.67,-34.75],[-56.22,-34.86],[-57.14,-34.43],[-57.82,-34.46],[-58.43,-33.91],[-58.5,-34.43],[-57.23,-35.29],[-57.36,-35.98],[-56.74,-36.41],[-56.79,-36.9],[-57.75,-38.18],[-59.23,-38.72],[-61.24,-38.93],[-62.34,-38.83],[-62.13,-39.42],[-62.33,-40.17],[-62.15,-40.68],[-62.75,-41.03],[-63.77,-41.17],[-64.73,-40.8],[-65.12,-41.06],[-64.98,-42.06],[-64.3,-42.36],[-63.76,-42.04],[-63.46,-42.56],[-64.38,-42.87],[-65.18,-43.5],[-65.33,-44.5],[-65.57,-45.04],[-66.51,-45.04],[-67.29,-45.55],[-67.58,-46.3],[-66.6,-47.03],[-65.64,-47.24],[-65.99,-48.13],[-67.17,-48.7],[-67.82,-49.87],[-68.73,-50.26],[-69.14,-50.73],[-68.82,-51.77],[-68.15,-52.35],[-68.57,-52.3],[-69.46,-52.29],[-69.94,-52.54],[-70.85,-52.9],[-71.01,-53.83],[-71.43,-53.86],[-72.56,-53.53],[-73.7,-52.84],[-74.95,-52.26],[-75.26,-51.63],[-74.98,-51.04],[-75.48,-50.38],[-75.61,-48.67],[-75.18,-47.71],[-74.13,-46.94],[-75.64,-46.65],[-74.69,-45.76],[-74.35,-44.1],[-73.24,-44.45],[-72.72,-42.38],[-73.39,-42.12],[-73.7,-43.37],[-74.33,-43.22],[-74.02,-41.79],[-73.68,-39.94],[-73.22,-39.26],[-73.51,-38.28],[-73.59,-37.16],[-73.17,-37.12],[-72.55,-35.51],[-71.86,-33.91],[-71.44,-32.42],[-71.67,-30.92],[-71.37,-30.1],[-71.49,-28.86],[-70.91,-27.64],[-70.72,-25.71],[-70.4,-23.63],[-70.09,-21.39],[-70.16,-19.76],[-70.37,-18.35],[-71.38,-17.77],[-71.46,-17.36],[-73.44,-16.36],[-75.24,-15.27],[-76.01,-14.65],[-76.42,-13.82],[-76.26,-13.53],[-77.11,-12.22],[-78.09,-10.38],[-79.04,-8.39],[-79.45,-7.93],[-79.76,-7.19],[-80.54,-6.54],[-81.25,-6.14],[-80.93,-5.69],[-81.41,-4.74],[-81.1,-4.04],[-80.3,-3.4],[-79.77,-2.66],[-79.99,-2.22],[-80.37,-2.69],[-80.97,-2.25],[-80.76,-1.97],[-80.93,-1.06],[-80.58,-0.91],[-80.4,-0.28],[-80.02,0.36],[-80.09,0.77],[-79.54,0.98],[-78.86,1.38],[-78.99,1.69],[-78.62,1.77],[-78.66,2.27],[-78.43,2.63],[-77.93,2.7],[-77.51,3.33],[-77.13,3.85],[-77.5,4.09],[-77.31,4.67],[-77.53,5.58],[-77.32,5.85],[-77.48,6.69],[-77.88,7.22],[-78.21,7.51],[-78.43,8.05],[-78.18,8.32],[-78.44,8.39],[-78.62,8.72],[-79.12,9.0],[-79.56,8.93],[-79.76,8.58],[-80.16,8.33],[-80.38,8.3],[-80.48,8.09],[-80.0,7.55],[-80.28,7.42],[-80.42,7.27],[-80.89,7.22],[-81.06,7.82],[-81.19,7.65],[-81.52,7.71],[-81.72,8.11],[-82.13,8.18],[-82.39,8.29],[-82.82,8.29],[-82.85,8.07],[-82.97,8.23],[-83.51,8.45],[-83.71,8.66],[-83.6,8.83],[-83.63,9.05],[-83.91,9.29],[-84.3,9.49],[-84.65,9.62],[-84.71,9.91],[-84.98,10.09],[-84.91,9.8],[-85.11,9.56],[-85.34,9.83],[-85.66,9.93],[-85.8,10.13],[-85.79,10.44],[-85.66,10.75],[-85.94,10.9],[-85.71,11.09],[-86.06,11.4],[-86.53,11.81],[-86.75,12.14],[-87.17,12.46],[-87.67,12.91],[-87.56,13.06],[-87.39,12.91],[-87.32,12.98],[-87.49,13.3],[-87.79,13.38],[-87.9,13.15],[-88.48,13.16],[-88.84,13.26],[-89.26,13.46],[-89.81,13.52],[-90.1,13.74],[-90.61,13.91],[-91.23,13.93],[-91.69,14.13],[-92.23,14.54],[-93.36,15.62],[-93.88,15.94],[-94.69,16.2],[-95.25,16.13],[-96.05,15.75],[-96.56,15.65],[-97.26,15.92],[-98.01,16.11],[-98.95,16.57],[-99.7,16.71],[-100.83,17.17],[-101.67,17.65],[-101.92,17.92],[-102.48,17.98],[-103.5,18.29],[-103.92,18.75],[-104.99,19.32],[-105.49,19.95],[-105.73,20.43],[-105.4,20.53],[-105.5,20.82],[-105.27,21.08],[-105.27,21.42],[-105.6,21.87],[-105.69,22.27],[-106.03,22.77],[-106.91,23.77],[-107.92,24.55],[-108.4,25.17],[-109.26,25.58],[-109.44,25.82],[-109.29,26.44],[-109.8,26.68],[-110.39,27.16],[-110.64,27.86],[-111.18,27.94],[-111.76,28.47],[-112.23,28.95],[-112.27,29.27],[-112.81,30.02],[-113.16,30.79],[-113.15,31.17],[-113.87,31.57],[-114.21,31.52],[-114.78,31.8],[-114.94,31.39],[-114.77,30.91],[-114.67,30.16],[-114.33,29.75],[-113.59,29.06],[-113.42,28.83],[-113.27,28.75],[-113.14,28.41],[-112.96,28.43],[-112.76,27.78],[-112.46,27.53],[-112.24,27.17],[-111.62,26.66],[-111.28,25.73],[-110.99,25.29],[-110.71,24.83],[-110.66,24.3],[-110.17,24.27],[-109.77,23.81],[-109.41,23.36],[-109.43,23.19],[-109.85,22.82],[-110.03,22.82],[-110.29,23.43],[-110.95,24.0],[-111.67,24.48],[-112.18,24.74],[-112.15,25.47],[-112.3,26.01],[-112.78,26.32],[-113.46,26.77],[-113.6,26.64],[-113.85,26.9],[-114.47,27.14],[-115.06,27.72],[-114.98,27.8],[-114.57,27.74],[-114.2,28.12],[-114.16,28.57],[-114.93,29.28],[-115.52,29.56],[-115.89,30.18],[-116.26,30.84],[-116.72,31.64],[-117.13,32.54],[-117.3,33.05],[-117.94,33.62],[-118.41,33.74],[-118.52,34.03],[-119.08,34.08],[-119.44,34.35],[-120.37,34.45],[-120.62,34.61],[-120.74,35.16],[-121.71,36.16],[-122.55,37.55],[-122.51,37.78],[-122.95,38.11],[-123.73,38.95],[-123.87,39.77],[-124.4,40.31],[-124.18,41.14],[-124.21,42.0],[-124.53,42.77],[-124.14,43.71],[-123.9,45.52],[-124.08,46.86],[-124.4,47.72],[-124.69,48.18],[-124.57,48.38],[-123.12,48.04],[-122.59,47.1],[-122.34,47.36],[-122.5,48.18],[-122.84,49.0],[-122.97,49.0],[-124.91,49.98],[-125.62,50.42],[-127.44,50.83],[-127.99,51.72],[-127.85,52.33],[-129.13,52.76],[-129.31,53.56],[-130.51,54.29],[-130.54,54.8],[-131.09,55.18],[-131.97,55.5],[-132.25,56.37],[-133.54,57.18],[-134.08,58.12],[-135.04,58.19],[-136.63,58.21],[-137.8,58.5],[-139.87,59.54],[-140.83,59.73],[-142.57,60.08],[-143.96,60.0],[-145.93,60.46],[-147.11,60.88],[-148.22,60.67],[-148.02,59.98],[-148.57,59.91],[-149.73,59.71],[-150.61,59.37],[-151.72,59.16],[-151.86,59.75],[-151.41,60.73],[-150.35,61.03],[-150.62,61.28],[-151.9,60.73],[-152.58,60.06],[-154.02,59.35],[-153.29,58.86],[-154.23,58.15],[-155.31,57.73],[-156.31,57.42],[-156.56,56.98],[-158.12,56.46],[-158.43,55.99],[-159.6,55.57],[-160.29,55.64],[-161.22,55.36],[-162.24,55.02],[-163.07,54.69],[-164.79,54.4],[-164.94,54.57],[-163.85,55.04],[-162.87,55.35],[-161.8,55.9],[-160.56,56.01],[-160.07,56.42],[-158.68,57.02],[-158.46,57.22],[-157.72,57.57],[-157.55,58.33],[-157.04,58.92],[-158.19,58.62],[-158.52,58.79],[-159.06,58.42],[-159.71,58.93],[-159.98,58.57],[-160.36,59.07],[-161.35,58.67],[-161.97,58.67],[-162.05,59.27],[-161.87,59.63],[-162.52,59.99],[-163.82,59.8],[-164.66,60.27],[-165.35,60.51],[-165.35,61.07],[-166.12,61.5],[-165.73,62.08],[-164.92,62.63],[-164.56,63.15],[-163.75,63.22],[-163.07,63.06],[-162.26,63.54],[-161.53,63.46],[-160.77,63.77],[-160.96,64.22],[-161.52,64.4],[-160.78,64.79],[-161.39,64.78],[-162.45,64.56],[-162.76,64.34],[-163.55,64.56],[-164.96,64.45],[-166.43,64.69],[-166.84,65.09],[-168.11,65.67],[-166.71,66.09],[-164.47,66.58],[-163.65,66.58],[-163.79,66.08],[-161.68,66.12],[-162.49,66.74],[-163.72,67.12],[-164.43,67.62],[-165.39,68.04],[-166.76,68.36],[-166.2,68.88],[-164.43,68.92],[-163.17,69.37],[-162.93,69.86],[-161.91,70.33],[-160.93,70.45],[-159.04,70.89],[-158.12,70.82],[-156.58,71.36],[-155.07,71.15],[-154.34,70.7],[-153.9,70.89],[-152.21,70.83],[-152.27,70.6],[-150.74,70.43],[-149.72,70.53],[-147.61,70.21],[-145.69,70.12],[-144.92,69.99],[-143.59,70.15],[-142.07,69.85],[-140.99,69.71],[-139.12,69.47],[-137.55,68.99],[-136.5,68.9],[-135.63,69.32],[-134.41,69.63],[-132.93,69.51],[-131.43,69.94],[-129.79,70.19],[-129.11,69.78],[-128.36,70.01],[-128.14,70.48],[-127.45,70.38],[-125.76,69.48],[-124.42,70.16],[-124.29,69.4],[-123.06,69.56],[-122.68,69.86],[-121.47,69.8],[-119.94,69.38],[-117.6,69.01],[-116.23,68.84],[-115.25,68.91],[-113.9,68.4],[-115.3,67.9],[-113.5,67.69],[-110.8,67.81],[-109.95,67.98],[-108.88,67.38],[-107.79,67.89],[-108.81,68.31],[-108.17,68.65],[-106.95,68.7],[-106.15,68.8],[-105.34,68.56],[-104.34,68.02],[-103.22,68.1],[-101.45,67.65],[-99.9,67.81],[-98.44,67.78],[-98.56,68.4],[-97.67,68.58],[-96.12,68.24],[-96.13,67.29],[-95.49,68.09],[-94.68,68.06],[-94.23,69.07],[-95.3,69.69],[-96.47,70.09],[-96.39,71.19],[-95.21,71.92],[-93.89,71.76],[-92.88,71.32],[-91.52,70.19],[-92.41,69.7],[-90.55,69.5]]]},{"type":"Polygon","coordinates":[[[-114.17,73.12],[-114.67,72.65],[-112.44,72.96],[-111.05,72.45],[-109.92,72.96],[-109.01,72.63],[-108.19,71.65],[-107.69,72.07],[-108.4,73.09],[-107.52,73.24],[-106.52,73.08],[-105.4,72.67],[-104.77,71.7],[-104.46,70.99],[-102.79,70.5],[-100.98,70.02],[-101.09,69.58],[-102.73,69.5],[-102.09,69.12],[-102.43,68.75],[-104.24,68.91],[-105.96,69.18],[-107.12,69.12],[-109,68.78],[-111.97,68.6],[-113.31,68.54],[-113.85,69.01],[-115.22,69.28],[-116.11,69.17],[-117.34,69.96],[-116.67,70.07],[-115.13,70.24],[-113.72,70.19],[-112.42,70.37],[-114.35,70.6],[-116.49,70.52],[-117.9,70.54],[-118.43,70.91],[-116.11,71.31],[-117.66,71.3],[-119.4,71.56],[-118.56,72.31],[-117.87,72.71],[-115.19,73.31],[-114.17,73.12]]]},{"type":"Polygon","coordinates":[[[-104.5,73.42],[-105.38,72.76],[-106.94,73.46],[-106.6,73.6],[-105.26,73.64],[-104.5,73.42]]]},{"type":"Polygon","coordinates":[[[-76.34,73.1],[-76.25,72.83],[-77.31,72.86],[-78.39,72.88],[-79.49,72.74],[-79.78,72.8],[-80.88,73.33],[-80.83,73.69],[-80.35,73.76],[-78.06,73.65],[-76.34,73.1]]]},{"type":"Polygon","coordinates":[[[-86.56,73.16],[-85.77,72.53],[-84.85,73.34],[-82.32,73.75],[-80.6,72.72],[-80.75,72.06],[-78.77,72.35],[-77.82,72.75],[-75.61,72.24],[-74.23,71.77],[-74.1,71.33],[-72.24,71.56],[-71.2,70.92],[-68.79,70.53],[-67.91,70.12],[-66.97,69.19],[-68.81,68.72],[-66.45,68.07],[-64.86,67.85],[-63.42,66.93],[-61.85,66.86],[-62.16,66.16],[-63.92,65.0],[-65.15,65.43],[-66.72,66.39],[-68.02,66.26],[-68.14,65.69],[-67.09,65.11],[-65.73,64.65],[-65.32,64.38],[-64.67,63.39],[-65.01,62.67],[-66.28,62.95],[-68.78,63.75],[-67.37,62.88],[-66.33,62.28],[-66.17,61.93],[-68.88,62.33],[-71.02,62.91],[-72.24,63.4],[-71.89,63.68],[-73.38,64.19],[-74.83,64.68],[-74.82,64.39],[-77.71,64.23],[-78.56,64.57],[-77.9,65.31],[-76.02,65.33],[-73.96,65.45],[-74.29,65.81],[-73.94,66.31],[-72.65,67.28],[-72.93,67.73],[-73.31,68.07],[-74.84,68.55],[-76.87,68.89],[-76.23,69.15],[-77.29,69.77],[-78.17,69.83],[-78.96,70.17],[-79.49,69.87],[-81.31,69.74],[-84.94,69.97],[-87.06,70.26],[-88.68,70.41],[-89.51,70.76],[-88.47,71.22],[-89.89,71.22],[-90.21,72.24],[-89.44,73.13],[-88.41,73.54],[-85.83,73.8],[-86.56,73.16]]]},{"type":"Polygon","coordinates":[[[-100.36,73.84],[-99.16,73.63],[-97.38,73.76],[-97.12,73.47],[-98.05,72.99],[-96.54,72.56],[-96.72,71.66],[-98.36,71.27],[-99.32,71.36],[-100.01,71.74],[-102.5,72.51],[-102.48,72.83],[-100.44,72.71],[-101.54,73.36],[-100.36,73.84]]]},{"type":"Polygon","coordinates":[[[143.6,73.21],[142.09,73.21],[140.04,73.32],[139.86,73.37],[140.81,73.77],[142.06,73.86],[143.48,73.48],[143.6,73.21]]]},{"type":"Polygon","coordinates":[[[-93.2,72.77],[-94.27,72.02],[-95.41,72.06],[-96.03,72.94],[-96.02,73.44],[-95.5,73.86],[-94.5,74.13],[-92.42,74.1],[-90.51,73.86],[-92.0,72.97],[-93.2,72.77]]]},{"type":"Polygon","coordinates":[[[-120.46,71.4],[-123.09,70.9],[-123.62,71.34],[-125.93,71.87],[-125.59,72.19],[-124.81,73.02],[-123.94,73.68],[-124.92,74.29],[-121.54,74.45],[-120.11,74.24],[-117.56,74.19],[-116.58,73.9],[-115.51,73.48],[-116.77,73.22],[-119.22,72.52],[-120.46,71.82],[-120.46,71.4]]]},{"type":"Polygon","coordinates":[[[150.73,75.08],[149.58,74.69],[147.98,74.78],[146.12,75.17],[146.36,75.5],[148.22,75.35],[150.73,75.08]]]},{"type":"Polygon","coordinates":[[[-93.61,74.98],[-94.16,74.59],[-95.61,74.67],[-96.82,74.93],[-96.29,75.38],[-94.85,75.65],[-93.98,75.3],[-93.61,74.98]]]},{"type":"Polygon","coordinates":[[[145.09,75.56],[144.3,74.82],[140.61,74.85],[138.96,74.61],[136.97,75.26],[137.51,75.95],[138.83,76.14],[141.47,76.09],[145.09,75.56]]]},{"type":"Polygon","coordinates":[[[-98.5,76.72],[-97.74,76.26],[-97.7,75.74],[-98.16,75],[-99.81,74.9],[-100.88,75.06],[-100.86,75.64],[-102.5,75.56],[-102.57,76.34],[-101.49,76.31],[-99.98,76.65],[-98.58,76.59],[-98.5,76.72]]]},{"type":"Polygon","coordinates":[[[-108.21,76.2],[-107.82,75.85],[-106.93,76.01],[-105.88,75.97],[-105.7,75.48],[-106.31,75.01],[-109.7,74.85],[-112.22,74.42],[-113.74,74.39],[-113.87,74.72],[-111.79,75.16],[-116.31,75.04],[-117.71,75.22],[-116.35,76.2],[-115.4,76.48],[-112.59,76.14],[-110.81,75.55],[-109.07,75.47],[-110.5,76.43],[-109.58,76.79],[-108.55,76.68],[-108.21,76.2]]]},{"type":"Polygon","coordinates":[[[57.54,70.72],[56.94,70.63],[53.68,70.76],[53.41,71.21],[51.6,71.47],[51.46,72.01],[52.48,72.23],[52.44,72.77],[54.43,73.63],[53.51,73.75],[55.9,74.63],[55.63,75.08],[57.87,75.61],[61.17,76.25],[64.5,76.44],[66.21,76.81],[68.16,76.94],[68.85,76.54],[68.18,76.23],[64.64,75.74],[61.58,75.26],[58.48,74.31],[56.99,73.33],[55.42,72.37],[55.62,71.54],[57.54,70.72]]]},{"type":"Polygon","coordinates":[[[-94.68,77.1],[-93.57,76.78],[-91.61,76.78],[-90.74,76.45],[-90.97,76.07],[-89.82,75.85],[-89.19,75.61],[-87.84,75.57],[-86.38,75.48],[-84.79,75.7],[-82.75,75.78],[-81.13,75.71],[-80.06,75.34],[-79.83,74.92],[-80.46,74.66],[-81.95,74.44],[-83.23,74.56],[-86.1,74.41],[-88.15,74.39],[-89.76,74.52],[-92.42,74.84],[-92.77,75.39],[-92.89,75.88],[-93.89,76.32],[-95.96,76.44],[-97.12,76.75],[-96.75,77.16],[-94.68,77.1]]]},{"type":"Polygon","coordinates":[[[-116.2,77.65],[-116.34,76.88],[-117.11,76.53],[-118.04,76.48],[-119.9,76.05],[-121.5,75.9],[-122.85,76.12],[-122.85,76.12],[-121.16,76.86],[-119.1,77.51],[-117.57,77.5],[-116.2,77.65]]]},{"type":"Polygon","coordinates":[[[106.97,76.97],[107.24,76.48],[108.15,76.72],[111.08,76.71],[113.33,76.22],[114.13,75.85],[113.89,75.33],[112.78,75.03],[110.15,74.48],[109.4,74.18],[110.64,74.04],[112.12,73.79],[113.02,73.98],[113.53,73.34],[113.97,73.59],[115.57,73.75],[118.78,73.59],[119.02,73.12],[123.2,72.97],[123.26,73.74],[125.38,73.56],[126.98,73.57],[128.59,73.04],[129.05,72.4],[128.46,71.98],[129.72,71.19],[131.29,70.79],[132.25,71.84],[133.86,71.39],[135.56,71.66],[137.5,71.35],[138.23,71.63],[139.87,71.49],[139.15,72.42],[140.47,72.85],[149.5,72.2],[150.35,71.61],[152.97,70.84],[157.01,71.03],[159.0,70.87],[159.83,70.45],[159.71,69.72],[160.94,69.44],[162.28,69.64],[164.05,69.67],[165.94,69.47],[167.84,69.58],[169.58,68.69],[170.82,69.01],[170.01,69.65],[170.45,70.1],[173.64,69.82],[175.72,69.88],[178.6,69.4],[180,68.96],[180,64.98],[179.99,64.97],[178.71,64.53],[177.41,64.61],[178.31,64.08],[178.91,63.25],[179.37,62.98],[179.49,62.57],[179.23,62.3],[177.36,62.52],[174.57,61.77],[173.68,61.65],[172.15,60.95],[170.7,60.34],[170.33,59.88],[168.9,60.57],[166.3,59.79],[165.84,60.16],[164.88,59.73],[163.54,59.87],[163.22,59.21],[162.02,58.24],[162.05,57.84],[163.19,57.62],[163.06,56.16],[162.13,56.12],[161.7,55.29],[162.12,54.86],[160.37,54.34],[160.02,53.2],[158.53,52.96],[158.23,51.94],[156.79,51.01],[156.42,51.7],[155.99,53.16],[155.43,55.38],[155.91,56.77],[156.76,57.36],[156.81,57.83],[158.36,58.06],[160.15,59.31],[161.87,60.34],[163.67,61.14],[164.47,62.55],[163.26,62.47],[162.66,61.64],[160.12,60.54],[159.3,61.77],[156.72,61.43],[154.22,59.76],[155.04,59.15],[152.81,58.88],[151.27,58.78],[151.34,59.5],[149.78,59.66],[148.54,59.16],[145.49,59.34],[142.2,59.04],[138.96,57.09],[135.13,54.73],[136.7,54.6],[137.19,53.98],[138.16,53.76],[138.8,54.25],[139.9,54.19],[141.35,53.09],[141.38,52.24],[140.6,51.24],[140.51,50.05],[140.06,48.45],[138.55,47.0],[138.22,46.31],[136.86,45.14],[135.52,43.99],[134.87,43.4],[133.54,42.81],[132.91,42.8],[132.28,43.28],[130.94,42.55],[130.78,42.22],[130.4,42.28],[129.97,41.94],[129.67,41.6],[129.71,40.88],[129.19,40.66],[129.01,40.49],[128.63,40.19],[127.97,40.03],[127.53,39.76],[127.5,39.32],[127.39,39.21],[127.78,39.05],[128.35,38.61],[129.21,37.43],[129.46,36.78],[129.47,35.63],[129.09,35.08],[128.19,34.89],[127.39,34.48],[126.49,34.39],[126.37,34.93],[126.56,35.68],[126.12,36.73],[126.86,36.89],[126.17,37.75],[125.69,37.94],[125.57,37.75],[125.28,37.67],[125.24,37.86],[124.98,37.95],[124.71,38.11],[124.99,38.55],[125.22,38.67],[125.13,38.85],[125.39,39.39],[125.32,39.55],[124.74,39.66],[124.27,39.93],[122.87,39.64],[122.13,39.17],[121.05,38.9],[121.59,39.36],[121.38,39.75],[122.17,40.42],[121.64,40.95],[120.77,40.59],[119.64,39.9],[119.02,39.25],[118.04,39.2],[117.53,38.74],[118.06,38.06],[118.88,37.9],[118.91,37.45],[119.7,37.16],[120.82,37.87],[121.71,37.48],[122.36,37.45],[122.52,36.93],[121.1,36.65],[120.64,36.11],[119.66,35.61],[119.15,34.91],[120.23,34.36],[120.62,33.38],[121.23,32.46],[121.91,31.69],[121.89,30.95],[121.26,30.68],[121.5,30.14],[122.09,29.83],[121.94,29.02],[121.68,28.23],[121.13,28.14],[120.4,27.05],[119.59,25.74],[118.66,24.55],[117.28,23.62],[115.89,22.78],[114.76,22.67],[114.15,22.22],[113.81,22.55],[113.24,22.05],[111.84,21.55],[110.79,21.4],[110.44,20.34],[109.89,20.28],[109.63,21.01],[109.86,21.4],[108.52,21.72],[108.05,21.55],[106.72,20.7],[105.88,19.75],[105.66,19.06],[106.43,18.0],[107.36,16.7],[108.27,16.08],[108.88,15.28],[109.34,13.43],[109.2,11.67],[108.37,11.01],[107.22,10.36],[106.41,9.53],[105.16,8.6],[104.8,9.24],[105.08,9.92],[104.33,10.49],[103.5,10.63],[103.09,11.15],[102.59,12.19],[101.69,12.65],[100.83,12.63],[100.98,13.41],[100.1,13.41],[100.02,12.31],[99.48,10.85],[99.15,9.96],[99.22,9.24],[99.87,9.21],[100.28,8.3],[100.46,7.43],[101.02,6.86],[101.62,6.74],[102.14,6.22],[102.37,6.13],[102.96,5.52],[103.38,4.86],[103.44,4.18],[103.33,3.73],[103.43,3.38],[103.5,2.79],[103.85,2.52],[104.25,1.63],[104.23,1.29],[103.52,1.23],[102.57,1.97],[101.39,2.76],[101.27,3.27],[100.7,3.94],[100.56,4.77],[100.2,5.31],[100.31,6.04],[100.09,6.46],[99.69,6.85],[99.52,7.34],[98.99,7.91],[98.5,8.38],[98.34,7.79],[98.15,8.35],[98.26,8.97],[98.55,9.93],[98.46,10.68],[98.76,11.44],[98.43,12.03],[98.51,13.12],[98.1,13.64],[97.78,14.84],[97.6,16.1],[97.16,16.93],[96.51,16.43],[95.37,15.71],[94.81,15.8],[94.19,16.04],[94.53,17.28],[94.32,18.21],[93.54,19.37],[93.66,19.73],[93.08,19.86],[92.37,20.67],[92.08,21.19],[92.03,21.7],[91.83,22.18],[91.42,22.77],[90.5,22.81],[90.59,22.39],[90.27,21.84],[89.85,22.04],[89.7,21.86],[89.42,21.97],[89.03,22.06],[88.89,21.69],[88.21,21.7],[86.98,21.5],[87.03,20.74],[86.5,20.15],[85.06,19.48],[83.94,18.3],[83.19,17.67],[82.19,17.02],[82.19,16.56],[81.69,16.31],[80.79,15.95],[80.32,15.9],[80.03,15.14],[80.23,13.84],[80.29,13.01],[79.86,12.06],[79.86,10.36],[79.34,10.31],[78.89,9.55],[79.19,9.22],[78.28,8.93],[77.94,8.25],[77.54,7.97],[76.59,8.9],[76.13,10.3],[75.75,11.31],[75.4,11.78],[74.86,12.74],[74.62,13.99],[74.44,14.62],[73.53,15.99],[73.12,17.93],[72.82,19.21],[72.82,20.42],[72.63,21.36],[71.18,20.76],[70.47,20.88],[69.16,22.09],[69.64,22.45],[69.35,22.84],[68.18,23.69],[67.44,23.94],[67.15,24.66],[66.37,25.43],[64.53,25.24],[62.91,25.22],[61.5,25.08],[59.62,25.38],[58.53,25.61],[57.4,25.74],[56.97,26.97],[56.49,27.14],[55.72,26.96],[54.72,26.48],[53.49,26.81],[52.48,27.58],[51.52,27.87],[50.85,28.81],[50.12,30.15],[49.58,29.99],[48.94,30.32],[48.57,29.93],[47.97,29.98],[48.18,29.53],[48.09,29.31],[48.42,28.55],[48.81,27.69],[49.3,27.46],[49.47,27.11],[50.15,26.69],[50.21,26.28],[50.11,25.94],[50.24,25.61],[50.53,25.33],[50.66,25.0],[50.81,24.75],[50.74,25.48],[51.01,26.01],[51.29,26.11],[51.59,25.8],[51.61,25.22],[51.39,24.63],[51.58,24.25],[51.76,24.29],[51.79,24.02],[52.58,24.18],[53.4,24.15],[54.01,24.12],[54.69,24.8],[55.44,25.44],[56.07,26.06],[56.36,26.4],[56.49,26.31],[56.39,25.9],[56.26,25.71],[56.4,24.92],[56.85,24.24],[57.4,23.88],[58.14,23.75],[58.73,23.57],[59.18,22.99],[59.45,22.66],[59.81,22.53],[59.81,22.31],[59.44,21.71],[59.28,21.43],[58.86,21.11],[58.49,20.43],[58.03,20.48],[57.83,20.24],[57.67,19.74],[57.79,19.07],[57.69,18.94],[57.23,18.95],[56.61,18.57],[56.51,18.09],[56.28,17.88],[55.66,17.88],[55.27,17.63],[55.27,17.23],[54.79,16.95],[54.24,17.05],[53.57,16.71],[53.11,16.65],[52.39,16.38],[52.19,15.94],[52.17,15.6],[51.17,15.18],[49.57,14.71],[48.68,14.0],[48.24,13.95],[47.94,14.01],[47.35,13.59],[46.72,13.4],[45.88,13.35],[45.63,13.29],[45.41,13.03],[45.14,12.95],[44.99,12.7],[44.49,12.72],[44.18,12.59],[43.48,12.64],[43.22,13.22],[43.25,13.77],[43.09,14.06],[42.89,14.8],[42.6,15.21],[42.81,15.26],[42.7,15.72],[42.82,15.91],[42.78,16.35],[42.65,16.77],[42.35,17.08],[42.27,17.47],[41.75,17.83],[41.22,18.67],[40.94,19.49],[40.25,20.17],[39.8,20.34],[39.14,21.29],[39.02,21.99],[39.07,22.58],[38.49,23.69],[38.02,24.08],[37.48,24.29],[37.15,24.86],[37.21,25.08],[36.93,25.6],[36.64,25.83],[36.25,26.57],[35.64,27.38],[35.13,28.06],[34.63,28.06],[34.79,28.61],[34.83,28.96],[34.96,29.36],[34.92,29.5],[34.64,29.1],[34.43,28.34],[34.15,27.82],[33.92,27.65],[33.59,27.97],[33.14,28.42],[32.42,29.85],[32.32,29.76],[32.73,28.71],[33.35,27.7],[34.1,26.14],[34.47,25.6],[34.8,25.03],[35.69,23.93],[35.49,23.75],[35.53,23.1],[36.69,22.2],[36.87,22.0],[37.19,21.02],[36.97,20.84],[37.11,19.81],[37.48,18.61],[37.86,18.37],[38.41,18.0],[38.99,16.84],[39.27,15.92],[39.81,15.44],[41.18,14.49],[41.73,13.92],[42.28,13.34],[42.59,13.0],[43.08,12.7],[43.32,12.39],[43.29,11.97],[42.72,11.74],[43.15,11.46],[43.47,11.28],[43.67,10.86],[44.12,10.45],[44.61,10.44],[45.56,10.7],[46.65,10.82],[47.53,11.13],[48.02,11.19],[48.38,11.38],[48.95,11.41],[49.27,11.43],[49.73,11.58],[50.26,11.68],[50.73,12.02],[51.11,12.02],[51.13,11.75],[51.04,11.17],[51.05,10.64],[50.83,10.28],[50.55,9.2],[50.07,8.08],[49.45,6.8],[48.59,5.34],[47.74,4.22],[46.56,2.86],[45.56,2.05],[44.07,1.05],[43.14,0.29],[42.04,-0.92],[41.81,-1.45],[41.59,-1.68],[40.88,-2.08],[40.64,-2.5],[40.26,-2.57],[40.12,-3.28],[39.8,-3.68],[39.6,-4.35],[39.2,-4.68],[38.74,-5.91],[38.8,-6.48],[39.44,-6.84],[39.47,-7.1],[39.19,-7.7],[39.25,-8.01],[39.19,-8.49],[39.54,-9.11],[39.95,-10.1],[40.32,-10.32],[40.48,-10.77],[40.44,-11.76],[40.56,-12.64],[40.6,-14.2],[40.78,-14.69],[40.48,-15.41],[40.09,-16.1],[39.45,-16.72],[38.54,-17.1],[37.41,-17.59],[36.28,-18.66],[35.9,-18.84],[35.2,-19.55],[34.79,-19.78],[34.7,-20.5],[35.18,-21.25],[35.37,-21.84],[35.39,-22.14],[35.56,-22.09],[35.53,-23.07],[35.37,-23.54],[35.61,-23.71],[35.46,-24.12],[35.04,-24.48],[34.22,-24.82],[33.01,-25.36],[32.57,-25.73],[32.66,-26.15],[32.92,-26.22],[32.83,-26.74],[32.58,-27.47],[32.46,-28.3],[32.2,-28.75],[31.52,-29.26],[31.33,-29.4],[30.9,-29.91],[30.62,-30.42],[30.06,-31.14],[28.93,-32.17],[28.22,-32.77],[27.46,-33.23],[26.42,-33.61],[25.91,-33.67],[25.78,-33.94],[25.17,-33.8],[24.68,-33.99],[23.59,-33.79],[22.99,-33.92],[22.57,-33.86],[21.54,-34.26],[20.69,-34.42],[20.07,-34.8],[19.62,-34.82],[19.19,-34.46],[18.86,-34.44],[18.42,-34.0],[18.38,-34.14],[18.24,-33.87],[18.25,-33.28],[17.93,-32.61],[18.25,-32.43],[18.22,-31.66],[17.57,-30.73],[17.06,-29.88],[17.06,-29.88],[16.35,-28.58],[15.6,-27.82],[15.21,-27.09],[14.99,-26.12],[14.74,-25.39],[14.41,-23.85],[14.39,-22.66],[14.26,-22.11],[13.87,-21.7],[13.35,-20.87],[12.83,-19.67],[12.61,-19.05],[11.79,-18.07],[11.73,-17.3],[11.64,-16.67],[11.78,-15.79],[12.12,-14.88],[12.18,-14.45],[12.5,-13.55],[12.74,-13.14],[13.31,-12.48],[13.63,-12.04],[13.74,-11.3],[13.69,-10.73],[13.39,-10.37],[13.12,-9.77],[12.88,-9.17],[12.93,-8.96],[13.24,-8.56],[12.93,-7.6],[12.73,-6.93],[12.23,-6.29],[12.32,-6.1],[12.18,-5.79],[11.92,-5.04],[11.09,-3.98],[10.07,-2.97],[9.41,-2.14],[8.8,-1.11],[8.83,-0.78],[9.05,-0.46],[9.29,0.27],[9.49,1.01],[9.31,1.16],[9.65,2.28],[9.8,3.07],[9.4,3.73],[8.95,3.9],[8.74,4.35],[8.49,4.5],[8.5,4.77],[7.46,4.41],[7.08,4.46],[6.7,4.24],[5.9,4.26],[5.36,4.89],[5.03,5.61],[4.33,6.27],[3.57,6.26],[2.69,6.26],[1.87,6.14],[1.06,5.93],[-0.51,5.34],[-1.06,5.0],[-1.96,4.71],[-2.86,4.99],[-3.31,4.98],[-4.01,5.18],[-4.65,5.17],[-5.83,4.99],[-6.53,4.71],[-7.52,4.34],[-7.71,4.36],[-7.97,4.36],[-9.0,4.83],[-9.91,5.59],[-10.77,6.14],[-11.44,6.79],[-11.71,6.86],[-12.43,7.26],[-12.95,7.8],[-13.12,8.16],[-13.25,8.9],[-13.69,9.49],[-14.07,9.89],[-14.33,10.02],[-14.58,10.21],[-14.69,10.66],[-14.84,10.88],[-15.13,11.04],[-15.66,11.46],[-16.09,11.52],[-16.31,11.81],[-16.31,11.96],[-16.61,12.17],[-16.68,12.38],[-16.84,13.15],[-16.71,13.6],[-17.13,14.37],[-17.62,14.73],[-17.19,14.92],[-16.7,15.62],[-16.46,16.14],[-16.55,16.67],[-16.27,17.17],[-16.15,18.11],[-16.26,19.1],[-16.38,19.59],[-16.28,20.09],[-16.54,20.57],[-17.06,21.0],[-17.02,21.42],[-16.97,21.89],[-16.59,22.16],[-16.26,22.68],[-16.33,23.02],[-15.98,23.72],[-15.43,24.36],[-15.09,24.52],[-14.82,25.1],[-14.8,25.64],[-14.44,26.25],[-13.77,26.62],[-13.14,27.64],[-12.62,28.04],[-11.69,28.15],[-10.9,28.83],[-10.4,29.1],[-9.56,29.93],[-9.81,31.18],[-9.43,32.04],[-9.3,32.56],[-8.66,33.24],[-7.65,33.7],[-6.91,34.11],[-6.24,35.15],[-5.93,35.76],[-5.19,35.76],[-4.59,35.33],[-3.64,35.4],[-2.6,35.18],[-2.17,35.17],[-1.21,35.71],[-0.13,35.89],[0.5,36.3],[1.47,36.61],[3.16,36.78],[4.82,36.87],[5.32,36.72],[6.26,37.11],[7.33,37.12],[7.74,36.89],[8.42,36.95],[9.51,37.35],[10.21,37.23],[10.18,36.72],[11.03,37.09],[11.1,36.9],[10.6,36.41],[10.59,35.95],[10.94,35.7],[10.81,34.83],[10.15,34.33],[10.34,33.79],[10.86,33.77],[11.11,33.29],[11.49,33.14],[12.66,32.79],[13.08,32.88],[13.92,32.71],[15.25,32.27],[15.71,31.38],[16.61,31.18],[18.02,30.76],[19.09,30.27],[19.57,30.53],[20.05,30.99],[19.82,31.75],[20.13,32.24],[20.85,32.71],[21.54,32.84],[22.9,32.64],[23.24,32.19],[23.61,32.19],[23.93,32.02],[24.92,31.9],[25.16,31.57],[26.5,31.59],[27.46,31.32],[28.45,31.03],[28.91,30.87],[29.68,31.19],[30.1,31.47],[30.98,31.56],[31.69,31.43],[31.96,30.93],[32.19,31.26],[32.99,31.02],[33.77,30.97],[34.27,31.22],[34.56,31.55],[34.49,31.61],[34.75,32.07],[34.96,32.83],[35.1,33.08],[35.13,33.09],[35.48,33.91],[35.98,34.61],[36.0,34.64],[35.91,35.41],[36.15,35.82],[35.78,36.28],[36.16,36.65],[35.55,36.57],[34.71,36.8],[34.03,36.22],[32.51,36.11],[31.7,36.64],[30.62,36.68],[30.39,36.26],[29.7,36.14],[28.73,36.68],[27.64,36.66],[27.05,37.65],[26.32,38.21],[26.8,38.99],[26.17,39.46],[27.28,40.42],[28.82,40.46],[29.24,41.22],[31.15,41.09],[32.35,41.74],[33.51,42.02],[35.17,42.04],[36.91,41.34],[38.35,40.95],[39.51,41.1],[40.37,41.01],[41.55,41.54],[41.7,41.96],[41.45,42.65],[40.88,43.01],[40.32,43.13],[39.96,43.44],[38.68,44.28],[37.54,44.66],[36.68,45.24],[37.4,45.4],[38.23,46.24],[37.67,46.64],[39.15,47.04],[39.12,47.26],[38.22,47.1],[37.43,47.02],[36.76,46.7],[35.82,46.65],[34.96,46.27],[35.02,45.65],[35.51,45.41],[36.53,45.47],[36.33,45.11],[35.24,44.94],[33.88,44.36],[33.33,44.56],[33.55,45.03],[32.45,45.33],[32.63,45.52],[33.59,45.85],[33.3,46.08],[31.74,46.33],[31.68,46.71],[30.75,46.58],[30.38,46.03],[29.6,45.29],[29.63,45.04],[29.14,44.82],[28.84,44.91],[28.56,43.71],[28.04,43.29],[27.67,42.58],[28.0,42.01],[28.12,41.62],[28.99,41.3],[28.81,41.05],[27.62,41.0],[27.19,40.69],[26.36,40.15],[26.04,40.62],[26.06,40.82],[25.45,40.85],[24.93,40.95],[23.71,40.69],[24.41,40.13],[23.9,39.96],[23.34,39.96],[22.81,40.48],[22.63,40.26],[22.85,39.66],[23.35,39.19],[22.97,38.97],[23.53,38.51],[24.03,38.22],[24.04,37.66],[23.12,37.92],[23.41,37.41],[22.78,37.31],[23.15,36.42],[22.49,36.41],[21.67,36.85],[21.3,37.65],[21.12,38.31],[20.73,38.77],[20.22,39.34],[20.15,39.63],[19.98,39.7],[19.96,39.92],[19.41,40.25],[19.32,40.73],[19.4,41.41],[19.54,41.72],[19.37,41.88],[19.16,41.96],[18.88,42.28],[18.45,42.48],[17.51,42.85],[16.93,43.21],[16.02,43.51],[15.17,44.24],[15.38,44.32],[14.92,44.74],[14.9,45.08],[14.26,45.23],[13.95,44.8],[13.66,45.14],[13.68,45.48],[13.72,45.5],[13.94,45.59],[13.14,45.74],[12.33,45.38],[12.38,44.89],[12.26,44.6],[12.59,44.09],[13.53,43.59],[14.03,42.76],[15.14,41.96],[15.93,41.96],[16.17,41.74],[15.89,41.54],[16.79,41.18],[17.52,40.88],[18.38,40.36],[18.48,40.17],[18.29,39.81],[17.74,40.28],[16.87,40.44],[16.45,39.8],[17.17,39.42],[17.05,38.9],[16.64,38.84],[16.1,37.99],[15.68,37.91],[15.69,38.21],[15.89,38.75],[16.11,38.96],[15.72,39.54],[15.41,40.05],[15.0,40.17],[14.7,40.6],[14.06,40.79],[13.63,41.19],[12.89,41.25],[12.11,41.7],[11.19,42.36],[10.51,42.93],[10.2,43.92],[9.7,44.04],[8.89,44.37],[8.43,44.23],[7.85,43.77],[7.44,43.69],[6.53,43.13],[4.56,43.4],[3.1,43.08],[2.99,42.47],[3.04,41.89],[2.09,41.23],[0.81,41.01],[0.72,40.68],[0.11,40.12],[-0.28,39.31],[0.11,38.74],[-0.47,38.29],[-0.68,37.64],[-1.44,37.44],[-2.15,36.67],[-3.42,36.66],[-4.37,36.68],[-5.0,36.32],[-5.38,35.95],[-5.87,36.03],[-6.24,36.37],[-6.52,36.94],[-7.45,37.1],[-7.86,36.84],[-8.38,36.98],[-8.9,36.87],[-8.75,37.65],[-8.84,38.27],[-9.29,38.36],[-9.53,38.74],[-9.45,39.39],[-9.05,39.76],[-8.98,40.16],[-8.77,40.76],[-8.79,41.18],[-8.99,41.54],[-9.03,41.88],[-8.98,42.59],[-9.39,43.03],[-7.98,43.75],[-6.75,43.57],[-5.41,43.57],[-4.35,43.4],[-3.52,43.46],[-1.9,43.42],[-1.38,44.02],[-1.19,46.01],[-2.23,47.06],[-2.96,47.57],[-4.49,47.96],[-4.59,48.68],[-3.3,48.9],[-1.62,48.64],[-1.93,49.78],[-0.99,49.35],[1.34,50.13],[1.64,50.95],[2.51,51.15],[3.32,51.35],[3.83,51.62],[4.71,53.09],[6.07,53.51],[6.91,53.48],[7.1,53.69],[7.94,53.75],[8.12,53.53],[8.8,54.02],[8.57,54.4],[8.53,54.96],[8.12,55.52],[8.09,56.54],[8.26,56.81],[8.54,57.11],[9.42,57.17],[9.78,57.45],[10.58,57.73],[10.55,57.22],[10.25,56.89],[10.37,56.61],[10.91,56.46],[10.67,56.08],[10.37,56.19],[9.65,55.47],[9.92,54.98],[9.94,54.6],[10.95,54.36],[10.94,54.01],[11.96,54.2],[12.52,54.47],[13.65,54.08],[14.12,53.76],[14.8,54.05],[16.36,54.51],[17.62,54.85],[18.62,54.68],[18.7,54.44],[19.66,54.43],[19.89,54.87],[21.27,55.19],[21.06,56.03],[21.09,56.78],[21.58,57.41],[22.52,57.75],[23.32,57.01],[24.12,57.03],[24.31,57.79],[24.43,58.38],[24.06,58.26],[23.43,58.61],[23.34,59.19],[24.6,59.47],[25.86,59.61],[26.95,59.45],[27.98,59.48],[29.12,60.03],[28.07,60.5],[26.26,60.42],[24.5,60.06],[22.87,59.85],[22.29,60.39],[21.32,60.72],[21.54,61.71],[21.06,62.61],[21.54,63.19],[22.44,63.82],[24.73,64.9],[25.4,65.11],[25.29,65.53],[23.9,66.01],[22.18,65.72],[21.21,65.03],[21.37,64.41],[19.78,63.61],[17.85,62.75],[17.12,61.34],[17.83,60.64],[18.79,60.08],[17.87,58.95],[16.83,58.72],[16.45,57.04],[15.88,56.1],[14.67,56.2],[14.1,55.41],[12.94,55.36],[12.63,56.31],[11.79,57.44],[11.03,58.86],[10.36,59.47],[8.38,58.31],[7.05,58.08],[5.67,58.59],[5.31,59.66],[4.99,61.97],[5.91,62.61],[8.55,63.45],[10.53,64.49],[12.36,65.88],[14.76,67.81],[16.44,68.56],[19.18,69.82],[21.38,70.26],[23.02,70.2],[24.55,71.03],[26.37,70.99],[28.17,71.19],[31.29,70.45],[30.01,70.19],[31.1,69.56],[32.13,69.91],[33.78,69.3],[36.51,69.06],[40.29,67.93],[41.06,67.46],[41.13,66.79],[40.02,66.27],[38.38,66.0],[33.92,66.76],[33.18,66.63],[34.81,65.9],[34.94,64.41],[36.23,64.11],[37.01,63.85],[37.14,64.33],[36.52,64.78],[37.18,65.14],[39.59,64.52],[40.44,64.76],[39.76,65.5],[42.09,66.48],[43.02,66.42],[43.95,66.07],[44.53,66.76],[43.7,67.35],[44.19,67.95],[43.45,68.57],[46.25,68.25],[46.82,67.69],[45.56,67.57],[45.56,67.01],[46.35,66.67],[47.89,66.88],[48.14,67.52],[50.23,68.0],[53.72,68.86],[54.47,68.81],[53.49,68.2],[54.73,68.1],[55.44,68.44],[57.32,68.47],[58.8,68.88],[59.94,68.28],[61.08,68.94],[60.03,69.52],[60.55,69.85],[63.5,69.55],[64.89,69.23],[68.51,68.09],[69.18,68.62],[68.16,69.14],[68.14,69.36],[66.93,69.45],[67.26,69.93],[66.72,70.71],[66.69,71.03],[68.54,71.93],[69.2,72.84],[69.94,73.04],[72.59,72.78],[72.8,72.22],[71.85,71.41],[72.47,71.09],[72.79,70.39],[72.56,69.02],[73.67,68.41],[73.24,67.74],[71.28,66.32],[72.42,66.17],[72.82,66.53],[73.92,66.79],[74.19,67.28],[75.05,67.76],[74.47,68.33],[74.94,68.99],[73.84,69.07],[73.6,69.63],[74.4,70.63],[73.1,71.45],[74.89,72.12],[74.66,72.83],[75.16,72.86],[75.68,72.3],[75.29,71.34],[76.36,71.15],[75.9,71.87],[77.58,72.27],[79.65,72.32],[81.5,71.75],[80.61,72.58],[80.51,73.65],[82.25,73.85],[84.66,73.81],[86.82,73.94],[86.01,74.46],[87.17,75.12],[88.32,75.14],[90.26,75.64],[92.9,75.77],[93.23,76.05],[95.86,76.14],[96.68,75.92],[98.92,76.45],[100.76,76.43],[101.04,76.86],[101.99,77.29],[104.35,77.7],[106.07,77.37],[104.71,77.13],[106.97,76.97]],[[49.11,41.28],[49.62,40.57],[50.08,40.53],[50.39,40.26],[49.57,40.18],[49.4,39.4],[49.22,39.05],[48.86,38.82],[48.88,38.32],[49.2,37.58],[50.15,37.37],[50.84,36.87],[52.26,36.7],[53.83,36.97],[53.92,37.2],[53.74,37.91],[53.88,38.95],[53.1,39.29],[53.36,39.98],[52.69,40.03],[52.92,40.88],[53.86,40.63],[54.74,40.95],[54.01,41.55],[53.72,42.12],[52.92,41.87],[52.81,41.14],[52.5,41.78],[52.45,42.03],[52.69,42.44],[52.5,42.79],[51.34,43.13],[50.89,44.03],[50.34,44.28],[50.31,44.61],[51.28,44.51],[51.32,45.25],[52.17,45.41],[53.04,45.26],[53.22,46.23],[53.04,46.85],[52.04,46.8],[51.19,47.05],[50.03,46.61],[49.1,46.4],[48.65,45.81],[47.68,45.64],[46.68,44.61],[47.59,43.66],[47.49,42.99],[48.58,41.81],[49.11,41.28]]]},{"type":"Polygon","coordinates":[[[-93.84,77.52],[-94.3,77.49],[-96.17,77.56],[-96.44,77.83],[-94.42,77.82],[-93.72,77.63],[-93.84,77.52]]]},{"type":"Polygon","coordinates":[[[-110.19,77.7],[-112.05,77.41],[-113.53,77.73],[-112.72,78.05],[-111.26,78.15],[-109.85,78.0],[-110.19,77.7]]]},{"type":"Polygon","coordinates":[[[24.72,77.85],[22.49,77.44],[20.73,77.68],[21.42,77.94],[20.81,78.25],[22.88,78.45],[23.28,78.08],[24.72,77.85]]]},{"type":"Polygon","coordinates":[[[-109.66,78.6],[-110.88,78.41],[-112.54,78.41],[-112.53,78.55],[-111.5,78.85],[-110.96,78.8],[-109.66,78.6]]]},{"type":"Polygon","coordinates":[[[-95.83,78.06],[-97.31,77.85],[-98.12,78.08],[-98.55,78.46],[-98.63,78.87],[-97.34,78.83],[-96.75,78.77],[-95.56,78.42],[-95.83,78.06]]]},{"type":"Polygon","coordinates":[[[-100.06,78.32],[-99.67,77.91],[-101.3,78.02],[-102.95,78.34],[-105.18,78.38],[-104.21,78.68],[-105.42,78.92],[-105.49,79.3],[-103.53,79.17],[-100.83,78.8],[-100.06,78.32]]]},{"type":"Polygon","coordinates":[[[105.08,78.31],[99.44,77.92],[101.26,79.23],[102.09,79.35],[102.84,79.28],[105.37,78.71],[105.08,78.31]]]},{"type":"Polygon","coordinates":[[[18.25,79.7],[21.54,78.96],[19.03,78.56],[18.47,77.83],[17.59,77.64],[17.12,76.81],[15.91,76.77],[13.76,77.38],[14.67,77.74],[13.17,78.02],[11.22,78.87],[10.44,79.65],[13.17,80.01],[13.72,79.66],[15.14,79.67],[15.52,80.02],[16.99,80.05],[18.25,79.7]]]},{"type":"Polygon","coordinates":[[[25.45,80.41],[27.41,80.06],[25.92,79.52],[23.02,79.4],[20.08,79.57],[19.9,79.84],[18.46,79.86],[17.37,80.32],[20.46,80.6],[21.91,80.36],[22.92,80.66],[25.45,80.41]]]},{"type":"Polygon","coordinates":[[[51.14,80.55],[49.79,80.42],[48.89,80.34],[48.75,80.18],[47.59,80.01],[46.5,80.25],[47.07,80.56],[44.85,80.59],[46.8,80.77],[48.32,80.78],[48.52,80.51],[49.1,80.75],[50.04,80.92],[51.52,80.7],[51.14,80.55]]]},{"type":"Polygon","coordinates":[[[99.94,78.88],[97.76,78.76],[94.97,79.04],[93.31,79.43],[92.55,80.14],[91.18,80.34],[93.78,81.02],[95.94,81.25],[97.88,80.75],[100.19,79.78],[99.94,78.88]]]},{"type":"Polygon","coordinates":[[[-87.02,79.66],[-85.81,79.34],[-87.19,79.04],[-89.04,78.29],[-90.8,78.22],[-92.88,78.34],[-93.95,78.75],[-93.94,79.11],[-93.15,79.38],[-94.97,79.37],[-96.08,79.71],[-96.71,80.16],[-96.02,80.6],[-95.32,80.91],[-94.3,80.98],[-94.74,81.21],[-92.41,81.26],[-91.13,80.72],[-89.45,80.51],[-87.81,80.32],[-87.02,79.66]]]},{"type":"Polygon","coordinates":[[[-68.5,83.11],[-65.83,83.03],[-63.68,82.9],[-61.85,82.63],[-61.89,82.36],[-64.33,81.93],[-66.75,81.73],[-67.66,81.5],[-65.48,81.51],[-67.84,80.9],[-69.47,80.62],[-71.18,79.8],[-73.24,79.63],[-73.88,79.43],[-76.91,79.32],[-75.53,79.2],[-76.22,79.02],[-75.39,78.53],[-76.34,78.18],[-77.89,77.9],[-78.36,77.51],[-79.76,77.21],[-79.62,76.98],[-77.91,77.02],[-77.89,76.78],[-80.56,76.18],[-83.17,76.45],[-86.11,76.3],[-87.6,76.42],[-89.49,76.47],[-89.62,76.95],[-87.77,77.18],[-88.26,77.9],[-87.65,77.97],[-84.98,77.54],[-86.34,78.18],[-87.96,78.37],[-87.15,78.76],[-85.38,79.0],[-85.09,79.35],[-86.51,79.74],[-86.93,80.25],[-84.2,80.21],[-83.41,80.1],[-81.85,80.46],[-84.1,80.58],[-87.6,80.52],[-89.37,80.86],[-90.2,81.26],[-91.37,81.55],[-91.59,81.89],[-90.1,82.08],[-88.93,82.12],[-86.97,82.28],[-85.5,82.65],[-84.26,82.6],[-83.18,82.32],[-82.42,82.86],[-81.1,83.02],[-79.31,83.13],[-76.25,83.17],[-75.72,83.06],[-72.83,83.23],[-70.67,83.17],[-68.5,83.11]]]},{"type":"Polygon","coordinates":[[[-27.1,83.52],[-20.85,82.73],[-22.69,82.34],[-26.52,82.3],[-31.9,82.2],[-31.4,82.02],[-27.86,82.13],[-24.84,81.79],[-22.9,82.09],[-22.07,81.73],[-23.17,81.15],[-20.62,81.52],[-15.77,81.91],[-12.77,81.72],[-12.21,81.29],[-16.29,80.58],[-16.85,80.35],[-20.05,80.18],[-17.73,80.13],[-18.9,79.4],[-19.7,78.75],[-19.67,77.64],[-18.47,76.99],[-20.04,76.94],[-21.68,76.63],[-19.83,76.1],[-19.6,75.25],[-20.67,75.16],[-19.37,74.3],[-21.59,74.22],[-20.43,73.82],[-20.76,73.46],[-22.17,73.31],[-23.57,73.31],[-22.31,72.63],[-22.3,72.18],[-24.28,72.6],[-24.79,72.33],[-23.44,72.08],[-22.13,71.47],[-21.75,70.66],[-23.54,70.47],[-24.31,70.86],[-25.54,71.43],[-25.2,70.75],[-26.36,70.23],[-23.73,70.18],[-22.35,70.13],[-25.03,69.26],[-27.75,68.47],[-30.67,68.13],[-31.78,68.12],[-32.81,67.74],[-34.2,66.68],[-36.35,65.98],[-37.04,65.94],[-38.38,65.69],[-39.81,65.46],[-40.67,64.84],[-40.68,64.14],[-41.19,63.48],[-42.82,62.68],[-42.42,61.9],[-42.87,61.07],[-43.38,60.1],[-44.79,60.04],[-46.26,60.85],[-48.26,60.86],[-49.23,61.41],[-49.9,62.38],[-51.63,63.63],[-52.14,64.28],[-52.28,65.18],[-53.66,66.1],[-53.3,66.84],[-53.97,67.19],[-52.98,68.36],[-51.48,68.73],[-51.08,69.15],[-50.87,69.93],[-52.01,69.57],[-52.56,69.43],[-53.46,69.28],[-54.68,69.61],[-54.75,70.29],[-54.36,70.82],[-53.43,70.84],[-51.39,70.57],[-53.11,71.2],[-54.0,71.55],[-55,71.41],[-55.83,71.65],[-54.72,72.59],[-55.33,72.96],[-56.12,73.65],[-57.32,74.71],[-58.6,75.1],[-58.59,75.52],[-61.27,76.1],[-63.39,76.18],[-66.06,76.13],[-68.5,76.06],[-69.66,76.38],[-71.4,77.01],[-68.78,77.32],[-66.76,77.38],[-71.04,77.64],[-73.3,78.04],[-73.16,78.43],[-69.37,78.91],[-65.71,79.39],[-65.32,79.76],[-68.02,80.12],[-67.15,80.52],[-63.69,81.21],[-62.23,81.32],[-62.65,81.77],[-60.28,82.03],[-57.21,82.19],[-54.13,82.2],[-53.04,81.89],[-50.39,82.44],[-48.0,82.06],[-46.6,81.99],[-44.52,81.66],[-46.9,82.2],[-46.76,82.63],[-43.41,83.23],[-39.9,83.18],[-38.62,83.55],[-35.09,83.65],[-27.1,83.52]]]}]} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 2df5e3b4..7b5926ef 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -3,27 +3,43 @@ package com.bitchat.android import android.app.Application import com.bitchat.android.nostr.RelayDirectory import com.bitchat.android.ui.theme.ThemePreferenceManager -import com.bitchat.android.net.TorManager +import com.bitchat.android.net.ArtiTorManager /** * Main application class for bitchat Android */ class BitchatApplication : Application() { - + override fun onCreate() { super.onCreate() - + + // Start the single process-wide power policy before transport components are constructed. + com.bitchat.android.mesh.PowerManager.getInstance(this).start() + // Initialize Tor first so any early network goes over Tor - try { TorManager.init(this) } catch (_: Exception) { } + try { + val torProvider = ArtiTorManager.getInstance() + torProvider.init(this) + } catch (_: Exception){} // Initialize relay directory (loads assets/nostr_relays.csv) RelayDirectory.initialize(this) + // Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately + try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { } + // Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup try { com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) } catch (_: Exception) { } + // Restore private conversations before background transports can deliver new messages. + // AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace + // newer transport state with an older database snapshot. + try { + com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this) + } catch (_: Exception) { } + // Warm up Nostr identity to ensure npub is available for favorite notifications try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) @@ -32,6 +48,34 @@ class BitchatApplication : Application() { // Initialize theme preference ThemePreferenceManager.init(this) + // Initialize chat UI mode (matrix transcript vs bubbles) + com.bitchat.android.ui.theme.ChatUiModeManager.init(this) + + // Initialize debug preference manager (persists debug toggles) + try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } + + // Initialize Wi‑Fi 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 } } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 30ff742e..6f80052a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -4,16 +4,14 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.util.Log -import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.core.view.WindowCompat -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.Scaffold import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -22,12 +20,16 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.Lifecycle import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.onboarding.BluetoothCheckScreen import com.bitchat.android.onboarding.BluetoothStatus import com.bitchat.android.onboarding.BluetoothStatusManager import com.bitchat.android.onboarding.BatteryOptimizationManager +import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationStatus +import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.LocationCheckScreen @@ -39,45 +41,91 @@ import com.bitchat.android.onboarding.PermissionExplanationScreen import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.ui.ChatScreen import com.bitchat.android.ui.ChatViewModel +import com.bitchat.android.ui.OrientationAwareActivity import com.bitchat.android.ui.theme.BitchatTheme -import com.bitchat.android.ui.theme.ThemePreference -import com.bitchat.android.ui.theme.ThemePreferenceManager +import com.bitchat.android.wifiaware.WifiAwareController +import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.services.VerificationService import kotlinx.coroutines.delay import kotlinx.coroutines.launch -class MainActivity : ComponentActivity() { - +class MainActivity : OrientationAwareActivity() { + private lateinit var permissionManager: PermissionManager private lateinit var onboardingCoordinator: OnboardingCoordinator private lateinit var bluetoothStatusManager: BluetoothStatusManager private lateinit var locationStatusManager: LocationStatusManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager - // Core mesh service - managed at app level + // Core mesh service - provided by the foreground service holder private lateinit var meshService: BluetoothMeshService + private lateinit var unifiedMeshService: MeshService private val mainViewModel: MainViewModel by viewModels() + private var pendingMeshForegroundServiceStart = false private val chatViewModel: ChatViewModel by viewModels { object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return ChatViewModel(application, meshService) as T + return ChatViewModel(application, meshService, unifiedMeshService) as T } } } + private val forceFinishReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context, intent: android.content.Intent) { + if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) { + android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI") + finishAffinity() + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.setRecentsScreenshotEnabled(false) + } + // Register receiver for force finish signal from shutdown coordinator + val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) + if (android.os.Build.VERSION.SDK_INT >= 33) { + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null, + android.content.Context.RECEIVER_NOT_EXPORTED + ) + } else { + @Suppress("DEPRECATION") + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null + ) + } + + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity") + finish() + return + } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() // Enable edge-to-edge display for modern Android look enableEdgeToEdge() - - // Make status bar transparent and content can extend behind it - WindowCompat.setDecorFitsSystemWindows(window, false) - + // Initialize permission management permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) + // Start the foreground service when allowed, then get mesh instances from the holder. + startMeshForegroundServiceBestEffort() + meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) + unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext) + // Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED + // Bridging is now handled by TransportBridgeService automatically + bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -100,16 +148,22 @@ class MainActivity : ComponentActivity() { activity = this, permissionManager = permissionManager, onOnboardingComplete = ::handleOnboardingComplete, + onBackgroundLocationRequired = { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + }, onOnboardingFailed = ::handleOnboardingFailed ) setContent { BitchatTheme { - Surface( + Scaffold( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - OnboardingFlowScreen() + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + OnboardingFlowScreen(modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) } } } @@ -122,6 +176,17 @@ class MainActivity : ComponentActivity() { } } } + + // Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + WifiAwareController.running.collect { running -> + if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + unifiedMeshService.delegate = chatViewModel + } + } + } + } // Only start onboarding process if we're in the initial CHECKING state // This prevents restarting onboarding on configuration changes @@ -131,7 +196,7 @@ class MainActivity : ComponentActivity() { } @Composable - private fun OnboardingFlowScreen() { + private fun OnboardingFlowScreen(modifier: Modifier = Modifier) { val context = LocalContext.current val onboardingState by mainViewModel.onboardingState.collectAsState() val bluetoothStatus by mainViewModel.bluetoothStatus.collectAsState() @@ -157,7 +222,6 @@ class MainActivity : ComponentActivity() { onDispose { try { context.unregisterReceiver(receiver) - Log.d("BluetoothStatusUI", "BroadcastReceiver unregistered") } catch (e: IllegalStateException) { Log.w("BluetoothStatusUI", "Receiver was not registered") } @@ -165,12 +229,13 @@ class MainActivity : ComponentActivity() { } when (onboardingState) { - OnboardingState.CHECKING -> { - InitializingScreen() + OnboardingState.PERMISSION_REQUESTING -> { + InitializingScreen(modifier) } OnboardingState.BLUETOOTH_CHECK -> { BluetoothCheckScreen( + modifier = modifier, status = bluetoothStatus, onEnableBluetooth = { mainViewModel.updateBluetoothLoading(true) @@ -179,12 +244,17 @@ class MainActivity : ComponentActivity() { onRetry = { checkBluetoothAndProceed() }, + onSkip = { + mainViewModel.skipBluetoothCheck() + checkLocationAndProceed() + }, isLoading = isBluetoothLoading ) } OnboardingState.LOCATION_CHECK -> { LocationCheckScreen( + modifier = modifier, status = locationStatus, onEnableLocation = { mainViewModel.updateLocationLoading(true) @@ -199,6 +269,7 @@ class MainActivity : ComponentActivity() { OnboardingState.BATTERY_OPTIMIZATION_CHECK -> { BatteryOptimizationScreen( + modifier = modifier, status = batteryOptimizationStatus, onDisableBatteryOptimization = { mainViewModel.updateBatteryOptimizationLoading(true) @@ -217,6 +288,7 @@ class MainActivity : ComponentActivity() { OnboardingState.PERMISSION_EXPLANATION -> { PermissionExplanationScreen( + modifier = modifier, permissionCategories = permissionManager.getCategorizedPermissions(), onContinue = { mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_REQUESTING) @@ -224,16 +296,23 @@ class MainActivity : ComponentActivity() { } ) } - - OnboardingState.PERMISSION_REQUESTING -> { - InitializingScreen() + + OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> { + BackgroundLocationPermissionScreen( + modifier = modifier, + onContinue = { + onboardingCoordinator.requestBackgroundLocation() + }, + onRetry = { + onboardingCoordinator.checkBackgroundLocationAndProceed() + }, + onSkip = { + onboardingCoordinator.skipBackgroundLocation() + } + ) } - - OnboardingState.INITIALIZING -> { - InitializingScreen() - } - - OnboardingState.COMPLETE -> { + + OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> { // Set up back navigation handling for the chat screen val backCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -256,6 +335,7 @@ class MainActivity : ComponentActivity() { OnboardingState.ERROR -> { InitializationErrorScreen( + modifier = modifier, errorMessage = errorMessage, onRetry = { mainViewModel.updateOnboardingState(OnboardingState.CHECKING) @@ -274,7 +354,7 @@ class MainActivity : ComponentActivity() { when (state) { OnboardingState.COMPLETE -> { // App is fully initialized, mesh service is running - android.util.Log.d("MainActivity", "Onboarding completed - app ready") + android.util.Log.i("MainActivity", "Onboarding completed - app ready") } OnboardingState.ERROR -> { android.util.Log.e("MainActivity", "Onboarding error state reached") @@ -284,8 +364,6 @@ class MainActivity : ComponentActivity() { } private fun checkOnboardingStatus() { - Log.d("MainActivity", "Checking onboarding status") - lifecycleScope.launch { // Small delay to show the checking state delay(500) @@ -299,12 +377,15 @@ class MainActivity : ComponentActivity() { * Check Bluetooth status and proceed with onboarding flow */ private fun checkBluetoothAndProceed() { - // Log.d("MainActivity", "Checking Bluetooth status") - + // Check if user has skipped Bluetooth check for this session + if (mainViewModel.isBluetoothCheckSkipped.value) { + checkLocationAndProceed() + return + } + // For first-time users, skip Bluetooth check and go straight to permissions // We'll check Bluetooth after permissions are granted if (permissionManager.isFirstTimeLaunch()) { - Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions") proceedWithPermissionCheck() return } @@ -313,6 +394,12 @@ class MainActivity : ComponentActivity() { bluetoothStatusManager.logBluetoothStatus() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (!bleRequired) { + // Skip BLE checks entirely when BLE is disabled in debug settings + checkLocationAndProceed() + return + } when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next @@ -320,7 +407,6 @@ class MainActivity : ComponentActivity() { } BluetoothStatus.DISABLED -> { // Show Bluetooth enable screen (should have permissions as existing user) - Log.d("MainActivity", "Bluetooth disabled, showing enable screen") mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateBluetoothLoading(false) } @@ -337,20 +423,24 @@ class MainActivity : ComponentActivity() { * Proceed with permission checking */ private fun proceedWithPermissionCheck() { - Log.d("MainActivity", "Proceeding with permission check") - lifecycleScope.launch { delay(200) // Small delay for smooth transition - + if (permissionManager.isFirstTimeLaunch()) { - Log.d("MainActivity", "First time launch, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) - } else if (permissionManager.areAllPermissionsGranted()) { - Log.d("MainActivity", "Existing user with permissions, initializing app") - mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) - initializeApp() + } else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) { + mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) + } else if (permissionManager.areRequiredPermissionsGranted()) { + if (permissionManager.needsBackgroundLocationPermission() && + !permissionManager.isBackgroundLocationGranted() && + !com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity) + ) { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + } else { + mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) + initializeApp() + } } else { - Log.d("MainActivity", "Existing user missing permissions, showing explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } } @@ -360,7 +450,6 @@ class MainActivity : ComponentActivity() { * Handle Bluetooth enabled callback */ private fun handleBluetoothEnabled() { - Log.d("MainActivity", "Bluetooth enabled by user") mainViewModel.updateBluetoothLoading(false) mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED) checkLocationAndProceed() @@ -370,12 +459,9 @@ class MainActivity : ComponentActivity() { * Check Location services status and proceed with onboarding flow */ private fun checkLocationAndProceed() { - Log.d("MainActivity", "Checking location services status") - // For first-time users, skip location check and go straight to permissions // We'll check location after permissions are granted if (permissionManager.isFirstTimeLaunch()) { - Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions") proceedWithPermissionCheck() return } @@ -391,7 +477,6 @@ class MainActivity : ComponentActivity() { } LocationStatus.DISABLED -> { // Show location enable screen (should have permissions as existing user) - Log.d("MainActivity", "Location services disabled, showing enable screen") mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) } @@ -408,9 +493,10 @@ class MainActivity : ComponentActivity() { * Handle Location enabled callback */ private fun handleLocationEnabled() { - Log.d("MainActivity", "Location services enabled by user") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(LocationStatus.ENABLED) + // Ensure Wi-Fi Aware starts now that location is enabled + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() checkBatteryOptimizationAndProceed() } @@ -452,12 +538,10 @@ class MainActivity : ComponentActivity() { message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> { // During first-time onboarding, if Bluetooth enable fails due to permissions, // proceed to permission explanation screen where user will grant permissions first - Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation") proceedWithPermissionCheck() } message.contains("Permission") -> { // For existing users, redirect to permission explanation to grant missing permissions - Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else -> { @@ -468,8 +552,6 @@ class MainActivity : ComponentActivity() { } private fun handleOnboardingComplete() { - Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app") - // After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentLocationStatus = locationStatusManager.checkLocationStatus() @@ -479,31 +561,28 @@ class MainActivity : ComponentActivity() { else -> BatteryOptimizationStatus.ENABLED } + val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } when { - currentBluetoothStatus != BluetoothStatus.ENABLED -> { + bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> { // Bluetooth still disabled, but now we have permissions to enable it - Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateBluetoothLoading(false) } currentLocationStatus != LocationStatus.ENABLED -> { // Location services still disabled, but now we have permissions to enable it - Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.") mainViewModel.updateLocationStatus(currentLocationStatus) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) } currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> { // Battery optimization still enabled, show battery optimization screen - android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.") mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus) mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) mainViewModel.updateBatteryOptimizationLoading(false) } else -> { // Both are enabled, proceed to app initialization - Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization") mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) initializeApp() } @@ -515,17 +594,36 @@ class MainActivity : ComponentActivity() { mainViewModel.updateErrorMessage(message) mainViewModel.updateOnboardingState(OnboardingState.ERROR) } + + private fun startMeshForegroundServiceBestEffort() { + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + pendingMeshForegroundServiceStart = true + Log.i("MainActivity", "Deferring foreground mesh service start until activity is started") + return + } + + try { + com.bitchat.android.service.MeshForegroundService.start(applicationContext) + pendingMeshForegroundServiceStart = false + } catch (e: Exception) { + pendingMeshForegroundServiceStart = true + Log.w("MainActivity", "Unable to start foreground mesh service; will retry when activity is started", e) + } + } /** * Check Battery Optimization status and proceed with onboarding flow */ private fun checkBatteryOptimizationAndProceed() { - android.util.Log.d("MainActivity", "Checking battery optimization status") - // For first-time users, skip battery optimization check and go straight to permissions // We'll check battery optimization after permissions are granted if (permissionManager.isFirstTimeLaunch()) { - android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions") + proceedWithPermissionCheck() + return + } + + // Check if user has previously skipped battery optimization + if (BatteryOptimizationPreferenceManager.isSkipped(this)) { proceedWithPermissionCheck() return } @@ -546,7 +644,6 @@ class MainActivity : ComponentActivity() { } BatteryOptimizationStatus.ENABLED -> { // Show battery optimization disable screen - android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen") mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) mainViewModel.updateBatteryOptimizationLoading(false) } @@ -557,7 +654,6 @@ class MainActivity : ComponentActivity() { * Handle Battery Optimization disabled callback */ private fun handleBatteryOptimizationDisabled() { - android.util.Log.d("MainActivity", "Battery optimization disabled by user") mainViewModel.updateBatteryOptimizationLoading(false) mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED) proceedWithPermissionCheck() @@ -581,15 +677,17 @@ class MainActivity : ComponentActivity() { } private fun initializeApp() { - Log.d("MainActivity", "Starting app initialization") - lifecycleScope.launch { try { // Initialize the app with a proper delay to ensure Bluetooth stack is ready // This solves the issue where app needs restart to work on first install delay(1000) // Give the system time to process permission grants + + // Initialize PoW preferences early in the initialization process + PoWPreferenceManager.init(this@MainActivity) - Log.d("MainActivity", "Permissions verified, initializing chat system") + // Initialize Location Notes Manager (extracted to separate file) + com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity) // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { @@ -599,18 +697,18 @@ class MainActivity : ComponentActivity() { return@launch } - // Set up mesh service delegate and start services - meshService.delegate = chatViewModel - meshService.startServices() - - Log.d("MainActivity", "Mesh service started successfully") - + // Set up unified mesh delegate and start enabled transports + unifiedMeshService.delegate = chatViewModel + unifiedMeshService.startServices() + startMeshForegroundServiceBestEffort() + // Handle any notification intent handleNotificationIntent(intent) - + handleVerificationIntent(intent) + // Small delay to ensure mesh service is fully initialized delay(500) - Log.d("MainActivity", "App initialization complete") + Log.i("MainActivity", "App initialization complete") mainViewModel.updateOnboardingState(OnboardingState.COMPLETE) } catch (e: Exception) { Log.e("MainActivity", "Failed to initialize app", e) @@ -621,23 +719,45 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received, finishing activity") + finish() + return + } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() + // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) + handleVerificationIntent(intent) + } + } + + override fun onStart() { + super.onStart() + if (pendingMeshForegroundServiceStart) { + startMeshForegroundServiceBestEffort() } } override fun onResume() { super.onResume() + // Revoke stale live-location work before any resumed UI can use cached channels. + LocationChannelManager.getInstance(applicationContext).syncPermissionState() + // Check Bluetooth and Location status on resume and handle accordingly if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - // Set app foreground state - meshService.connectionManager.setAppBackgroundState(false) - chatViewModel.setAppBackgroundState(false) + // Reattach mesh delegate to new ChatViewModel instance after Activity recreation + try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { } // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() - if (currentBluetoothStatus != BluetoothStatus.ENABLED) { + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) { Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) @@ -652,17 +772,19 @@ class MainActivity : ComponentActivity() { mainViewModel.updateLocationStatus(currentLocationStatus) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) + } else { + // If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() } } } - override fun onPause() { + override fun onPause() { super.onPause() // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - // Set app background state - meshService.connectionManager.setAppBackgroundState(true) - chatViewModel.setAppBackgroundState(true) + // Detach UI delegate so the foreground service can own DM notifications while UI is closed + try { unifiedMeshService.delegate = null } catch (_: Exception) { } } } @@ -688,8 +810,9 @@ class MainActivity : ComponentActivity() { if (peerID != null) { Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification") - // Open the private chat with this peer - chatViewModel.startPrivateChat(peerID) + // Open the private chat sheet with this peer + chatViewModel.showMeshPeerList() + chatViewModel.showPrivateChatSheet(peerID) // Clear notifications for this sender since user is now viewing the chat chatViewModel.clearNotificationsForSender(peerID) @@ -700,7 +823,7 @@ class MainActivity : ComponentActivity() { val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH) if (geohash != null) { - Log.d("MainActivity", "Opening geohash chat #$geohash from notification") + Log.d("MainActivity", "Opening geohash chat from notification") // Switch to the geohash channel - create appropriate geohash channel level val level = when (geohash.length) { @@ -725,26 +848,30 @@ class MainActivity : ComponentActivity() { } } + private fun handleVerificationIntent(intent: Intent) { + val uri = intent.data ?: return + if (uri.scheme != "bitchat" || uri.host != "verify") return + + chatViewModel.showVerificationSheet() + val qr = VerificationService.verifyScannedQR(uri.toString()) + if (qr != null) { + chatViewModel.beginQRVerification(qr) + } + } + override fun onDestroy() { super.onDestroy() + try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { } + // Cleanup location status manager try { locationStatusManager.cleanup() - Log.d("MainActivity", "Location status manager cleaned up successfully") } catch (e: Exception) { Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") } - // Stop mesh services if app was fully initialized - if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - try { - meshService.stopServices() - Log.d("MainActivity", "Mesh services stopped successfully") - } catch (e: Exception) { - Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}") - } - } + // Do not stop mesh here; ForegroundService owns lifecycle for background reliability } } diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 35125d85..15ec6fda 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -35,6 +35,9 @@ class MainViewModel : ViewModel() { private val _isBatteryOptimizationLoading = MutableStateFlow(false) val isBatteryOptimizationLoading: StateFlow = _isBatteryOptimizationLoading.asStateFlow() + private val _isBluetoothCheckSkipped = MutableStateFlow(false) + val isBluetoothCheckSkipped: StateFlow = _isBluetoothCheckSkipped.asStateFlow() + // Public update functions for MainActivity fun updateOnboardingState(state: OnboardingState) { _onboardingState.value = state @@ -67,4 +70,8 @@ class MainViewModel : ViewModel() { fun updateBatteryOptimizationLoading(loading: Boolean) { _isBatteryOptimizationLoading.value = loading } + + fun skipBluetoothCheck() { + _isBluetoothCheckSkipped.value = true + } } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt new file mode 100644 index 00000000..731a85fb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -0,0 +1,92 @@ +package com.bitchat.android.core.ui.component.button + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.rememberPressScale +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +private val MultiClickThreshold = 300.milliseconds + +@Composable +fun BitChatBrandButton( + onClick: () -> Unit, + onTripleClick: () -> Unit, + contentDescription: String, + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.primary, + iconSize: Dp = 22.dp, +) { + var tapCount by remember { mutableIntStateOf(0) } + var resetJob by remember { mutableStateOf(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), + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt new file mode 100644 index 00000000..9c60123f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.core.ui.component.button + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.bitchat.android.R + +@Composable +fun CloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + IconButton( + onClick = onClick, + // 44.dp to match every other tap target in the app's chrome. + modifier = modifier.size(44.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = colorScheme.primary, + containerColor = Color.Transparent + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(18.dp) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt new file mode 100644 index 00000000..e53fed46 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.core.ui.component.sheet + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first. + * + * `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap + * on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from + * a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the + * composable out of the tree and makes the sheet vanish instantly. + * + * Anything inside a sheet that wants to close it should prefer this over calling its own + * `onDismiss` directly. + */ +val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitchatBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismissRequest: () -> Unit, + content: @Composable (ColumnScope.() -> Unit), +) { + val scope = rememberCoroutineScope() + + // Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()` + // throws if the sheet is already on its way out (two rapid taps on a close button), which is + // benign — the dismissal still has to go through. + val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) { + { + scope.launch { + runCatching { sheetState.hide() } + onDismissRequest() + } + } + } + + ModalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + containerColor = MaterialTheme.colorScheme.background, + ) { + CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) { + content() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt new file mode 100644 index 00000000..b8a027dc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -0,0 +1,89 @@ +package com.bitchat.android.core.ui.component.sheet + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.core.ui.component.button.CloseButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitchatSheetTopBar( + onClose: () -> Unit, + modifier: Modifier = Modifier, + backgroundAlpha: Float = 0.98f, + title: @Composable () -> Unit, + navigationIcon: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + TopAppBar( + title = title, + navigationIcon = { navigationIcon?.invoke() }, + actions = { + actions() + val dismiss = LocalSheetDismiss.current + CloseButton( + onClick = { dismiss?.invoke() ?: onClose() }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha), + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = modifier + ) +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitchatSheetCenterTopBar( + onClose: () -> Unit, + modifier: Modifier = Modifier, + backgroundAlpha: Float = 0.98f, + title: @Composable () -> Unit, + navigationIcon: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + CenterAlignedTopAppBar( + title = title, + navigationIcon = { navigationIcon?.invoke() }, + actions = { + actions() + val dismiss = LocalSheetDismiss.current + CloseButton( + onClick = { dismiss?.invoke() ?: onClose() }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha), + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = modifier + ) +} + +@Composable +fun BitchatSheetTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontFamily = BitchatFontFamily + ) + ) +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt new file mode 100644 index 00000000..ac3b52f1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt @@ -0,0 +1,100 @@ +package com.bitchat.android.core.ui.component.text + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow + +internal data class ClickedAnnotation( + val tag: String, + val item: String, +) + +internal fun findAnnotationAt( + text: AnnotatedString, + offset: Int, + annotationTags: List, +): 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, + 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(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) + }, + ) +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt b/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt new file mode 100644 index 00000000..14247f2d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt @@ -0,0 +1,48 @@ +package com.bitchat.android.core.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val BitChatIcon: ImageVector + get() { + _BitChatIcon?.let { return it } + + return ImageVector.Builder( + name = "BitChatIcon", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 8f, + viewportHeight = 8f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(2f, 0f) + lineTo(6f, 0f) + lineTo(6f, 1f) + lineTo(7f, 1f) + lineTo(7f, 2f) + lineTo(8f, 2f) + lineTo(8f, 5f) + lineTo(7f, 5f) + lineTo(7f, 6f) + lineTo(6f, 6f) + lineTo(6f, 8f) + lineTo(5f, 8f) + lineTo(5f, 7f) + lineTo(3f, 7f) + lineTo(3f, 6f) + lineTo(1f, 6f) + lineTo(1f, 5f) + lineTo(0f, 5f) + lineTo(0f, 2f) + lineTo(1f, 2f) + lineTo(1f, 1f) + lineTo(2f, 1f) + close() + } + }.build().also { _BitChatIcon = it } + } + +private var _BitChatIcon: ImageVector? = null diff --git a/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt b/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt deleted file mode 100644 index 19dabb57..00000000 --- a/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.bitchat.android.core.ui.utils - -import androidx.compose.foundation.clickable -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -fun Modifier.singleOrTripleClickable( - onSingleClick: () -> Unit, - onTripleClick: () -> Unit, - clickTimeThreshold: Long = 300L -): Modifier = composed { - var tapCount by remember { mutableIntStateOf(0) } - var lastTapTime by remember { mutableLongStateOf(0L) } - var singleClickJob by remember { mutableStateOf(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 - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index 449d705f..b15a6254 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -1,8 +1,15 @@ package com.bitchat.android.crypto import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import com.bitchat.android.noise.NoiseEncryptionService +import com.bitchat.android.noise.NoiseHandshakeProcessingResult +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoiseDecryptionResult import org.bouncycastle.crypto.AsymmetricCipherKeyPair import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters @@ -11,6 +18,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters import org.bouncycastle.crypto.signers.Ed25519Signer import java.security.SecureRandom import java.util.concurrent.ConcurrentHashMap +import androidx.core.content.edit /** * Encryption service that now uses NoiseEncryptionService internally @@ -19,29 +27,55 @@ import java.util.concurrent.ConcurrentHashMap * This is the main interface for all encryption/decryption operations in bitchat. * It now uses the Noise protocol for secure transport encryption with proper session management. */ -class EncryptionService(private val context: Context) { +open class EncryptionService(private val context: Context) { companion object { private const val TAG = "EncryptionService" private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key" + private const val OLD_PREFS_NAME = "bitchat_crypto" + private const val SECURE_PREFS_NAME = "bitchat_crypto_secure" } // Core Noise encryption service - private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context) + private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) } // Session tracking for established connections private val establishedSessions = ConcurrentHashMap() // peerID -> fingerprint // Ed25519 signing keys (separate from Noise static keys) - private val ed25519PrivateKey: Ed25519PrivateKeyParameters - private val ed25519PublicKey: Ed25519PublicKeyParameters + private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters + private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters // Callbacks for UI state updates var onSessionEstablished: ((String) -> Unit)? = null // peerID var onSessionLost: ((String) -> Unit)? = null // peerID var onHandshakeRequired: ((String) -> Unit)? = null // peerID + private lateinit var prefs: SharedPreferences init { + initialize() + } + + private fun setUpEncryptedPrefs() { + val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + // Create encrypted shared preferences + prefs = EncryptedSharedPreferences.create( + context, + SECURE_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + /** + * Initialization logic moved to method to allow overriding in tests + */ + protected open fun initialize() { + setUpEncryptedPrefs() // Initialize or load Ed25519 signing keys val keyPair = loadOrCreateEd25519KeyPair() ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters @@ -135,9 +169,14 @@ class EncryptionService(private val context: Context) { // Clear Ed25519 signing key from preferences try { - val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE) - prefs.edit().remove(ED25519_PRIVATE_KEY_PREF).apply() + prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) } Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences") + + // Generate new keys immediately + val keyPair = loadOrCreateEd25519KeyPair() + ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters + ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters + Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory") } catch (e: Exception) { Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}") } @@ -154,6 +193,13 @@ class EncryptionService(private val context: Context) { } return encrypted } + + @Throws(Exception::class) + fun encryptForSession( + data: ByteArray, + peerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession) /** * Decrypt data from a specific peer using Noise transport encryption @@ -166,6 +212,12 @@ class EncryptionService(private val context: Context) { } return decrypted } + + @Throws(Exception::class) + fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult { + return noiseService.decryptWithSession(data, peerID) + ?: throw Exception("Failed generation-bound decryption from $peerID") + } /** * Sign data using our static identity key @@ -218,6 +270,25 @@ class EncryptionService(private val context: Context) { fun getPeerFingerprint(peerID: String): String? { return noiseService.getPeerFingerprint(peerID) } + + /** + * Return the remote static key authenticated by the live Noise handshake. + * This deliberately bypasses announcement and PeerFingerprintManager + * caches; callers making downgrade decisions must bind to live channel + * authentication, not a self-certified identity payload. + */ + fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? { + return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf() + } + + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? = + noiseService.getAuthenticatedSession(peerID) + + fun withAuthenticatedSession( + peerID: String, + expectedSession: AuthenticatedNoiseSession, + action: () -> Boolean + ): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action) /** * Get current peer ID for a fingerprint (for peer ID rotation) @@ -229,9 +300,9 @@ class EncryptionService(private val context: Context) { /** * Initiate a Noise handshake with a peer */ - fun initiateHandshake(peerID: String): ByteArray? { + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { Log.d(TAG, "🤝 Initiating Noise handshake with $peerID") - return noiseService.initiateHandshake(peerID) + return noiseService.initiateHandshake(peerID, replaceEstablished) } /** @@ -241,11 +312,24 @@ class EncryptionService(private val context: Context) { Log.d(TAG, "🤝 Processing handshake message from $peerID") return noiseService.processHandshakeMessage(data, peerID) } + + /** + * Process one Noise handshake frame while preserving whether this exact call authenticated a + * new session. Unlike the response-only compatibility API, binding failures are propagated. + */ + @Throws(Exception::class) + open fun processHandshakeMessageWithResult( + data: ByteArray, + peerID: String + ): NoiseHandshakeProcessingResult { + Log.d(TAG, "🤝 Processing typed handshake message from $peerID") + return noiseService.processHandshakeMessageWithResult(data, peerID) + } /** * Remove a peer session (called when peer disconnects) */ - fun removePeer(peerID: String) { + open fun removePeer(peerID: String) { establishedSessions.remove(peerID) noiseService.removePeer(peerID) onSessionLost?.invoke(peerID) @@ -356,7 +440,7 @@ class EncryptionService(private val context: Context) { /** * Verify Ed25519 signature against data using a public key */ - fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { + open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { return try { val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0) val verifier = Ed25519Signer() @@ -377,13 +461,14 @@ class EncryptionService(private val context: Context) { * Load existing Ed25519 key pair from preferences or create a new one */ private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair { + // Migrate legacy plaintext Ed25519 key to encrypted storage if present + migrateOldEd25519KeyIfNeeded() try { - val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE) val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null) - + if (storedKey != null) { // Load existing key - val privateKeyBytes = android.util.Base64.decode(storedKey, android.util.Base64.DEFAULT) + val privateKeyBytes = Base64.decode(storedKey, Base64.DEFAULT) val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0) val publicKey = privateKey.generatePublicKey() Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair") @@ -394,18 +479,21 @@ class EncryptionService(private val context: Context) { } // Create new key pair + return generateAndSaveEd25519KeyPair() + } + + fun generateAndSaveEd25519KeyPair(): AsymmetricCipherKeyPair { val keyGen = Ed25519KeyPairGenerator() keyGen.init(Ed25519KeyGenerationParameters(SecureRandom())) val keyPair = keyGen.generateKeyPair() - + // Store private key in preferences try { val privateKey = keyPair.private as Ed25519PrivateKeyParameters val privateKeyBytes = privateKey.encoded - val encodedKey = android.util.Base64.encodeToString(privateKeyBytes, android.util.Base64.DEFAULT) - - val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE) - prefs.edit().putString(ED25519_PRIVATE_KEY_PREF, encodedKey).apply() + val encodedKey = Base64.encodeToString(privateKeyBytes, Base64.DEFAULT) + + prefs.edit { putString(ED25519_PRIVATE_KEY_PREF, encodedKey) } Log.d(TAG, "✅ Created and stored new Ed25519 signing key pair") } catch (e: Exception) { Log.e(TAG, "❌ Failed to store Ed25519 private key: ${e.message}") @@ -413,4 +501,25 @@ class EncryptionService(private val context: Context) { 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}") + } + } } diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoriteControlMessage.kt b/app/src/main/java/com/bitchat/android/favorites/FavoriteControlMessage.kt new file mode 100644 index 00000000..c75b325d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/favorites/FavoriteControlMessage.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.favorites + +import com.bitchat.android.services.ContactIdentityResolver + +data class FavoriteControlMessage( + val isFavorite: Boolean, + val npub: String? +) { + companion object { + private const val FAVORITED = "[FAVORITED]" + private const val UNFAVORITED = "[UNFAVORITED]" + + fun parse(content: String): FavoriteControlMessage? { + val trimmed = content.trim() + val isFavorite = when { + trimmed.startsWith(FAVORITED) -> true + trimmed.startsWith(UNFAVORITED) -> false + else -> return null + } + val encodedKey = trimmed.substringAfter(":", "").trim() + val npub = encodedKey + .takeIf { it.isNotEmpty() } + ?.let { ContactIdentityResolver.nostrPubkeyHex(it) } + ?.let { ContactIdentityResolver.npubFromHex(it) } + return FavoriteControlMessage(isFavorite = isFavorite, npub = npub) + } + + fun encode(isFavorite: Boolean, npub: String?): String { + val prefix = if (isFavorite) FAVORITED else UNFAVORITED + return "$prefix:${npub.orEmpty()}" + } + } +} diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index f1366243..ae453bed 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -2,18 +2,19 @@ package com.bitchat.android.favorites import android.content.Context import android.util.Log +import com.bitchat.android.services.AppStateStore import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.services.ContactIdentityResolver import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.util.* /** * Bridging Noise and Nostr favorites - * Direct port from iOS FavoritesPersistenceService.swift */ data class FavoriteRelationship( val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes) - val peerNostrPublicKey: String?, // npub bech32 string + val peerNostrPublicKey: String?, // npub bech32 string val peerNickname: String, val isFavorite: Boolean, // We favorited them val theyFavoritedUs: Boolean, // They favorited us @@ -21,22 +22,22 @@ data class FavoriteRelationship( val lastUpdated: Date ) { val isMutual: Boolean get() = isFavorite && theyFavoritedUs - + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - + other as FavoriteRelationship - + if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false if (peerNostrPublicKey != other.peerNostrPublicKey) return false if (peerNickname != other.peerNickname) return false if (isFavorite != other.isFavorite) return false if (theyFavoritedUs != other.theyFavoritedUs) return false - + return true } - + override fun hashCode(): Int { var result = peerNoisePublicKey.contentHashCode() result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0) @@ -47,6 +48,25 @@ data class FavoriteRelationship( } } +internal fun FavoriteRelationship?.withPeerFavoritedUs( + noisePublicKey: ByteArray, + theyFavoritedUs: Boolean, + now: Date = Date() +): FavoriteRelationship { + return this?.copy( + theyFavoritedUs = theyFavoritedUs, + lastUpdated = now + ) ?: FavoriteRelationship( + peerNoisePublicKey = noisePublicKey, + peerNostrPublicKey = null, + peerNickname = "Unknown", + isFavorite = false, + theyFavoritedUs = theyFavoritedUs, + favoritedAt = now, + lastUpdated = now + ) +} + interface FavoritesChangeListener { fun onFavoriteChanged(noiseKeyHex: String) fun onAllCleared() @@ -54,20 +74,21 @@ interface FavoritesChangeListener { /** * Manages favorites with Noise↔Nostr mapping - * Singleton pattern matching iOS implementation + * Singleton pattern matching iOS implementation. */ class FavoritesPersistenceService private constructor(private val context: Context) { - + companion object { private const val TAG = "FavoritesPersistenceService" - private const val FAVORITES_KEY = "favorite_relationships" - + private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship + private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub + @Volatile private var INSTANCE: FavoritesPersistenceService? = null - + val shared: FavoritesPersistenceService get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized") - + fun initialize(context: Context) { if (INSTANCE == null) { synchronized(this) { @@ -78,57 +99,69 @@ class FavoritesPersistenceService private constructor(private val context: Conte } } } - + private val stateManager = SecureIdentityStateManager(context) private val gson = Gson() - private val favorites = mutableMapOf() // noiseKeyHex -> relationship + private val favorites = mutableMapOf() // noiseHex -> relationship + private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub private val listeners = mutableListOf() - + init { loadFavorites() + loadPeerIdIndex() } - - /** - * Get favorite status for Noise public key - */ + + /** Get favorite status for Noise public key */ fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) return favorites[keyHex] } - - /** - * Get favorite status for 16-hex peerID - */ + + /** Get favorite status for a mesh peer ID or full Noise public key hex. */ fun getFavoriteStatus(peerID: String): FavoriteRelationship? { - // For 16-hex peerIDs, we need to find the corresponding full Noise key - // This is a simplified lookup - in practice you'd use fingerprint matching - for ((_, relationship) in favorites) { - val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) } - if (noiseKeyHex.startsWith(peerID)) { - return relationship + val pid = peerID.trim().lowercase() + + if (ContactIdentityResolver.isNoiseKeyHex(pid)) { + return favorites[pid] + } + + ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint -> + return favorites.values.firstOrNull { relationship -> + ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) + .equals(fingerprint, ignoreCase = true) } } + + if (ContactIdentityResolver.isMeshPeerId(pid)) { + peerIdIndex[pid]?.let { indexedNpub -> + findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) } + } + return favorites.values.firstOrNull { relationship -> + ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid + } + } + return null } - - /** - * Update Nostr public key for a peer - */ + + /** Update Nostr public key for a peer (indexed by Noise key) */ fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) + ?.let { ContactIdentityResolver.npubFromHex(it) } + ?: nostrPubkey val existing = favorites[keyHex] - + if (existing != null) { val updated = existing.copy( - peerNostrPublicKey = nostrPubkey, + peerNostrPublicKey = normalizedNpub, lastUpdated = Date() ) favorites[keyHex] = updated } else { - // Create new relationship val relationship = FavoriteRelationship( peerNoisePublicKey = noisePublicKey, - peerNostrPublicKey = nostrPubkey, + peerNostrPublicKey = normalizedNpub, peerNickname = "Unknown", isFavorite = false, theyFavoritedUs = false, @@ -137,19 +170,59 @@ class FavoritesPersistenceService private constructor(private val context: Conte ) favorites[keyHex] = relationship } - + saveFavorites() notifyChanged(keyHex) Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...") } - - /** - * Update favorite status - */ + + + /** Update Nostr pubkey for a specific mesh peerID. */ + fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) { + val pid = peerID.trim().lowercase() + val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) + ?.let { ContactIdentityResolver.npubFromHex(it) } + ?: nostrPubkey + if (ContactIdentityResolver.isMeshPeerId(pid)) { + peerIdIndex[pid] = normalizedNpub + savePeerIdIndex() + notifyChanged(pid) + Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") + } else { + Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") + } + } + + + /** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */ + fun findNostrPubkeyForPeerID(peerID: String): String? { + val pid = peerID.trim().lowercase() + return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey + } + + /** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */ + fun findPeerIDForNostrPubkey(nostrPubkey: String): String? { + val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null + + peerIdIndex.entries.firstOrNull { (_, stored) -> + ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex + }?.let { return it.key } + + favorites.values.firstOrNull { relationship -> + relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex + }?.let { relationship -> + return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) + } + + return null + } + + /** Update favorite status */ fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + val existing = favorites[keyHex] - + val updated = if (existing != null) { existing.copy( peerNickname = nickname, @@ -168,129 +241,126 @@ class FavoritesPersistenceService private constructor(private val context: Conte lastUpdated = Date() ) } - + favorites[keyHex] = updated saveFavorites() notifyChanged(keyHex) Log.d(TAG, "Updated favorite status for $nickname: $isFavorite") } - - /** - * Update peer favorited us status - */ + + /** Update peer favorited-us flag */ fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { - val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) val existing = favorites[keyHex] - - if (existing != null) { - val updated = existing.copy( - theyFavoritedUs = theyFavoritedUs, - lastUpdated = Date() - ) - favorites[keyHex] = updated - saveFavorites() - notifyChanged(keyHex) - - Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") - } + val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs) + + favorites[keyHex] = updated + saveFavorites() + notifyChanged(keyHex) + + Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } - - /** - * Get all mutual favorites - */ - fun getMutualFavorites(): List { - return favorites.values.filter { it.isMutual } - } - - /** - * Get all favorites we have - */ - fun getOurFavorites(): List { - return favorites.values.filter { it.isFavorite } - } - - /** - * Clear all favorites - */ + + fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } + fun getOurFavorites(): List = favorites.values.filter { it.isFavorite } + fun getAllRelationships(): List = favorites.values.toList() + fun clearAllFavorites() { favorites.clear() saveFavorites() + peerIdIndex.clear() + savePeerIdIndex() Log.i(TAG, "Cleared all favorites") notifyAllCleared() } - - /** - * Find Noise key by Nostr pubkey - */ + + /** Find Noise key by Nostr pubkey */ fun findNoiseKey(forNostrPubkey: String): ByteArray? { - val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null + val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null return favorites.values.firstOrNull { rel -> - rel.peerNostrPublicKey?.let { stored -> - normalizeNostrKeyToHex(stored) - } == targetHex + rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex }?.peerNoisePublicKey } - - /** - * Find Nostr pubkey by Noise key - */ + + /** Find Nostr pubkey by Noise key */ fun findNostrPubkey(forNoiseKey: ByteArray): String? { - val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) } + val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) return favorites[keyHex]?.peerNostrPublicKey } - - // MARK: - Private Methods - + + // MARK: - Persistence + private fun loadFavorites() { try { - // Use public methods instead of reflection to access encrypted preferences val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) if (favoritesJson != null) { val type = object : TypeToken>() {}.type val data: Map = gson.fromJson(favoritesJson, type) - + favorites.clear() data.forEach { (key, relationshipData) -> - val relationship = relationshipData.toFavoriteRelationship() - favorites[key] = relationship + favorites[key] = relationshipData.toFavoriteRelationship() } - Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } } catch (e: Exception) { Log.e(TAG, "Failed to load favorites: ${e.message}") } } - + private fun saveFavorites() { try { - // Convert to serializable format val data = favorites.mapValues { (_, relationship) -> FavoriteRelationshipData.fromFavoriteRelationship(relationship) } - val favoritesJson = gson.toJson(data) - - // Use public methods instead of reflection to access encrypted preferences stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) - Log.d(TAG, "Saved ${favorites.size} favorite relationships") } catch (e: Exception) { Log.e(TAG, "Failed to save favorites: ${e.message}") } } + private fun loadPeerIdIndex() { + try { + val json = stateManager.getSecureValue(PEERID_INDEX_KEY) + if (json != null) { + val type = object : TypeToken>() {}.type + val data: Map = gson.fromJson(json, type) + peerIdIndex.clear() + data.forEach { (peerID, npub) -> + val normalizedPeerID = peerID.lowercase() + if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) { + peerIdIndex[normalizedPeerID] = npub + } + } + Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load peerID index: ${e.message}") + } + } + + private fun savePeerIdIndex() { + try { + val json = gson.toJson(peerIdIndex) + stateManager.storeSecureValue(PEERID_INDEX_KEY, json) + Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") + } catch (e: Exception) { + Log.e(TAG, "Failed to save peerID index: ${e.message}") + } + } + // MARK: - Listeners fun addListener(listener: FavoritesChangeListener) { - synchronized(listeners) { - if (!listeners.contains(listener)) listeners.add(listener) - } + synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) } } fun removeListener(listener: FavoritesChangeListener) { synchronized(listeners) { listeners.remove(listener) } } private fun notifyChanged(noiseKeyHex: String) { + runCatching { AppStateStore.canonicalizePrivateChats() } val snapshot = synchronized(listeners) { listeners.toList() } snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } } } @@ -298,27 +368,9 @@ class FavoritesPersistenceService private constructor(private val context: Conte val snapshot = synchronized(listeners) { listeners.toList() } snapshot.forEach { runCatching { it.onAllCleared() } } } - - /** - * Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison - */ - private fun normalizeNostrKeyToHex(value: String): String? { - return try { - if (value.startsWith("npub1")) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value) - if (hrp != "npub") return null - data.joinToString("") { "%02x".format(it) } - } else { - // Assume hex - value.lowercase() - } - } catch (_: Exception) { null } - } } -/** - * Serializable data class for JSON storage - */ +/** Serializable data for JSON storage */ private data class FavoriteRelationshipData( val peerNoisePublicKeyHex: String, val peerNostrPublicKey: String?, @@ -331,7 +383,7 @@ private data class FavoriteRelationshipData( companion object { fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData { return FavoriteRelationshipData( - peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }, + peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey), peerNostrPublicKey = relationship.peerNostrPublicKey, peerNickname = relationship.peerNickname, isFavorite = relationship.isFavorite, @@ -341,9 +393,9 @@ private data class FavoriteRelationshipData( ) } } - + fun toFavoriteRelationship(): FavoriteRelationship { - val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0) return FavoriteRelationship( peerNoisePublicKey = noiseKeyBytes, peerNostrPublicKey = peerNostrPublicKey, diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt new file mode 100644 index 00000000..da6898e8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -0,0 +1,404 @@ +package com.bitchat.android.features.file + +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.util.Log +import androidx.core.content.FileProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.* + +object FileUtils { + + private const val TAG = "FileUtils" + + /** + * Save a file from URI to app's file directory with unique filename + */ + fun saveFileFromUri( + context: Context, + uri: Uri, + originalName: String? = null + ): String? { + return try { + val inputStream = context.contentResolver.openInputStream(uri) + if (inputStream == null) { + Log.e(TAG, "❌ Failed to open input stream for URI: $uri") + return null + } + Log.d(TAG, "📂 Opened input stream successfully") + + // Determine file extension + val extension = originalName?.substringAfterLast(".") ?: "bin" + val fileName = "file_${System.currentTimeMillis()}.$extension" + + // Create incoming dir if needed + val incomingDir = File(context.filesDir, "files/incoming").apply { + if (!exists()) mkdirs() + } + + val file = File(incomingDir, fileName) + + inputStream.use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } + + Log.d(TAG, "Saved file to: ${file.absolutePath}") + file.absolutePath + + } catch (e: Exception) { + Log.e(TAG, "Failed to save file from URI", e) + null + } + } + + /** + * Copy file to app's outgoing directory for sending + */ + fun copyFileForSending(context: Context, uri: Uri, originalName: String? = null): String? { + Log.d(TAG, "🔄 Starting file copy from URI: $uri") + return try { + val inputStream = context.contentResolver.openInputStream(uri) + if (inputStream == null) { + Log.e(TAG, "❌ Failed to open input stream for URI: $uri") + return null + } + Log.d(TAG, "📂 Opened input stream successfully") + + // Determine original filename and extension if available + val displayName = originalName ?: run { + try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null + } + } catch (_: Exception) { null } + } + val extension = displayName?.substringAfterLast('.', missingDelimiterValue = "")?.takeIf { it.isNotBlank() } + ?: run { + // Try mime type to extension + val mime = try { context.contentResolver.getType(uri) } catch (_: Exception) { null } + android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin" + } + // Preserve original filename (without artificial prefixes), ensure uniqueness + val baseName = displayName?.substringBeforeLast('.')?.take(64)?.replace(Regex("[^A-Za-z0-9._-]"), "_") + ?: "file" + var fileName = if (extension.isNotBlank()) "$baseName.$extension" else baseName + + // Create outgoing dir if needed + val outgoingDir = File(context.filesDir, "files/outgoing").apply { + if (!exists()) mkdirs() + } + + var target = File(outgoingDir, fileName) + if (target.exists()) { + var idx = 1 + val pureBase = baseName + val dotExt = if (extension.isNotBlank()) ".${extension}" else "" + while (target.exists() && idx < 1000) { + fileName = "$pureBase ($idx)$dotExt" + target = File(outgoingDir, fileName) + idx++ + } + } + + inputStream.use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + } + } + + Log.d(TAG, "✅ Successfully copied file for sending: ${target.absolutePath}") + Log.d(TAG, "📊 Final file size: ${target.length()} bytes") + target.absolutePath + + } catch (e: Exception) { + Log.e(TAG, "❌ CRITICAL: Failed to copy file for sending", e) + Log.e(TAG, "❌ Source URI: $uri") + Log.e(TAG, "❌ Original name: $originalName") + Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}") + null + } + } + + /** + * Get MIME type for a file based on extension + */ + fun getMimeTypeFromExtension(fileName: String): String { + return when (fileName.substringAfterLast(".", "").lowercase()) { + "pdf" -> "application/pdf" + "doc" -> "application/msword" + "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + "xls" -> "application/vnd.ms-excel" + "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + "ppt" -> "application/vnd.ms-powerpoint" + "pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation" + "txt" -> "text/plain" + "json" -> "application/json" + "xml" -> "application/xml" + "csv" -> "text/csv" + "html", "htm" -> "text/html" + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "bmp" -> "image/bmp" + "webp" -> "image/webp" + "svg" -> "image/svg+xml" + "mp3" -> "audio/mpeg" + "wav" -> "audio/wav" + "m4a" -> "audio/mp4" + "mp4" -> "video/mp4" + "avi" -> "video/x-msvideo" + "mov" -> "video/quicktime" + "zip" -> "application/zip" + "rar" -> "application/vnd.rar" + "7z" -> "application/x-7z-compressed" + else -> "application/octet-stream" + } + } + + /** + * Format file size for display + */ + fun formatFileSize(bytes: Long): String { + val units = arrayOf("B", "KB", "MB", "GB") + var size = bytes.toDouble() + var unitIndex = 0 + while (size >= 1024 && unitIndex < units.size - 1) { + size /= 1024.0 + unitIndex++ + } + return "%.1f %s".format(size, units[unitIndex]) + } + + /** + * Check if file is viewable in system viewer + */ + fun isFileViewable(fileName: String): Boolean { + val extension = fileName.substringAfterLast(".", "").lowercase() + return extension in listOf( + "pdf", "txt", "json", "xml", "html", "htm", "csv", + "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg" + ) + } + + /** + * Save an incoming file packet to app storage and return absolute path. + * Mirrors existing behavior used in MessageHandler (preserves names and folders). + */ + fun saveIncomingFile( + context: Context, + file: com.bitchat.android.model.BitchatFilePacket + ): String { + val lowerMime = file.mimeType.lowercase() + val isImage = lowerMime.startsWith("image/") + // FIX: Use cacheDir instead of filesDir to prevent storage exhaustion attacks (Issue #592) + // Files in cacheDir are eligible for automatic system cleanup when space is low + val baseDir = context.cacheDir + val subdir = if (isImage) "images/incoming" else "files/incoming" + val dir = java.io.File(baseDir, subdir).apply { mkdirs() } + + fun extFromMime(m: String): String = when (m.lowercase()) { + "image/jpeg", "image/jpg" -> ".jpg" + "image/png" -> ".png" + "image/webp" -> ".webp" + "application/pdf" -> ".pdf" + "text/plain" -> ".txt" + else -> if (isImage) ".jpg" else ".bin" + } + + // Prefer transmitted original name; ensure uniqueness to avoid overwrites + val baseName = (file.fileName.takeIf { it.isNotBlank() } + ?: (if (isImage) "img" else "file")) + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val ext = extFromMime(lowerMime) + var safeName = if (baseName.contains('.')) baseName else baseName + ext + var idx = 1 + while (java.io.File(dir, safeName).exists() && idx < 1000) { + val dot = safeName.lastIndexOf('.') + safeName = if (dot > 0) { + val b = safeName.substring(0, dot) + val e = safeName.substring(dot) + "$b ($idx)$e" + } else { + "$safeName ($idx)" + } + idx++ + } + + return try { + val out = java.io.File(dir, safeName) + out.outputStream().use { it.write(file.content) } + out.absolutePath + } catch (_: Exception) { + // Fallback to cache dir with uniqueness + try { + var fallback = safeName + var idx2 = 1 + while (java.io.File(context.cacheDir, fallback).exists() && idx2 < 1000) { + val dot = fallback.lastIndexOf('.') + fallback = if (dot > 0) { + val b = fallback.substring(0, dot) + val e = fallback.substring(dot) + "$b ($idx2)$e" + } else { + "$fallback ($idx2)" + } + idx2++ + } + val out = java.io.File(context.cacheDir, fallback) + out.outputStream().use { it.write(file.content) } + out.absolutePath + } catch (_: Exception) { + val tmp = java.io.File.createTempFile(if (isImage) "img_" else "file_", if (isImage) ".jpg" else ".bin") + tmp.writeBytes(file.content) + tmp.absolutePath + } + } + } + + /** + * Classify BitchatMessageType from MIME string used in file messages. + */ + fun messageTypeForMime(mime: String): com.bitchat.android.model.BitchatMessageType { + val lower = mime.lowercase() + return when { + lower.startsWith("image/") -> com.bitchat.android.model.BitchatMessageType.Image + lower.startsWith("audio/") -> com.bitchat.android.model.BitchatMessageType.Audio + else -> com.bitchat.android.model.BitchatMessageType.File + } + } + + /** + * Recursively delete all media files (incoming and outgoing) + * Used for Panic Mode cleanup + */ + fun clearAllMedia(context: Context) { + try { + // Clear files dir subdirectories (legacy storage and outgoing) + val filesDir = context.filesDir + val dirsToClear = listOf( + "files/incoming", + "files/outgoing", + "images/incoming", + "images/outgoing", + "voicenotes" + ) + + dirsToClear.forEach { subDir -> + val dir = File(filesDir, subDir) + if (dir.exists()) { + dir.deleteRecursively() + Log.d(TAG, "Deleted media directory from filesDir: $subDir") + } + } + + // Clear cache dir subdirectories (new incoming storage) + // Note: cacheDir.deleteRecursively() below would handle this, but being explicit ensures these + // specific media folders are targeted even if full cache clear fails or is modified later. + val cacheDir = context.cacheDir + val cacheDirsToClear = listOf( + "files/incoming", + "images/incoming" + ) + + cacheDirsToClear.forEach { subDir -> + val dir = File(cacheDir, subDir) + if (dir.exists()) { + dir.deleteRecursively() + Log.d(TAG, "Deleted media directory from cacheDir: $subDir") + } + } + + // Also clear entire cache dir as a catch-all + context.cacheDir.deleteRecursively() + Log.d(TAG, "Cleared entire cache directory") + + } catch (e: Exception) { + Log.e(TAG, "Failed to clear media files", e) + } + } + + /** + * Delete app-owned media referenced only by the conversation being removed. + * + * Canonical-path checks prevent message content from turning this into an arbitrary-file + * deletion primitive. Shared paths remain intact while any retained message still references + * them. + */ + fun deleteConversationMedia( + context: Context, + deletedMessages: Collection, + retainedMessages: Collection + ) { + 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) { + 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") + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt b/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt new file mode 100644 index 00000000..18104cd4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt @@ -0,0 +1,111 @@ +package com.bitchat.android.features.media + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +object ImageUtils { + fun downscaleAndSaveToAppFiles(context: Context, uri: Uri, maxDim: Int = 512, quality: Int = 85): String? { + return try { + val resolver = context.contentResolver + val exifRotation = resolver.openInputStream(uri)?.use { getRotationDegreesFromExif(it) } ?: 0 + + // Reopen for decode as the previous stream is consumed + val input = resolver.openInputStream(uri) ?: return null + val original = BitmapFactory.decodeStream(input) + input.close() + original ?: return null + + val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original + + val w = oriented.width + val h = oriented.height + val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f) + val newW = (w / scale).toInt().coerceAtLeast(1) + val newH = (h / scale).toInt().coerceAtLeast(1) + val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented + val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() } + val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg") + FileOutputStream(outFile).use { fos -> + scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos) + } + try { if (oriented !== original) original.recycle() } catch (_: Exception) {} + try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {} + outFile.absolutePath + } catch (e: Exception) { + null + } + } + + fun downscalePathAndSaveToAppFiles(context: Context, path: String, maxDim: Int = 512, quality: Int = 85): String? { + return try { + val original = BitmapFactory.decodeFile(path) ?: return null + val exifRotation = getRotationDegreesFromExif(path) + val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original + + val w = oriented.width + val h = oriented.height + val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f) + val newW = (w / scale).toInt().coerceAtLeast(1) + val newH = (h / scale).toInt().coerceAtLeast(1) + val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented + val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() } + val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg") + FileOutputStream(outFile).use { fos -> + scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos) + } + try { if (oriented !== original) original.recycle() } catch (_: Exception) {} + try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {} + outFile.absolutePath + } catch (e: Exception) { + null + } + } + + fun loadBitmapWithExifOrientation(path: String): Bitmap? { + return try { + val base = BitmapFactory.decodeFile(path) ?: return null + val rotation = getRotationDegreesFromExif(path) + if (rotation != 0) rotateBitmap(base, rotation) else base + } catch (_: Exception) { + null + } + } + + private fun rotateBitmap(src: Bitmap, degrees: Int): Bitmap { + return try { + val m = Matrix() + m.postRotate(degrees.toFloat()) + Bitmap.createBitmap(src, 0, 0, src.width, src.height, m, true).also { + try { src.recycle() } catch (_: Exception) {} + } + } catch (_: Exception) { + src + } + } + + private fun getRotationDegreesFromExif(path: String): Int = try { + val exif = ExifInterface(path) + orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + } catch (_: Exception) { 0 } + + private fun getRotationDegreesFromExif(stream: InputStream): Int = try { + val exif = ExifInterface(stream) + orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + } catch (_: Exception) { 0 } + + private fun orientationToDegrees(orientation: Int): Int = when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_ROTATE_180 -> 180 + ExifInterface.ORIENTATION_ROTATE_270 -> 270 + ExifInterface.ORIENTATION_TRANSPOSE -> 90 + ExifInterface.ORIENTATION_TRANSVERSE -> 270 + else -> 0 + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceCapture.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceCapture.kt new file mode 100644 index 00000000..a3214825 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceCapture.kt @@ -0,0 +1,362 @@ +package com.bitchat.android.features.voice + +import android.annotation.SuppressLint +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import android.media.MediaRecorder +import android.util.Log +import java.io.File +import java.nio.ByteOrder +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.math.sin + +/** Destination selected for one hold gesture. Calls must be safe from a capture thread. */ +fun interface LiveVoiceTarget { + fun send(packet: ByteArray) +} + +internal data class LiveVoiceCaptureStats( + val queuedPcmFrames: Int, + val encodedFrames: Int, + val dataPackets: Int, + val droppedOversizeFrames: Int, + val outboundPackets: Int, + val deliveredPackets: Int +) + +/** + * AudioRecord + MediaCodec capture used only when a live mesh route is available. + * + * Encoded access units are streamed as iOS-compatible burst packets while the same units are + * muxed into an ordinary `.m4a`, which the existing voice-note path sends on release. + */ +internal class LiveVoiceCapture( + private val outputDirectory: File, + private val target: LiveVoiceTarget, + private val burstID: ByteArray = VoiceBurstPacket.makeBurstID(), + /** Debug Mesh Lab uses a deterministic tone so physical tests never capture ambient audio. */ + private val syntheticPcm: Boolean = false +) { + companion object { + private const val TAG = "LiveVoiceCapture" + private const val SAMPLE_RATE = 16_000 + private const val CHANNEL_COUNT = 1 + private const val BIT_RATE = 16_000 + private const val SAMPLES_PER_AAC_FRAME = 1_024 + private const val MIN_VALID_DURATION_MS = 600L + private const val CODEC_TIMEOUT_US = 10_000L + private const val CODEC_INPUT_DEADLINE_MS = 1_000L + private const val SYNTHETIC_TONE_HZ = 440.0 + private const val SYNTHETIC_TONE_AMPLITUDE = 8_000 + private const val AAC_FRAME_DURATION_NS = 64_000_000L + private const val OUTBOUND_QUEUE_CAPACITY = 256 + private const val OUTBOUND_DRAIN_TIMEOUT_MS = 10_000L + } + + private val running = AtomicBoolean(false) + private val amplitude = AtomicInteger(0) + private val queuedPcmFrames = AtomicInteger(0) + private val encodedFrames = AtomicInteger(0) + private val outboundPackets = AtomicInteger(0) + private val deliveredPackets = AtomicInteger(0) + private val outboundQueue = LinkedBlockingQueue(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 + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt new file mode 100644 index 00000000..05223b48 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt @@ -0,0 +1,478 @@ +package com.bitchat.android.features.voice + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.services.AppStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileOutputStream +import java.util.Date +import java.util.TreeMap +import java.util.UUID + +enum class LiveVoiceScope { DIRECT_MESSAGE, PUBLIC_MESH } + +sealed interface LiveVoiceEvent { + val peerID: String + val burstID: String + val scope: LiveVoiceScope + + data class Started( + override val peerID: String, + override val burstID: String, + override val scope: LiveVoiceScope + ) : LiveVoiceEvent + + data class Finished( + override val peerID: String, + override val burstID: String, + override val scope: LiveVoiceScope, + val dataPackets: Int, + val frames: Int, + val bytes: Int, + val expectedPackets: Int?, + val missingPackets: Int, + val path: String + ) : LiveVoiceEvent + + data class Canceled( + override val peerID: String, + override val burstID: String, + override val scope: LiveVoiceScope + ) : LiveVoiceEvent + + data class Absorbed( + override val peerID: String, + override val burstID: String, + override val scope: LiveVoiceScope, + val finalizedPath: String + ) : LiveVoiceEvent +} + +/** Shared phone/Wear receiver: bounded assembly, live playback, bubble state and note absorption. */ +class LiveVoiceManager private constructor(private val context: Context) { + companion object { + private const val TAG = "LiveVoiceManager" + private const val MAX_CONCURRENT_ASSEMBLIES = 8 + private const val MAX_BURST_BYTES = 384 * 1_024 + private const val INBOUND_BYTES_PER_SECOND = 6_000 + private const val MAX_BUFFERED_PACKETS = 128 + private const val GAP_SKIP_MS = 550L + private const val IDLE_TIMEOUT_MS = 3_000L + private const val FINISHED_TTL_MS = 10 * 60 * 1_000L + private const val FINISHED_CAP = 32 + + // The manager constructor immediately narrows this to context.applicationContext. + @SuppressLint("StaticFieldLeak") + @Volatile private var instance: LiveVoiceManager? = null + + fun getInstance(context: Context): LiveVoiceManager = instance ?: synchronized(this) { + instance ?: LiveVoiceManager(context.applicationContext).also { instance = it } + } + + fun burstIDFromVoiceFileName(fileName: String): String? { + if (!fileName.startsWith("voice_")) return null + val id = fileName.removePrefix("voice_").take(16) + return id.takeIf { value -> + value.length == 16 && value.all { it.digitToIntOrNull(16) != null } + }?.lowercase() + } + } + + private data class AssemblyKey( + val peerID: String, + val scope: LiveVoiceScope, + val burstID: String + ) + + private class Assembly( + val key: AssemblyKey, + val nickname: String, + val message: BitchatMessage, + var file: File, + val output: FileOutputStream, + val startedAtMs: Long, + val player: PttAudioPlayer? + ) { + val buffered = TreeMap>() + 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() + private val finishedBursts = linkedMapOf() + private val _liveMessageIDs = MutableStateFlow>(emptySet()) + val liveMessageIDs: StateFlow> = _liveMessageIDs.asStateFlow() + private val _activePublicTalker = MutableStateFlow(null) + val activePublicTalker: StateFlow = _activePublicTalker.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _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_.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() } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoicePreferences.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoicePreferences.kt new file mode 100644 index 00000000..ed3c3268 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoicePreferences.kt @@ -0,0 +1,22 @@ +package com.bitchat.android.features.voice + +import android.content.Context + +/** One preference gates live PTT sending and playback; finalized voice notes remain available. */ +object LiveVoicePreferences { + private const val PREFERENCES = "bitchat_settings" + private const val ENABLED = "ptt.liveVoiceEnabled" + + fun isEnabled(context: Context): Boolean = + context.applicationContext + .getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .getBoolean(ENABLED, true) + + fun setEnabled(context: Context, enabled: Boolean) { + context.applicationContext + .getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putBoolean(ENABLED, enabled) + .apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/PttAudioPlayer.kt b/app/src/main/java/com/bitchat/android/features/voice/PttAudioPlayer.kt new file mode 100644 index 00000000..d1897655 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/PttAudioPlayer.kt @@ -0,0 +1,156 @@ +package com.bitchat.android.features.voice + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.media.MediaCodec +import android.media.MediaFormat +import android.util.Log +import java.nio.ByteBuffer +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** Small jitter-buffered AAC player for foreground live bursts. */ +internal class PttAudioPlayer { + companion object { + private const val TAG = "PttAudioPlayer" + private const val SAMPLE_RATE = 16_000 + private const val FRAME_DURATION_US = 64_000L + private const val JITTER_FRAMES = 6 + private const val JITTER_DEADLINE_MS = 500L + private const val CODEC_TIMEOUT_US = 10_000L + } + + private val frames = LinkedBlockingQueue(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) { + 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() } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt b/app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt new file mode 100644 index 00000000..b8bc6e52 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt @@ -0,0 +1,220 @@ +package com.bitchat.android.features.voice + +import java.io.ByteArrayOutputStream +import java.security.SecureRandom + +/** iOS-compatible codec identifier carried by a live push-to-talk START packet. */ +enum class VoiceBurstCodec(val value: UByte) { + AAC_LC_16K_MONO(0x01u); + + companion object { + fun fromValue(value: UByte): VoiceBurstCodec? = entries.firstOrNull { it.value == value } + } +} + +/** + * One live push-to-talk packet. + * + * Wire format (shared with iOS): + * `[burstID: 8][seq: UInt16 BE][flags: UInt8][payload...]`. + */ +class VoiceBurstPacket private constructor( + val burstID: ByteArray, + val sequence: Int, + val kind: Kind +) { + sealed interface Kind { + data class Start(val codec: VoiceBurstCodec) : Kind + class Frames(val frames: List) : 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() + 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() + 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 { + val frameCost = 2 + frame.size + if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) { + droppedFrameCount++ + return emptyList() + } + val output = mutableListOf() + 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 { + 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) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt b/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt new file mode 100644 index 00000000..be2fe90e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt @@ -0,0 +1,109 @@ + +package com.bitchat.android.features.voice + +import android.content.Context +import android.media.MediaRecorder +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility. + * The resulting file has MIME audio/mp4. + */ +class VoiceRecorder( + private val context: Context, + private val liveTarget: LiveVoiceTarget? = null +) { + companion object { private const val TAG = "VoiceRecorder" } + + private var recorder: MediaRecorder? = null + private var liveCapture: LiveVoiceCapture? = null + private val _amplitude = MutableStateFlow(0) + val amplitude: StateFlow = _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 + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/VoiceVisualizer.kt b/app/src/main/java/com/bitchat/android/features/voice/VoiceVisualizer.kt new file mode 100644 index 00000000..9e1114b7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/VoiceVisualizer.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.features.voice + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.unit.dp +import kotlin.math.min + +@Composable +fun CyberpunkVisualizer(amplitude: Int, color: Color, modifier: Modifier = Modifier) { + val norm = min(1f, amplitude / 20_000f) + val heightFrac by animateFloatAsState( + targetValue = 0.1f + 0.9f * norm, + animationSpec = tween(120, easing = LinearEasing), label = "amp" + ) + Canvas( + modifier = modifier + .fillMaxWidth() + .height(48.dp) + ) { + val w = size.width + val h = size.height + val barCount = 24 + val gap = 6f + val bw = (w - gap * (barCount - 1)) / barCount + for (i in 0 until barCount) { + val phase = (i.toFloat() / barCount) + val barH = (0.2f + heightFrac * (0.8f * (0.5f + 0.5f * kotlin.math.sin(phase * Math.PI * 2).toFloat()))) * h + val x = i * (bw + gap) + val y = (h - barH) / 2f + drawRect(color.copy(alpha = 0.85f), topLeft = androidx.compose.ui.geometry.Offset(x, y), size = androidx.compose.ui.geometry.Size(bw, barH)) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt b/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt new file mode 100644 index 00000000..a6596099 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt @@ -0,0 +1,174 @@ +package com.bitchat.android.features.voice + +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.abs +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min + +object VoiceWaveformCache { + private val map = ConcurrentHashMap() + 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) + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt new file mode 100644 index 00000000..a6c72852 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.geohash + +import android.content.Context +import android.location.Address +import android.location.Geocoder +import android.os.Build +import android.util.Log +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class AndroidGeocoderProvider(context: Context) : GeocoderProvider { + private val geocoder = Geocoder(context, Locale.getDefault()) + private val TAG = "AndroidGeocoderProvider" + + override suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? + ): List
{ + 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
) { + 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() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt new file mode 100644 index 00000000..06439c80 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt @@ -0,0 +1,162 @@ +package com.bitchat.android.geohash + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.os.Looper +import android.util.Log +import androidx.core.app.ActivityCompat +import com.google.android.gms.location.* +import com.google.android.gms.tasks.CancellationTokenSource + +internal class FusedLocationProvider(private val context: Context) : LocationProvider { + + companion object { + private const val TAG = "FusedLocationProvider" + } + + private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) + + // Map to keep track of callbacks to remove them later + private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>() + private val activeCurrentLocationRequests = mutableSetOf() + + 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") + } + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt new file mode 100644 index 00000000..2c1e809c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt @@ -0,0 +1,19 @@ +package com.bitchat.android.geohash + +import android.content.Context +import android.location.Geocoder + +/** + * Factory to provide the best available geocoder. + */ +object GeocoderFactory { + fun get(context: Context): GeocoderProvider { + // If Google Play Services Geocoder is present, use it. + // Otherwise, fall back to OpenStreetMap. + return if (Geocoder.isPresent()) { + AndroidGeocoderProvider(context) + } else { + OpenStreetMapGeocoderProvider() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt new file mode 100644 index 00000000..34197565 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt @@ -0,0 +1,18 @@ +package com.bitchat.android.geohash + +import android.location.Address + +/** + * Interface for reverse geocoding providers. + */ +interface GeocoderProvider { + /** + * Get a list of Address objects from latitude and longitude. + */ + suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? = null + ): List
+} diff --git a/app/src/main/java/com/bitchat/android/geohash/Geohash.kt b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt index 0194fe70..00b2cdf3 100644 --- a/app/src/main/java/com/bitchat/android/geohash/Geohash.kt +++ b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt @@ -11,6 +11,8 @@ object Geohash { private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray() private val charToValue: Map = base32Chars.withIndex().associate { it.value to it.index } + data class Bounds(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double) + /** * Encodes the provided coordinates into a geohash string. * @param latitude Latitude in degrees (-90...90) @@ -69,14 +71,24 @@ object Geohash { * @return Pair(latitude, longitude) */ fun decodeToCenter(geohash: String): Pair { - if (geohash.isEmpty()) return 0.0 to 0.0 + val b = decodeToBounds(geohash) + val latCenter = (b.latMin + b.latMax) / 2 + val lonCenter = (b.lonMin + b.lonMax) / 2 + return latCenter to lonCenter + } + + /** + * Decodes a geohash string to bounding box (lat/lon min/max). + */ + fun decodeToBounds(geohash: String): Bounds { + if (geohash.isEmpty()) return Bounds(0.0, 0.0, 0.0, 0.0) var latInterval = -90.0 to 90.0 var lonInterval = -180.0 to 180.0 var isEven = true geohash.lowercase().forEach { ch -> - val cd = charToValue[ch] ?: return 0.0 to 0.0 + val cd = charToValue[ch] ?: return Bounds(0.0, 0.0, 0.0, 0.0) for (mask in intArrayOf(16, 8, 4, 2, 1)) { if (isEven) { val mid = (lonInterval.first + lonInterval.second) / 2 @@ -96,9 +108,43 @@ object Geohash { 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 - val lonCenter = (lonInterval.first + lonInterval.second) / 2 - return latCenter to lonCenter + /** + * Returns the 8 neighboring geohash cells at the same precision as the input. + * Neighbors include N, NE, E, SE, S, SW, W, NW, even when crossing parent cell boundaries. + */ + fun neighborsSamePrecision(geohash: String): Set { + 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() + 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 } } diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt new file mode 100644 index 00000000..92e237cb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -0,0 +1,247 @@ +package com.bitchat.android.geohash + +import android.content.Context +import android.location.Geocoder +import android.location.Location +import android.location.LocationManager +import android.util.Log +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale + +/** + * Stores a user-maintained list of bookmarked geohash channels. + * - Persistence: SharedPreferences (JSON string array) + * - Semantics: geohashes are normalized to lowercase base32 and de-duplicated + */ +class GeohashBookmarksStore private constructor(private val context: Context) { + + companion object { + private const val TAG = "GeohashBookmarksStore" + private const val STORE_KEY = "locationChannel.bookmarks" + private const val NAMES_STORE_KEY = "locationChannel.bookmarkNames" + + @Volatile private var INSTANCE: GeohashBookmarksStore? = null + fun getInstance(context: Context): GeohashBookmarksStore { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: GeohashBookmarksStore(context.applicationContext).also { INSTANCE = it } + } + } + + private val allowedChars = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() + fun normalize(raw: String): String { + return raw.trim().lowercase(Locale.US) + .replace("#", "") + .filter { allowedChars.contains(it) } + } + } + + private val gson = Gson() + private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE) + + private val membership = mutableSetOf() + + private val _bookmarks = MutableStateFlow>(emptyList()) + val bookmarks: StateFlow> = _bookmarks.asStateFlow() + + private val _bookmarkNames = MutableStateFlow>(emptyMap()) + val bookmarkNames: StateFlow> = _bookmarkNames.asStateFlow() + + // For throttling / preventing duplicate geocode lookups + private val resolving = mutableSetOf() + + 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>() {}.type + val arr = gson.fromJson>(arrJson, listType) + val seen = mutableSetOf() + val ordered = mutableListOf() + 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>() {}.type + val dict = gson.fromJson>(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() + 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) { + try { + val json = gson.toJson(list) + prefs.edit().putString(STORE_KEY, json).apply() + } catch (_: Exception) {} + } + + private fun persistNames(map: Map) { + try { + val json = gson.toJson(map) + prefs.edit().putString(NAMES_STORE_KEY, json).apply() + } catch (_: Exception) {} + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt new file mode 100644 index 00000000..26b9fd3c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt @@ -0,0 +1,24 @@ +package com.bitchat.android.geohash + +internal object GeohashNostrPrivacyPolicy { + fun livePresenceTargets( + availableChannels: Collection, + liveLocationEnabled: Boolean, + ): Set { + if (!liveLocationEnabled) return emptySet() + return availableChannels + .asSequence() + .filter { it.level.precision <= GeohashChannelLevel.CITY.precision } + .map { it.geohash } + .toSet() + } + + fun samplingTargets( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + liveLocationEnabled: Boolean, + ): Set = buildSet { + addAll(userSelectedGeohashes) + if (liveLocationEnabled) addAll(liveLocationGeohashes) + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt b/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt new file mode 100644 index 00000000..5a4f30fb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt @@ -0,0 +1,119 @@ +package com.bitchat.android.geohash + +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide, fail-closed consent gate for accessing live device location. + * + * A generation token prevents callbacks that were started under an older consent + * state from being accepted after location access is disabled or re-enabled. + */ +internal class LiveLocationAccessPolicy( + initialEnabled: Boolean = DEFAULT_LIVE_LOCATION_ENABLED, +) { + private val accessLock = ReentrantReadWriteLock() + private val generation = AtomicLong(0L) + private val _enabled = MutableStateFlow(initialEnabled) + private var accessAvailable = initialEnabled + + val enabled: StateFlow = _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 = 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) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt index aed974b6..a475713a 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt @@ -5,6 +5,7 @@ package com.bitchat.android.geohash * Direct port from iOS implementation for 100% compatibility */ enum class GeohashChannelLevel(val precision: Int, val displayName: String) { + BUILDING(8, "Building"), // iOS: precision 8 for building-level (used for Location Notes) BLOCK(7, "Block"), NEIGHBORHOOD(6, "Neighborhood"), CITY(5, "City"), @@ -35,7 +36,18 @@ data class GeohashChannel( */ sealed class 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. diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 7c645122..46f05950 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -2,20 +2,23 @@ package com.bitchat.android.geohash import android.Manifest import android.content.Context +import android.content.IntentFilter import android.content.pm.PackageManager -import android.location.Geocoder import android.location.Location -import android.location.LocationListener import android.location.LocationManager -import android.os.Bundle import android.util.Log import androidx.core.app.ActivityCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.bitchat.android.nostr.NostrIdentityBridge import kotlinx.coroutines.* -import java.util.* import com.google.gson.Gson import com.google.gson.JsonSyntaxException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn /** * Manages location permissions, one-shot location retrieval, and computing geohash channels. @@ -38,69 +41,121 @@ class LocationChannelManager private constructor(private val context: Context) { // State enum matching iOS enum class PermissionState { - NOT_DETERMINED, DENIED, - RESTRICTED, AUTHORIZED } + enum class LocationSelectionSource { + NEARBY, + MANUAL + } + private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - private val geocoder: Geocoder = Geocoder(context, Locale.getDefault()) - private var lastLocation: Location? = null - private var refreshTimer: Job? = null - private var isGeocoding: Boolean = false + private val locationProvider: LocationProvider + private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context) + private var geocodingJob: Job? = null private val gson = Gson() private var dataManager: com.bitchat.android.ui.DataManager? = null + private var selectedLocationSource: LocationSelectionSource? = null + private var activeLocationUpdateCallback: ((Location) -> Unit)? = null + + private fun checkSystemLocationEnabled(): Boolean { + return try { + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } catch (_: Exception) { + false + } + } + + private val locationStateReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: Context?, intent: android.content.Intent?) { + if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) { + val isEnabled = checkSystemLocationEnabled() + Log.d(TAG, "System location state changed: $isEnabled") + _systemLocationEnabled.value = isEnabled + if (!isEnabled) { + clearLiveLocationState() + } + } + } + } // Published state for UI bindings (matching iOS @Published properties) - private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED) - val permissionState: LiveData = _permissionState + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val _availableChannels = MutableLiveData>(emptyList()) - val availableChannels: LiveData> = _availableChannels + private val _permissionState = MutableStateFlow(PermissionState.DENIED) + val permissionState: StateFlow = _permissionState - private val _selectedChannel = MutableLiveData(ChannelID.Mesh) - val selectedChannel: LiveData = _selectedChannel + private val _availableChannels = MutableStateFlow>(emptyList()) + val availableChannels: StateFlow> = _availableChannels - private val _teleported = MutableLiveData(false) - val teleported: LiveData = _teleported + private val _selectedChannel = MutableStateFlow(ChannelID.Mesh) + val selectedChannel: StateFlow = _selectedChannel - private val _locationNames = MutableLiveData>(emptyMap()) - val locationNames: LiveData> = _locationNames + private val _teleported = MutableStateFlow(false) + val teleported: StateFlow = _teleported + + private val _locationNames = MutableStateFlow>(emptyMap()) + val locationNames: StateFlow> = _locationNames - // Add a new LiveData property to indicate when location is being fetched - private val _isLoadingLocation = MutableLiveData(false) - val isLoadingLocation: LiveData = _isLoadingLocation + private val _isLoadingLocation = MutableStateFlow(false) + val isLoadingLocation: StateFlow = _isLoadingLocation + + val locationServicesEnabled: StateFlow = LiveLocationPrivacyGate.enabled + + private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled()) + val systemLocationEnabled: StateFlow = _systemLocationEnabled + + val effectiveLocationEnabled: StateFlow = combine( + locationServicesEnabled, + systemLocationEnabled + ) { appToggle, systemToggle -> + appToggle && systemToggle + }.stateIn( + scope, + SharingStarted.Eagerly, + false + ) + init { - updatePermissionState() - // Initialize DataManager and load persisted channel selection + // Choose the best location provider + val availability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) + locationProvider = if (availability == ConnectionResult.SUCCESS) { + Log.i(TAG, "Using FusedLocationProvider (Google Play Services)") + FusedLocationProvider(context) + } else { + Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)") + SystemLocationProvider(context) + } + LiveLocationPrivacyGate.addRevocationListener(::cancelLiveLocationWork) + + // Initialize DataManager and load persisted settings dataManager = com.bitchat.android.ui.DataManager(context) + loadLocationServicesState() + syncPermissionState() + if (!_systemLocationEnabled.value) clearLiveLocationState() loadPersistedChannelSelection() + + // Register for system location changes + context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)) } // MARK: - Public API (matching iOS interface) /** * Enable location channels (request permission if needed) + * UNIFIED: Only requests location if location services are enabled by user */ fun enableLocationChannels() { - Log.d(TAG, "enableLocationChannels() called") - - when (getCurrentPermissionStatus()) { - PermissionState.NOT_DETERMINED -> { - Log.d(TAG, "Permission not determined - user needs to grant in app settings") - _permissionState.postValue(PermissionState.NOT_DETERMINED) - } - PermissionState.DENIED, PermissionState.RESTRICTED -> { - Log.d(TAG, "Permission denied or restricted") - _permissionState.postValue(PermissionState.DENIED) - } - PermissionState.AUTHORIZED -> { - Log.d(TAG, "Permission authorized - requesting location") - _permissionState.postValue(PermissionState.AUTHORIZED) - requestOneShotLocation() - } + if (!LiveLocationPrivacyGate.isEnabled || !_systemLocationEnabled.value) { + Log.w(TAG, "Location services disabled (app or system) - not requesting location") + return + } + + if (syncPermissionState() == PermissionState.AUTHORIZED) { + requestOneShotLocation() } } @@ -108,34 +163,50 @@ class LocationChannelManager private constructor(private val context: Context) { * Refresh available channels from current location */ fun refreshChannels() { - if (_permissionState.value == PermissionState.AUTHORIZED) { + if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) { requestOneShotLocation() } } /** - * Begin periodic one-shot location refreshes while a selector UI is visible + * Begin real-time location updates while a selector UI is visible + * Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately */ fun beginLiveRefresh(interval: Long = 5000L) { - Log.d(TAG, "Beginning live refresh with interval ${interval}ms") - - if (_permissionState.value != PermissionState.AUTHORIZED) { + if (syncPermissionState() != PermissionState.AUTHORIZED) { Log.w(TAG, "Cannot start live refresh - permission not authorized") return } - // Cancel existing timer - refreshTimer?.cancel() - - // Start new timer with coroutines - refreshTimer = CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - requestOneShotLocation() - delay(interval) + if (!isLocationServicesEnabled()) { + Log.w(TAG, "Cannot start live refresh - location services disabled") + return + } + + endLiveRefresh() + LiveLocationPrivacyGate.resumeAccess() + val token = LiveLocationPrivacyGate.captureToken() ?: return + val callback: (Location) -> Unit = { location -> + if (canUseLiveLocation(token)) { + onLocationUpdated(location, token) } } + activeLocationUpdateCallback = callback + + // Register for continuous updates from available provider + val started = LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.requestLocationUpdates( + intervalMs = interval, + minDistanceMeters = 5f, + callback = callback + ) + } + if (!started) { + activeLocationUpdateCallback = null + return + } - // Kick off immediately + // Prime state immediately with last known / current location requestOneShotLocation() } @@ -143,224 +214,236 @@ class LocationChannelManager private constructor(private val context: Context) { * Stop periodic refreshes when selector UI is dismissed */ fun endLiveRefresh() { - Log.d(TAG, "Ending live refresh") - refreshTimer?.cancel() - refreshTimer = null + activeLocationUpdateCallback?.let(locationProvider::removeLocationUpdates) + activeLocationUpdateCallback = null } /** - * Select a channel + * Generic selection is intentionally treated as manual. GPS-derived selections must use + * [selectNearby] so their provenance can be revoked when live location is disabled. */ fun select(channel: ChannelID) { - Log.d(TAG, "Selected channel: ${channel.displayName}") - // Use synchronous set to avoid race with background recomputation - _selectedChannel.value = channel - saveChannelSelection(channel) + when (channel) { + ChannelID.Mesh -> selectInternal(ChannelID.Mesh, source = null, teleported = false) + is ChannelID.Location -> selectManual(channel.channel) + } + } - // Immediately recompute teleported status against the latest known location - lastLocation?.let { location -> - when (channel) { - is ChannelID.Mesh -> { - _teleported.postValue(false) - } - is ChannelID.Location -> { - val currentGeohash = Geohash.encode( - latitude = location.latitude, - longitude = location.longitude, - precision = channel.channel.level.precision - ) - val isTeleportedNow = currentGeohash != channel.channel.geohash - _teleported.postValue(isTeleportedNow) - Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})") - } + fun selectNearby(channel: GeohashChannel): Boolean { + val isCurrentNearbyChannel = _availableChannels.value.contains(channel) + if (!isCurrentNearbyChannel || !isLocationServicesEnabled() || + syncPermissionState() != PermissionState.AUTHORIZED + ) { + Log.w(TAG, "Blocked nearby channel selection without live-location access") + return false + } + selectInternal( + ChannelID.Location(channel), + source = LocationSelectionSource.NEARBY, + teleported = false + ) + return true + } + + fun selectManual(channel: GeohashChannel, teleported: Boolean = true) { + selectInternal( + ChannelID.Location(channel), + source = LocationSelectionSource.MANUAL, + teleported = teleported || !LiveLocationPrivacyGate.isEnabled + ) + } + + /** + * Enable location services (user-controlled toggle) + */ + fun enableLocationServices() { + if (!LiveLocationPrivacyGate.isEnabled) { + LiveLocationPrivacyGate.update(true) + saveLocationServicesState(true) + } + + // If we have permission and system location is on, start location operations + if (syncPermissionState() == PermissionState.AUTHORIZED && systemLocationEnabled.value) { + requestOneShotLocation() + } + } + + /** + * Disable location services (user-controlled toggle) + */ + fun disableLocationServices() { + LiveLocationPrivacyGate.update(false) + saveLocationServicesState(false) + clearLiveLocationState(invalidateAccess = false) + } + + /** + * Check if location services are enabled by the user + */ + /** + * Check if both the app toggle and system location are enabled + */ + fun isLocationServicesEnabled(): Boolean { + return LiveLocationPrivacyGate.isEnabled && _systemLocationEnabled.value + } + + fun canUseSelectedLocationChannel(channel: GeohashChannel): Boolean { + if (_selectedChannel.value != ChannelID.Location(channel)) return false + return selectedLocationSource == LocationSelectionSource.MANUAL || + LiveLocationPrivacyGate.captureToken() != null + } + + fun isSelectedChannelLiveDerived(channel: GeohashChannel): Boolean = + _selectedChannel.value == ChannelID.Location(channel) && + selectedLocationSource == LocationSelectionSource.NEARBY + + fun liveLocationTokenForSelectedChannel(channel: GeohashChannel): Long? { + if (!isSelectedChannelLiveDerived(channel)) return null + return LiveLocationPrivacyGate.captureToken() + } + + private fun selectInternal( + channel: ChannelID, + source: LocationSelectionSource?, + teleported: Boolean + ) { + selectedLocationSource = source + _teleported.value = when (channel) { + ChannelID.Mesh -> false + is ChannelID.Location -> teleported + } + _selectedChannel.value = channel + saveChannelSelection(channel, source) + } + + /** + * Revokes all GPS-derived in-memory state and pending work. This deliberately does not + * change the persisted soft setting when Android's hard permission or system provider is + * temporarily unavailable. + */ + private fun clearLiveLocationState(invalidateAccess: Boolean = true) { + if (invalidateAccess) LiveLocationPrivacyGate.invalidate() + cancelLiveLocationWork() + _isLoadingLocation.value = false + NostrIdentityBridge.clearGeohashIdentityCache( + _availableChannels.value.map { it.geohash } + ) + _availableChannels.value = emptyList() + _locationNames.value = emptyMap() + + when { + _selectedChannel.value is ChannelID.Location && + selectedLocationSource == LocationSelectionSource.NEARBY -> { + selectInternal(ChannelID.Mesh, source = null, teleported = false) + } + _selectedChannel.value is ChannelID.Location -> { + // Manual channels remain usable for teleports, bookmarks, and DMs. Never use + // a previously retained GPS fix to classify them while live access is off. + selectedLocationSource = LocationSelectionSource.MANUAL + _teleported.value = true + saveChannelSelection(_selectedChannel.value, selectedLocationSource) } } } - - /** - * Set teleported status (for manual geohash teleportation) - */ - fun setTeleported(teleported: Boolean) { - Log.d(TAG, "Setting teleported status: $teleported") - _teleported.postValue(teleported) + + private fun cancelLiveLocationWork() { + locationProvider.cancel() + activeLocationUpdateCallback = null + geocodingJob?.cancel() + geocodingJob = null } // MARK: - Location Operations private fun requestOneShotLocation() { - if (!hasLocationPermission()) { + if (!isLocationServicesEnabled() || + syncPermissionState() != PermissionState.AUTHORIZED + ) { Log.w(TAG, "No location permission for one-shot request") return } - Log.d(TAG, "Requesting one-shot location") - - try { - // Try to get last known location from all available providers - var lastKnownLocation: Location? = null - - // Get all available providers and try each one - val providers = locationManager.getProviders(true) - for (provider in providers) { - val location = locationManager.getLastKnownLocation(provider) - if (location != null) { - // If we find a location, check if it's more recent than what we have - if (lastKnownLocation == null || location.time > lastKnownLocation.time) { - lastKnownLocation = location - } - } - } + LiveLocationPrivacyGate.resumeAccess() + val token = LiveLocationPrivacyGate.captureToken() ?: return + _isLoadingLocation.value = true - if (lastKnownLocation == null) { - lastKnownLocation = lastLocation; - } - - // Use last known location if we have one - if (lastKnownLocation != null) { - Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}") - lastLocation = lastKnownLocation - _isLoadingLocation.postValue(false) // Make sure loading state is off - computeChannels(lastKnownLocation) - reverseGeocodeIfNeeded(lastKnownLocation) - } else { - Log.d(TAG, "No last known location available") - // Set loading state to true so UI can show a spinner - _isLoadingLocation.postValue(true) - - // Request a fresh location only when we don't have a last known location - Log.d(TAG, "Requesting fresh location...") - requestFreshLocation() - } - } catch (e: SecurityException) { - Log.e(TAG, "Security exception requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error - updatePermissionState() - } - } - - // One-time location listener to get a fresh location update - private val oneShotLocationListener = object : LocationListener { - override fun onLocationChanged(location: Location) { - Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}") - lastLocation = location - computeChannels(location) - reverseGeocodeIfNeeded(location) - - // Update loading state to indicate we have a location now - _isLoadingLocation.postValue(false) - - // Remove this listener after getting the update - try { - locationManager.removeUpdates(this) - } catch (e: SecurityException) { - Log.e(TAG, "Error removing location listener: ${e.message}") - } - } - } - - // Request a fresh location update using getCurrentLocation instead of continuous updates - private fun requestFreshLocation() { - if (!hasLocationPermission()) { - _isLoadingLocation.postValue(false) // Turn off loading state if no permission - return - } - - try { - // Set loading state to true to indicate we're actively trying to get a location - _isLoadingLocation.postValue(true) - - // Try common providers in order of preference - 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, "Getting current location from $provider") - - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { - // For Android 11+ (API 30+), use getCurrentLocation - locationManager.getCurrentLocation( - provider, - null, // No cancellation signal - context.mainExecutor, - { location -> - if (location != null) { - Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}") - lastLocation = location - computeChannels(location) - reverseGeocodeIfNeeded(location) - } else { - Log.w(TAG, "Received null location from getCurrentLocation") - } - // Update loading state to indicate we have a location now - _isLoadingLocation.postValue(false) + val started = LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.getLastKnownLocation { cached -> + if (!canUseLiveLocation(token)) return@getLastKnownLocation + + if (cached != null) { + onLocationUpdated(cached, token) + } else { + LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.requestFreshLocation { fresh -> + if (!canUseLiveLocation(token)) return@requestFreshLocation + + if (fresh != null) { + onLocationUpdated(fresh, token) + } else { + Log.w(TAG, "Failed to get fresh location") + _isLoadingLocation.value = false } - ) - } else { - // For older versions, fall back to one-shot requestSingleUpdate - locationManager.requestSingleUpdate( - provider, - oneShotLocationListener, - null // Looper - null uses the main thread - ) + } } - - providerFound = true - break } } - - // If no provider was available, turn off loading state - if (!providerFound) { - Log.w(TAG, "No location providers available") - _isLoadingLocation.postValue(false) - } - } catch (e: SecurityException) { - Log.e(TAG, "Security exception requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error - } catch (e: Exception) { - Log.e(TAG, "Error requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error + } + if (!started) _isLoadingLocation.value = false + } + + private fun onLocationUpdated(location: Location, token: Long) { + LiveLocationPrivacyGate.runIfAllowed(token) { + if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed + _isLoadingLocation.value = false + computeChannels(location, token) + reverseGeocodeIfNeeded(location, token) } } + // MARK: - Helpers - private fun getCurrentPermissionStatus(): PermissionState { - return when { - ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> { - PermissionState.AUTHORIZED - } - ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED -> { - PermissionState.AUTHORIZED - } - else -> { - PermissionState.DENIED // In Android, we can't distinguish between denied and not determined after first ask - } + private fun hasRuntimeLocationPermission(): Boolean { + return ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + fun syncPermissionState(): PermissionState { + val newState = if (hasRuntimeLocationPermission()) { + PermissionState.AUTHORIZED + } else { + PermissionState.DENIED } + + if (_permissionState.value != newState) { + _permissionState.value = newState + } + + if (newState == PermissionState.DENIED) { + clearLiveLocationState() + } + return newState } - private fun updatePermissionState() { - val newState = getCurrentPermissionStatus() - Log.d(TAG, "Permission state updated to: $newState") - _permissionState.postValue(newState) + private fun canUseLiveLocation(token: Long): Boolean { + return LiveLocationPrivacyGate.accepts(token) && + _systemLocationEnabled.value && + hasRuntimeLocationPermission() } - private fun hasLocationPermission(): Boolean { - return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || - ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED - } + private fun computeChannels(location: Location, token: Long) { + if (!canUseLiveLocation(token)) return - private fun computeChannels(location: Location) { - Log.d(TAG, "Computing channels for location: ${location.latitude}, ${location.longitude}") - val levels = GeohashChannelLevel.allCases() val result = mutableListOf() - + for (level in levels) { val geohash = Geohash.encode( latitude = location.latitude, @@ -368,67 +451,57 @@ class LocationChannelManager private constructor(private val context: Context) { precision = level.precision ) result.add(GeohashChannel(level = level, geohash = geohash)) - - Log.v(TAG, "Generated ${level.displayName}: $geohash") } + + if (!canUseLiveLocation(token)) return + _availableChannels.value = result - _availableChannels.postValue(result) - - // Recompute teleported status based on current location vs selected channel val selectedChannelValue = _selectedChannel.value - when (selectedChannelValue) { - is ChannelID.Mesh -> { - _teleported.postValue(false) - } - is ChannelID.Location -> { - val currentGeohash = Geohash.encode( - latitude = location.latitude, - longitude = location.longitude, - precision = selectedChannelValue.channel.level.precision - ) - val isTeleported = currentGeohash != selectedChannelValue.channel.geohash - _teleported.postValue(isTeleported) - Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})") - } - null -> { - _teleported.postValue(false) - } + if (selectedChannelValue is ChannelID.Location && + selectedLocationSource == LocationSelectionSource.NEARBY + ) { + val currentGeohash = Geohash.encode( + latitude = location.latitude, + longitude = location.longitude, + precision = selectedChannelValue.channel.level.precision + ) + _teleported.value = currentGeohash != selectedChannelValue.channel.geohash + } else if (selectedChannelValue is ChannelID.Mesh) { + _teleported.value = false } } - private fun reverseGeocodeIfNeeded(location: Location) { - if (!Geocoder.isPresent()) { - Log.w(TAG, "Geocoder not present on this device") - return - } - - if (isGeocoding) { - Log.d(TAG, "Already geocoding, skipping") - return - } + private fun reverseGeocodeIfNeeded(location: Location, token: Long) { + if (!canUseLiveLocation(token)) return + geocodingJob?.cancel() - isGeocoding = true - - CoroutineScope(Dispatchers.IO).launch { + geocodingJob = scope.launch(Dispatchers.IO) { try { - Log.d(TAG, "Starting reverse geocoding") - - @Suppress("DEPRECATION") - val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1) - - if (!addresses.isNullOrEmpty()) { + if (!canUseLiveLocation(token)) return@launch + val addresses = geocoderProvider.getFromLocation( + location.latitude, + location.longitude, + 1, + liveLocationToken = token + ) + + if (!isActive || !canUseLiveLocation(token)) return@launch + + if (addresses.isNotEmpty()) { val address = addresses[0] val names = namesByLevel(address) - - Log.d(TAG, "Reverse geocoding result: $names") - _locationNames.postValue(names) + LiveLocationPrivacyGate.runIfAllowed(token) { + if (_systemLocationEnabled.value && hasRuntimeLocationPermission()) { + _locationNames.value = names + } + } } else { Log.w(TAG, "No reverse geocoding results") } } catch (e: Exception) { - Log.e(TAG, "Reverse geocoding failed: ${e.message}") - } finally { - isGeocoding = false + if (e !is CancellationException) { + Log.e(TAG, "Reverse geocoding failed") + } } } } @@ -441,11 +514,13 @@ class LocationChannelManager private constructor(private val context: Context) { dict[GeohashChannelLevel.REGION] = it } - // Province (state/province or county) + // Province (state/province or county or city) address.adminArea?.takeIf { it.isNotEmpty() }?.let { dict[GeohashChannelLevel.PROVINCE] = it } ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let { dict[GeohashChannelLevel.PROVINCE] = it + } ?: address.locality?.takeIf { it.isNotEmpty() }?.let { + dict[GeohashChannelLevel.PROVINCE] = it } // City (locality) @@ -479,26 +554,25 @@ class LocationChannelManager private constructor(private val context: Context) { /** * Save current channel selection to persistent storage */ - private fun saveChannelSelection(channel: ChannelID) { + private fun saveChannelSelection( + channel: ChannelID, + source: LocationSelectionSource? + ) { try { val channelData = when (channel) { - is ChannelID.Mesh -> { - gson.toJson(mapOf("type" to "mesh")) - } - is ChannelID.Location -> { - gson.toJson(mapOf( - "type" to "location", - "level" to channel.channel.level.name, - "precision" to channel.channel.level.precision, - "geohash" to channel.channel.geohash, - "displayName" to channel.channel.level.displayName - )) - } + is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true)) + is ChannelID.Location -> gson.toJson( + PersistedChannel( + mesh = false, + level = channel.channel.level.name, + geohash = channel.channel.geohash, + source = source?.name + ) + ) } dataManager?.saveLastGeohashChannel(channelData) - Log.d(TAG, "Saved channel selection: ${channel.displayName}") } catch (e: Exception) { - Log.e(TAG, "Failed to save channel selection: ${e.message}") + Log.e(TAG, "Failed to save channel selection") } } @@ -508,87 +582,113 @@ class LocationChannelManager private constructor(private val context: Context) { private fun loadPersistedChannelSelection() { try { val channelData = dataManager?.loadLastGeohashChannel() - if (channelData != null) { - val channelMap = gson.fromJson(channelData, Map::class.java) as? Map - if (channelMap != null) { - val channel = when (channelMap["type"] as? String) { - "mesh" -> ChannelID.Mesh - "location" -> { - val levelName = channelMap["level"] as? String - val precision = (channelMap["precision"] as? Double)?.toInt() - val geohash = channelMap["geohash"] as? String - val displayName = channelMap["displayName"] as? String - - if (levelName != null && precision != null && geohash != null && displayName != null) { - try { - val level = GeohashChannelLevel.valueOf(levelName) - val geohashChannel = GeohashChannel(level, geohash) - ChannelID.Location(geohashChannel) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "Invalid geohash level in persisted data: $levelName") - null - } - } else { - Log.w(TAG, "Incomplete location channel data in persistence") - null - } - } - else -> { - Log.w(TAG, "Unknown channel type in persisted data: ${channelMap["type"]}") - null - } - } - - if (channel != null) { - _selectedChannel.postValue(channel) - Log.d(TAG, "Restored persisted channel: ${channel.displayName}") - } else { - Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") - _selectedChannel.postValue(ChannelID.Mesh) - } + if (!channelData.isNullOrBlank()) { + val persisted = gson.fromJson(channelData, PersistedChannel::class.java) + val channel = persisted?.toChannel() + val source = persisted?.selectionSource() + val canRestore = channel !is ChannelID.Location || + source == LocationSelectionSource.MANUAL || + (LiveLocationPrivacyGate.isEnabled && + _systemLocationEnabled.value && + _permissionState.value == PermissionState.AUTHORIZED) + + if (channel != null && canRestore) { + _selectedChannel.value = channel + selectedLocationSource = if (channel is ChannelID.Location) source else null + _teleported.value = channel is ChannelID.Location && + source == LocationSelectionSource.MANUAL } else { - Log.w(TAG, "Invalid channel data format in persistence") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null + _teleported.value = false + saveChannelSelection(ChannelID.Mesh, source = null) } } else { - Log.d(TAG, "No persisted channel found, defaulting to Mesh") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null } } catch (e: JsonSyntaxException) { - Log.e(TAG, "Failed to parse persisted channel data: ${e.message}") - _selectedChannel.postValue(ChannelID.Mesh) + Log.e(TAG, "Failed to parse persisted channel data") + _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null } catch (e: Exception) { - Log.e(TAG, "Failed to load persisted channel: ${e.message}") - _selectedChannel.postValue(ChannelID.Mesh) + Log.e(TAG, "Failed to load persisted channel") + _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null } } - + + data class PersistedChannel( + val mesh: Boolean, + val level: String? = null, + val geohash: String? = null, + val source: String? = null + ) { + fun toChannel(): ChannelID? { + return if (mesh) { + ChannelID.Mesh + } else { + val levelName = level ?: return null + val gh = geohash ?: return null + ChannelID.Location.fromPersisted(levelName, gh) + } + } + + fun selectionSource(): LocationSelectionSource? { + if (mesh) return null + return source?.let { + runCatching { LocationSelectionSource.valueOf(it) }.getOrNull() + } ?: LocationSelectionSource.NEARBY + } + } + /** * Clear persisted channel selection (useful for testing or reset) */ fun clearPersistedChannel() { dataManager?.clearLastGeohashChannel() - _selectedChannel.postValue(ChannelID.Mesh) - Log.d(TAG, "Cleared persisted channel selection") + _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null + _teleported.value = false + } + + // MARK: - Location Services State Persistence + + /** + * Save location services enabled state to persistent storage + */ + private fun saveLocationServicesState(enabled: Boolean) { + try { + dataManager?.saveLocationServicesEnabled(enabled) + } catch (e: Exception) { + Log.e(TAG, "Failed to save location services state") + } + } + + /** + * Load persisted location services state from storage + */ + private fun loadLocationServicesState() { + try { + val enabled = dataManager?.isLocationServicesEnabled() ?: false + LiveLocationPrivacyGate.update(enabled) + } catch (e: Exception) { + Log.e(TAG, "Failed to load location services state") + LiveLocationPrivacyGate.update(false) + } } /** * Cleanup resources */ fun cleanup() { - Log.d(TAG, "Cleaning up LocationChannelManager") endLiveRefresh() + locationProvider.cancel() - // For older Android versions, remove any remaining location listener to prevent memory leaks - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { - try { - locationManager.removeUpdates(oneShotLocationListener) - } catch (e: SecurityException) { - Log.e(TAG, "Error removing location listener during cleanup: ${e.message}") - } catch (e: Exception) { - Log.e(TAG, "Error during cleanup: ${e.message}") - } - } - // For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation + geocodingJob?.cancel() + geocodingJob = null + + // Unregister receiver + try { context.unregisterReceiver(locationStateReceiver) } catch (_: Exception) {} } } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt new file mode 100644 index 00000000..f46837bd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.geohash + +import android.location.Location + +/** + * Abstraction for location providers to support both + * System (LocationManager) and Google Play Services (FusedLocationProvider). + */ +internal interface LocationProvider { + /** + * Get the last known location from cache. + * @param callback Called with the location or null if not found/error. + */ + fun getLastKnownLocation(callback: (Location?) -> Unit) + + /** + * Request a single, fresh location update. + * @param callback Called with the location or null if failed. + */ + fun requestFreshLocation(callback: (Location?) -> Unit) + + /** + * Request continuous location updates. + * @param intervalMs Desired interval in milliseconds. + * @param minDistanceMeters Minimum distance in meters. + * @param callback Called when location updates. + */ + fun requestLocationUpdates(intervalMs: Long, minDistanceMeters: Float, callback: (Location) -> Unit) + + /** + * Stop location updates. + * @param callback The same callback instance passed to requestLocationUpdates. + */ + fun removeLocationUpdates(callback: (Location) -> Unit) + + /** + * Cancel any pending one-shot location requests and cleanup resources. + */ + fun cancel() +} diff --git a/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt new file mode 100644 index 00000000..11be053e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt @@ -0,0 +1,132 @@ +package com.bitchat.android.geohash + +import android.location.Address +import android.util.Log +import com.bitchat.android.net.OkHttpProvider +import com.google.gson.Gson +import java.io.IOException +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import java.util.Locale +import kotlin.coroutines.resume + +class OpenStreetMapGeocoderProvider : GeocoderProvider { + private val TAG = "OSMGeocoderProvider" + private val gson = Gson() + private val userAgent = "Bitchat-Android/1.0" + + override suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? + ): List
{ + 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? + ) +} diff --git a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt new file mode 100644 index 00000000..80bbbe4a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt @@ -0,0 +1,261 @@ +package com.bitchat.android.geohash + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal +import android.util.Log +import androidx.core.app.ActivityCompat + +internal class SystemLocationProvider(private val context: Context) : LocationProvider { + + companion object { + private const val TAG = "SystemLocationProvider" + } + + private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + private val handler = android.os.Handler(android.os.Looper.getMainLooper()) + + // Map to keep track of listeners to unregister them later + private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>() + private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>() + private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>() + private val activeOneShotCancellationSignals = mutableMapOf<(Location?) -> Unit, CancellationSignal>() + + private fun hasLocationPermission(): Boolean { + return LiveLocationPrivacyGate.isEnabled && + (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + ) + } + + @SuppressLint("MissingPermission") + override fun getLastKnownLocation(callback: (Location?) -> Unit) { + if (!hasLocationPermission()) { + callback(null) + return + } + + try { + var bestLocation: Location? = null + val providers = locationManager.getProviders(true) + for (provider in providers) { + val location = locationManager.getLastKnownLocation(provider) + if (location != null) { + if (bestLocation == null || location.time > bestLocation.time) { + bestLocation = location + } + } + } + callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled }) + } catch (e: Exception) { + Log.e(TAG, "Error getting last-known location") + callback(null) + } + } + + @SuppressLint("MissingPermission") + override fun requestFreshLocation(callback: (Location?) -> Unit) { + if (!hasLocationPermission()) { + callback(null) + return + } + + try { + val providers = listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER + ) + + var providerFound = false + for (provider in providers) { + if (locationManager.isProviderEnabled(provider)) { + Log.d(TAG, "Requesting fresh location from $provider") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val cancellationSignal = CancellationSignal() + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals[callback] = cancellationSignal + } + try { + locationManager.getCurrentLocation( + provider, + cancellationSignal, + context.mainExecutor + ) { location -> + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals.remove(callback) + } + callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) + } + } catch (e: Exception) { + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals.remove(callback) + } + cancellationSignal.cancel() + throw e + } + } else { + // For older versions, use requestSingleUpdate with timeout mechanism + val timeoutRunnable = Runnable { + Log.w(TAG, "Location request timed out") + synchronized(activeOneShotListeners) { + val listener = activeOneShotListeners.remove(callback) + activeOneShotRunnables.remove(callback) + if (listener != null) { + try { + locationManager.removeUpdates(listener) + } catch (e: Exception) { + Log.e(TAG, "Error removing timed-out listener") + } + } + } + callback(null) + } + + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + synchronized(activeOneShotListeners) { + activeOneShotListeners.remove(callback) + val runnable = activeOneShotRunnables.remove(callback) + if (runnable != null) { + handler.removeCallbacks(runnable) + } + } + try { + locationManager.removeUpdates(this) + } catch (e: Exception) { + Log.e(TAG, "Error removing updates in callback") + } + callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) + } + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + override fun onProviderEnabled(provider: String) {} + override fun onProviderDisabled(provider: String) {} + } + + synchronized(activeOneShotListeners) { + activeOneShotListeners[callback] = listener + activeOneShotRunnables[callback] = timeoutRunnable + } + + locationManager.requestSingleUpdate(provider, listener, null) + handler.postDelayed(timeoutRunnable, 30000L) // 30s timeout + } + providerFound = true + break + } + } + + if (!providerFound) { + Log.w(TAG, "No location providers available for fresh location") + callback(null) + } + } catch (e: Exception) { + Log.e(TAG, "Error requesting fresh location") + callback(null) + } + } + + @SuppressLint("MissingPermission") + override fun requestLocationUpdates( + intervalMs: Long, + minDistanceMeters: Float, + callback: (Location) -> Unit + ) { + if (!hasLocationPermission()) return + + try { + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + if (LiveLocationPrivacyGate.isEnabled) callback(location) + } + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + override fun onProviderEnabled(provider: String) {} + override fun onProviderDisabled(provider: String) {} + } + + // Store the listener so we can remove it later + synchronized(activeListeners) { + activeListeners[callback] = listener + } + + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + var registered = false + + for (provider in providers) { + if (locationManager.isProviderEnabled(provider)) { + locationManager.requestLocationUpdates( + provider, + intervalMs, + minDistanceMeters, + listener + ) + registered = true + Log.d(TAG, "Registered updates for $provider") + } + } + + if (!registered) { + Log.w(TAG, "No providers enabled for continuous updates") + } + + } catch (e: Exception) { + Log.e(TAG, "Error requesting location updates") + } + } + + override fun removeLocationUpdates(callback: (Location) -> Unit) { + try { + val listener = synchronized(activeListeners) { + activeListeners.remove(callback) + } + + if (listener != null) { + locationManager.removeUpdates(listener) + Log.d(TAG, "Removed location updates") + } + } catch (e: Exception) { + Log.e(TAG, "Error removing updates") + } + } + + override fun cancel() { + try { + // Cancel continuous updates + synchronized(activeListeners) { + for ((_, listener) in activeListeners) { + try { locationManager.removeUpdates(listener) } catch (_: Exception) {} + } + activeListeners.clear() + } + + // Cancel one-shot requests + synchronized(activeOneShotListeners) { + for ((_, listener) in activeOneShotListeners) { + try { locationManager.removeUpdates(listener) } catch (_: Exception) {} + } + activeOneShotListeners.clear() + + for ((_, runnable) in activeOneShotRunnables) { + handler.removeCallbacks(runnable) + } + activeOneShotRunnables.clear() + } + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals.values.forEach { it.cancel() } + activeOneShotCancellationSignals.clear() + } + Log.d(TAG, "Cancelled all system location requests") + } catch (e: Exception) { + Log.e(TAG, "Error cancelling system provider") + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt new file mode 100644 index 00000000..140f2971 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt @@ -0,0 +1,323 @@ +package com.bitchat.android.hotspot + +import android.content.Context +import android.util.Log +import fi.iki.elonen.NanoHTTPD +import java.io.File +import java.io.FileInputStream + +/** + * Lightweight HTTP server for serving the universal APK over Wi-Fi P2P hotspot. + * Based on NanoHTTPD. + */ +class ApkWebServer( + private val context: Context, + private val apkFile: File, + private val port: Int = DEFAULT_PORT +) : NanoHTTPD(port) { + + companion object { + private const val TAG = "ApkWebServer" + const val DEFAULT_PORT = 9999 + } + + private val appVersion: String by lazy { + try { + context.packageManager + .getPackageArchiveInfo(apkFile.absolutePath, 0) + ?.versionName + ?: "Unknown" + } catch (e: Exception) { + "Unknown" + } + } + + // Cache the HTML landing page (generated once, reused for all requests) + private val cachedHtml: String by lazy { + generateLandingPageHtml() + } + + override fun serve(session: IHTTPSession): Response { + val uri = session.uri ?: "/" + + Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}") + + return when { + uri == "/bitchat.apk" -> { + serveApk() + } + uri == "/favicon.ico" -> { + newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found") + } + else -> { + serveLandingPage() + } + } + } + + /** + * Serve the APK file. + */ + private fun serveApk(): Response { + return try { + if (!apkFile.exists()) { + Log.e(TAG, "APK file not found: ${apkFile.path}") + return newFixedLengthResponse( + Response.Status.NOT_FOUND, + "text/plain", + "APK file not found" + ) + } + + Log.d(TAG, "Serving APK: ${apkFile.name} (${apkFile.length() / 1024 / 1024}MB)") + + val inputStream = FileInputStream(apkFile) + val response = newFixedLengthResponse( + Response.Status.OK, + "application/vnd.android.package-archive", + inputStream, + apkFile.length() + ) + + response.addHeader("Content-Disposition", "attachment; filename=\"bitchat-${appVersion}.apk\"") + response.addHeader("Accept-Ranges", "bytes") + + response + } catch (e: Exception) { + Log.e(TAG, "Error serving APK", e) + newFixedLengthResponse( + Response.Status.INTERNAL_ERROR, + "text/plain", + "Error serving APK: ${e.message}" + ) + } + } + + /** + * Serve the HTML landing page. + */ + private fun serveLandingPage(): Response { + return newFixedLengthResponse( + Response.Status.OK, + "text/html", + cachedHtml + ) + } + + /** + * Generate HTML landing page. + */ + private fun generateLandingPageHtml(): String { + val apkSizeMb = apkFile.length() / 1024 / 1024 + + return """ + + + + + + Download BitChat + + + +
+ +

BitChat

+

Secure Mesh Messaging

+ +
+
+
Version
+
$appVersion
+
+
+
Size
+
${apkSizeMb} MB
+
+
+ + + 📥 Download BitChat + + +
+

📱 Installation Instructions

+
    +
  1. Tap the download button above
  2. +
  3. Wait for the download to complete
  4. +
  5. Open the downloaded APK file
  6. +
  7. If prompted, enable "Install from unknown sources" for your browser
  8. +
  9. Follow the installation prompts
  10. +
+
+ +
+ ⚠️ Note: + 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. +
+
+ + + """.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) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt new file mode 100644 index 00000000..35d0f4ec --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -0,0 +1,706 @@ +package com.bitchat.android.hotspot + +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.BitchatTheme +import com.bitchat.android.util.UniversalApkManager +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import java.io.File + +/** + * Activity for managing Wi-Fi P2P hotspot for offline APK sharing. + * Pure Compose implementation, no fragments. + */ +class HotspotActivity : ComponentActivity() { + + companion object { + const val EXTRA_APK_PATH = "apk_path" + private const val TAG = "HotspotActivity" + } + + private val viewModel: HotspotViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get APK path from intent + val apkPath = intent.getStringExtra(EXTRA_APK_PATH) + val apkFile = if (apkPath != null) { + File(apkPath) + } else { + // Fallback: Try to get cached APK + UniversalApkManager(this).getCachedApk() + } + + if (apkFile == null || !apkFile.exists()) { + // No APK available, show error and finish + finish() + return + } + + setContent { + BitchatTheme { + HotspotScreen( + viewModel = viewModel, + apkFile = apkFile, + onClose = { finish() } + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Handle notification action to stop hotspot + if (intent.action == "STOP_HOTSPOT") { + viewModel.stopHotspot() + finish() + } + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HotspotScreen( + viewModel: HotspotViewModel, + apkFile: File, + onClose: () -> Unit +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Share BitChat", + fontFamily = BitchatFontFamily + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { padding -> + Crossfade( + targetState = state, + label = "HotspotStateCrossfade", + modifier = Modifier.padding(padding) + ) { currentState -> + when (currentState) { + is HotspotViewModel.HotspotState.Intro -> { + IntroScreen( + onStartHotspot = { viewModel.startHotspot(apkFile) } + ) + } + is HotspotViewModel.HotspotState.Starting -> { + LoadingScreen() + } + is HotspotViewModel.HotspotState.ConfirmDisconnect -> { + ExistingGroupConfirmation( + onConfirm = viewModel::confirmDisconnectAndStart, + onCancel = viewModel::cancelDisconnect + ) + } + is HotspotViewModel.HotspotState.Active -> { + ActiveHotspotScreen(state = currentState) + } + is HotspotViewModel.HotspotState.Error -> { + ErrorScreen( + message = currentState.message, + onRetry = { viewModel.resetToIntro() }, + onClose = onClose + ) + } + } + } + } +} + +@Composable +private fun ExistingGroupConfirmation( + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.hotspot_disconnect_title)) }, + text = { Text(stringResource(R.string.hotspot_disconnect_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.hotspot_disconnect_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun IntroScreen(onStartHotspot: () -> Unit) { + val requiredPermissions = remember { HotspotPermissions.requiredForSdk() } + val permissionState = rememberMultiplePermissionsState(requiredPermissions) { results -> + if (requiredPermissions.all { results[it] == true }) { + onStartHotspot() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Offline App Sharing", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "How it works:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + InfoItem("1. Your device creates a Wi-Fi hotspot") + InfoItem("2. Others connect to your hotspot") + InfoItem("3. They scan a QR code or enter a URL") + InfoItem("4. BitChat downloads directly to their device") + } + } + + // Permission rationale (if needed) + if (!permissionState.allPermissionsGranted && permissionState.shouldShowRationale) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "ℹ️ Permission Required", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = when { + Build.VERSION.SDK_INT >= HotspotPermissions.ANDROID_17_API_LEVEL -> + "BitChat needs nearby devices and local network access to create a Wi-Fi hotspot and serve the app to connected devices." + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> + "BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline." + else -> + "BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "⚠️ Note", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Text( + text = "This will create a Wi-Fi hotspot on your device. Your current Wi-Fi connection may be interrupted.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + // Check permission before starting hotspot + if (permissionState.allPermissionsGranted) { + // No permission needed or already granted + onStartHotspot() + } else { + // Request permission (auto-start handled by onPermissionResult callback) + permissionState.launchMultiplePermissionRequest() + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text( + // Starting the hotspot is the user's action. Android will ask + // for the required permission only when it has not already + // been granted. + text = "Start Hotspot", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +fun InfoItem(text: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } +} + +@Composable +fun LoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + Text( + text = "Starting hotspot...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} + +@Composable +fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) { + var selectedTab by remember { mutableStateOf(0) } + val tabs = listOf("Wi-Fi", "Website") + + Column( + modifier = Modifier.fillMaxSize() + ) { + // Status banner + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Hotspot Active", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "${state.connectedPeers} device(s) connected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + } + } + + // Tabs + TabRow( + selectedTabIndex = selectedTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal + ) + } + ) + } + } + + // Tab content + when (selectedTab) { + 0 -> WifiTabContent( + ssid = state.ssid, + password = state.password + ) + 1 -> WebsiteTabContent( + ipAddress = state.ipAddress, + port = state.port + ) + } + } +} + +@Composable +fun WifiTabContent(ssid: String, password: String) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "Step 1: Connect to Wi-Fi", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + text = "Have others scan this QR code to connect:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + // QR Code + val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() } + val wifiQr = remember(ssid, password, qrSize) { + QrCodeGenerator.generateWifiQr(ssid, password, qrSize) + } + + if (wifiQr != null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp) + ) { + Image( + bitmap = wifiQr.asImageBitmap(), + contentDescription = "Wi-Fi QR Code", + modifier = Modifier.size(280.dp) + ) + } + } + + Text( + text = "Or enter manually:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // SSID + CredentialCard( + label = "Network Name (SSID)", + value = ssid, + onCopy = { + clipboardManager.setText(AnnotatedString(ssid)) + } + ) + + // Password + CredentialCard( + label = "Password", + value = password, + onCopy = { + clipboardManager.setText(AnnotatedString(password)) + } + ) + } +} + +@Composable +fun WebsiteTabContent(ipAddress: String, port: Int) { + val url = "http://$ipAddress:$port" + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "Step 2: Download BitChat", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + text = "After connecting to the Wi-Fi, scan this QR code:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + // QR Code + val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() } + val urlQr = remember(url, qrSize) { + QrCodeGenerator.generateUrlQr(url, qrSize) + } + + if (urlQr != null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp) + ) { + Image( + bitmap = urlQr.asImageBitmap(), + contentDescription = "Website URL QR Code", + modifier = Modifier.size(280.dp) + ) + } + } + + Text( + text = "Or open in browser:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // URL + CredentialCard( + label = "Website URL", + value = url, + onCopy = { + clipboardManager.setText(AnnotatedString(url)) + } + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "📱 Instructions", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "1. Make sure you're connected to the Wi-Fi network above\n" + + "2. Open a web browser on your device\n" + + "3. Visit the URL above or scan the QR code\n" + + "4. Tap 'Download BitChat'\n" + + "5. Install the downloaded APK", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +fun CredentialCard( + label: String, + value: String, + onCopy: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + IconButton(onClick = onCopy) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } +} + +@Composable +fun ErrorScreen( + message: String, + onRetry: () -> Unit, + onClose: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "❌", + fontSize = 64.sp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Error", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) { + Text("Try Again") + } + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton(onClick = onClose) { + Text("Close") + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt new file mode 100644 index 00000000..a56182bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -0,0 +1,879 @@ +package com.bitchat.android.hotspot + +import android.Manifest +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.p2p.WifiP2pConfig +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pManager +import android.net.wifi.p2p.WifiP2pManager.* +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.util.Log +import java.net.NetworkInterface +import java.security.SecureRandom +import kotlin.random.Random + +/** + * Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing. + * Based on Briar's implementation. + */ +class HotspotManager(private val context: Context) { + + companion object { + private const val TAG = "HotspotMgr" + + // Group info polling interval + private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L + + // Give up if the group never forms within this window after creation succeeded + private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L + + // SSID and password configuration + private const val SSID_SUFFIX_LENGTH = 8 + private const val PASSWORD_LENGTH = 16 + + // Records the group we created so a later run can tell our own orphan apart + // from a group belonging to Cast, Android Auto or Quick Share. + private const val PREFS_NAME = "hotspot" + private const val KEY_OWNED_GROUP = "owned_group_name" + + // Characters to use for random generation (excluding confusing ones) + private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I + } + + private val wifiP2pManager: WifiP2pManager? = + context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager + + private var channel: Channel? = null + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null + + private val handler = Handler(Looper.getMainLooper()) + private val random = SecureRandom() + + private var currentGroup: WifiP2pGroup? = null + private var callback: HotspotCallback? = null + private var isStarting = false + private var hasNotifiedStarted = false // Track if we've notified the callback + private var isReceiverRegistered = false // Track receiver registration to prevent leaks + + // Set once our own createGroup command is accepted, and re-checked against every + // group snapshot afterwards. stopHotspot() only calls removeGroup() when this is + // true: removal is device-scoped, so issuing it when the group on the framework + // is not ours could only tear down another app's session (Cast, Android Auto, + // Quick Share). + private var createdGroup = false + + // Framework-reported name of the group this session hosts, once known. Null while + // the group is still forming, when a null snapshot carries no information. + private var hostedGroupName: String? = null + + // Name of the foreign group the user explicitly agreed to disconnect, or null. + // Consent is per-group: a group with a different name asks again. + private var confirmedReplacementName: String? = null + + // stopHotspot() can be reached again while its group query/removal is still in + // flight. Later callers wait for that same teardown instead of releasing the + // Wi-Fi Aware lease early. + private var teardownInProgress = false + private val teardownCallbacks = mutableListOf<() -> Unit>() + + // Saved credentials for reconnection + private var savedSsid: String? = null + private var savedPassword: String? = null + + // Last Wi-Fi P2P state seen on the broadcast, or null before the first one arrives + private var lastP2pState: Int? = null + + private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + + /** Network name of the last group this app created, surviving process death. */ + private var ownedGroupName: String? + get() = prefs.getString(KEY_OWNED_GROUP, null) + set(value) = prefs.edit().putString(KEY_OWNED_GROUP, value).apply() + + // Broadcast receiver for Wi-Fi P2P events + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { + val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) + lastP2pState = state + Log.d(TAG, "Wi-Fi P2P state changed: $state") + + // Wi-Fi Direct going away is terminal for this session: without it + // the group cannot form, and any group already up is now dead. + if (state == WIFI_P2P_STATE_DISABLED && (isStarting || hasNotifiedStarted)) { + Log.w(TAG, "Wi-Fi P2P was disabled; aborting hotspot") + failStartup(HotspotStartupPolicy.P2P_DISABLED_MESSAGE) + } + } + WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { + Log.d(TAG, "Wi-Fi P2P connection changed") + requestGroupInfo() + } + } + } + } + + /** + * Start the Wi-Fi P2P hotspot. + * + * @param confirmedReplacementName name of the foreign Wi-Fi Direct group the + * user has confirmed may be disconnected, as previously reported through + * [HotspotCallback.onExistingGroupConflict]. When null (or when the group + * present no longer matches), a foreign group is reported instead of touched. + */ + fun startHotspot(callback: HotspotCallback, confirmedReplacementName: String? = null) { + if (isStarting) { + Log.w(TAG, "Hotspot already starting") + return + } + + if (wifiP2pManager == null) { + Log.e(TAG, "Wi-Fi P2P not available on this device") + callback.onError("Wi-Fi Direct not supported on this device") + return + } + + val missingPermissions = HotspotPermissions.missingFrom(context) + if (missingPermissions.isNotEmpty()) { + Log.w(TAG, "Cannot start hotspot; missing required permissions: $missingPermissions") + val message = if (Manifest.permission.ACCESS_LOCAL_NETWORK in missingPermissions) { + "Local network permission is required to share the app over the hotspot" + } else { + "Nearby Wi-Fi permission is required to start the hotspot" + } + callback.onError(message) + return + } + + this.callback = callback + this.confirmedReplacementName = confirmedReplacementName + isStarting = true + + Log.d(TAG, "Starting Wi-Fi P2P hotspot") + + // Register broadcast receiver (only if not already registered) + if (!isReceiverRegistered) { + val intentFilter = IntentFilter().apply { + addAction(WIFI_P2P_STATE_CHANGED_ACTION) + addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION) + } + context.registerReceiver(broadcastReceiver, intentFilter) + isReceiverRegistered = true + Log.d(TAG, "Broadcast receiver registered") + } + + // Acquire locks + acquireLocks() + + // Load or generate credentials + if (savedSsid == null || savedPassword == null) { + savedSsid = generateSsid() + savedPassword = generatePassword() + Log.d(TAG, "Generated new credentials: SSID=$savedSsid") + } else { + Log.d(TAG, "Using saved credentials: SSID=$savedSsid") + } + + // Start P2P framework (retries reuse this one channel) + startWifiP2pFramework() + } + + /** + * Stop the hotspot. + * + * @param onTeardownComplete invoked once the framework has acknowledged the + * removal of our group (or immediately when this session created none). Lets + * the caller hold the Wi-Fi Aware radio back until the P2P group is gone. + */ + fun stopHotspot(onTeardownComplete: (() -> Unit)? = null) { + Log.d(TAG, "Stopping hotspot") + + onTeardownComplete?.let(teardownCallbacks::add) + if (teardownInProgress) { + Log.d(TAG, "Teardown already in progress; chaining completion") + return + } + + isStarting = false + hasNotifiedStarted = false + + // Stop group info polling + handler.removeCallbacksAndMessages(null) + + // Detach the channel first so any in-flight listener sees the hotspot as stopped, + // then remove the group and close the channel once the framework has replied. + val staleChannel = channel + channel = null + + val hadOwnGroup = createdGroup + val expectedGroupName = hostedGroupName ?: if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ) { + savedSsid + } else { + null + } + createdGroup = false + hostedGroupName = null + + var teardownAction: (() -> Unit)? = null + if (staleChannel != null && hadOwnGroup) { + teardownInProgress = true + teardownAction = { + removeOwnGroupIfStillPresent(staleChannel, expectedGroupName) + } + } else if (staleChannel != null) { + // This session created nothing, so there is nothing of ours to remove. + // removeGroup() here is exactly the bug this change fixes: device-scoped + // removal would disconnect whatever group another app has running. + closeChannel(staleChannel) + } + + // Release locks + releaseLocks() + + // Unregister receiver (only if registered) + if (isReceiverRegistered) { + try { + context.unregisterReceiver(broadcastReceiver) + isReceiverRegistered = false + Log.d(TAG, "Broadcast receiver unregistered") + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Receiver was not registered", e) + isReceiverRegistered = false + } + } + + currentGroup = null + callback = null + + if (teardownAction != null) { + teardownAction.invoke() + } else { + finishTeardown() + } + } + + /** + * Re-check the device-scoped group immediately before removing it. The last poll + * is only a snapshot: our group may have disappeared and another app may have + * claimed Wi-Fi Direct before stop was requested. + */ + @SuppressLint("MissingPermission") + private fun removeOwnGroupIfStillPresent(ch: Channel, expectedGroupName: String?) { + val manager = wifiP2pManager ?: run { + closeChannel(ch) + finishTeardown() + return + } + + try { + manager.requestGroupInfo(ch) { group -> + val stillOurs = HotspotStartupPolicy.isExpectedHostedGroup( + existingGroupName = group?.networkName, + isGroupOwner = group?.isGroupOwner == true, + expectedGroupName = expectedGroupName + ) + if (!stillOurs) { + Log.i( + TAG, + "Current group '${group?.networkName}' is not ours " + + "('$expectedGroupName'); leaving it alone" + ) + closeChannel(ch) + finishTeardown() + return@requestGroupInfo + } + + try { + manager.removeGroup(ch, object : ActionListener { + override fun onSuccess() { + Log.d(TAG, "Group removed successfully") + clearOwnedGroupNameIfMatches(expectedGroupName) + closeChannel(ch) + finishTeardown() + } + + override fun onFailure(reason: Int) { + Log.w(TAG, "Failed to remove group: $reason") + closeChannel(ch) + finishTeardown() + } + }) + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while removing the group", e) + closeChannel(ch) + finishTeardown() + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while confirming group ownership", e) + closeChannel(ch) + finishTeardown() + } + } + + private fun clearOwnedGroupNameIfMatches(removedGroupName: String?) { + if (HotspotStartupPolicy.shouldClearOwnedGroupName(ownedGroupName, removedGroupName)) { + ownedGroupName = null + } + } + + private fun finishTeardown() { + teardownInProgress = false + val callbacks = teardownCallbacks.toList() + teardownCallbacks.clear() + callbacks.forEach { it.invoke() } + } + + /** + * Release the channel's binder registration with WifiP2pService. Without this the + * registration survives until the process dies, and every start/stop cycle adds + * another stale client to the framework's list. + */ + private fun closeChannel(channelToClose: Channel) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) return + + try { + channelToClose.close() + Log.d(TAG, "P2P channel closed") + } catch (e: Exception) { + Log.w(TAG, "Error closing P2P channel", e) + } + } + + /** + * Get current connection information. + */ + fun getConnectionInfo(): ConnectionInfo? { + val group = currentGroup ?: return null + val ipAddress = getAccessPointAddress() + + return ConnectionInfo( + ssid = group.networkName ?: savedSsid ?: "", + password = group.passphrase ?: savedPassword ?: "", + ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP + connectedPeers = group.clientList?.size ?: 0 + ) + } + + /** + * Initialise the P2P framework once. Every retry reuses this channel — calling + * initialize() per attempt registers a fresh binder with WifiP2pService that is + * never reclaimed until the process dies. + */ + private fun startWifiP2pFramework() { + Log.d(TAG, "Initialising P2P channel") + + val newChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null) + + if (newChannel == null) { + // The service is unobtainable; retrying will not change that. + Log.e(TAG, "Failed to initialize P2P channel") + failStartup(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE) + return + } + + channel = newChannel + createGroupWhenP2pAvailable() + } + + /** + * Ask the framework for the current P2P state before the first attempt. + * + * When P2P is disabled the state machine answers every createGroup with BUSY — + * the same code a genuinely transient collision returns — so without this check + * a permanent failure is indistinguishable from a retryable one. + */ + @SuppressLint("MissingPermission") + private fun createGroupWhenP2pAvailable() { + val ch = channel ?: return + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + clearStaleGroupThenCreate(ch, attempt = 1) + return + } + + try { + wifiP2pManager?.requestP2pState(ch) { state -> + if (channel !== ch) return@requestP2pState + lastP2pState = state + clearStaleGroupThenCreate(ch, attempt = 1) + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while reading P2P state", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } + } + + /** + * A P2P group survives the process that created it, so a previous session killed + * while hosting leaves an orphan behind. The framework then rejects createGroup + * with BUSY for as long as that group exists, which no retry can clear. + */ + @SuppressLint("MissingPermission") + private fun clearStaleGroupThenCreate(ch: Channel, attempt: Int) { + try { + wifiP2pManager?.requestGroupInfo(ch) { existingGroup -> + if (channel !== ch) return@requestGroupInfo + + val action = HotspotStartupPolicy.startAction( + p2pState = lastP2pState, + existingGroupName = existingGroup?.networkName, + ownedGroupName = ownedGroupName, + confirmedGroupName = confirmedReplacementName + ) + + when (action) { + is HotspotStartupPolicy.StartAction.Fail -> { + Log.w(TAG, "Not attempting group creation: ${action.message}") + failStartup(action.message) + } + HotspotStartupPolicy.StartAction.ConfirmReplaceExisting -> { + val name = existingGroup?.networkName + if (name == null) { + // Unreachable while the policy requires a name, but there + // is nothing safe to bind consent to without one. + failStartup(HotspotStartupPolicy.P2P_BUSY_MESSAGE) + } else { + Log.i(TAG, "Existing group '$name' is not ours; asking the user") + reportExistingGroupConflict(name) + } + } + HotspotStartupPolicy.StartAction.Create -> + createGroup(attempt, oldGroupCleared = true) + HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> { + Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating") + removeStaleGroup(ch, attempt) + } + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } + } + + private fun removeStaleGroup(ch: Channel, attempt: Int) { + try { + wifiP2pManager?.removeGroup(ch, object : ActionListener { + override fun onSuccess() { + if (channel !== ch) return + Log.d(TAG, "Stale group removed") + createGroup(attempt, oldGroupCleared = true) + } + override fun onFailure(reason: Int) { + if (channel !== ch) return + // Creation may still succeed, and a BUSY reply here backs off as usual. + Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway") + createGroup(attempt, oldGroupCleared = false) + } + }) + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while removing the existing group", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } + } + + /** + * Create Wi-Fi P2P group. + * + * @param oldGroupCleared false when a previous group may still exist (its removal + * just failed). The ownership marker keeps the OLD group's name in that case: + * overwriting it early would make a BUSY retry classify our own stale group as + * foreign and raise a spurious consent dialog. On success the group-info poll + * records the authoritative name anyway. + */ + @SuppressLint("MissingPermission") + private fun createGroup(attempt: Int, oldGroupCleared: Boolean) { + val ch = channel ?: return + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Record before the call: if the process dies between creation and the + // first group info, the next run still knows this orphan is ours. + if (oldGroupCleared) { + ownedGroupName = savedSsid + } + + // Android 10+: Custom SSID and password + val config = WifiP2pConfig.Builder() + .setNetworkName(savedSsid!!) + .setPassphrase(savedPassword!!) + .setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility + .build() + + wifiP2pManager?.createGroup(ch, config, groupActionListener(attempt, ch)) + } else { + // Android 9 and below: System-generated SSID/password + wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch)) + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } + } + + private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener { + override fun onSuccess() { + if (channel !== requestChannel) { + // Ours by construction: this listener only observes our own createGroup. + Log.w(TAG, "Removing group created after hotspot was stopped") + try { + wifiP2pManager?.removeGroup(requestChannel, null) + } catch (e: SecurityException) { + // The orphan stays; the next start recognises it via ownedGroupName. + Log.e(TAG, "Could not remove the late group; permission was revoked", e) + } + return + } + Log.d(TAG, "P2P group created successfully") + createdGroup = true + isStarting = false + // Don't call onHotspotStarted() yet - wait for group info + startGroupInfoPolling() + } + + override fun onFailure(reason: Int) { + if (channel != null) { + handleGroupCreationFailure(reason, attempt) + } + } + } + + /** + * Handle group creation failure, backing off only for genuinely transient causes. + */ + private fun handleGroupCreationFailure(reason: Int, attempt: Int) { + val reasonStr = when (reason) { + ERROR -> "ERROR" + P2P_UNSUPPORTED -> "P2P_UNSUPPORTED" + BUSY -> "BUSY" + else -> "UNKNOWN($reason)" + } + + Log.w( + TAG, + "Failed to create group: $reasonStr " + + "(attempt $attempt/${HotspotStartupPolicy.MAX_ATTEMPTS}, p2pState=$lastP2pState)" + ) + + when (val decision = HotspotStartupPolicy.decide(reason, attempt, lastP2pState)) { + is HotspotStartupPolicy.Decision.Retry -> { + Log.d(TAG, "Retrying group creation in ${decision.delayMillis}ms") + handler.postDelayed({ + // Re-check for a stale group each round: BUSY is also how the + // framework reports "a group already exists". + channel?.let { clearStaleGroupThenCreate(it, attempt + 1) } + }, decision.delayMillis) + } + is HotspotStartupPolicy.Decision.Fail -> failStartup(decision.message) + } + } + + /** + * Terminal startup failure: release all resources (locks, receiver, handler + * callbacks) before notifying the callback, so a failed attempt doesn't leak + * and block subsequent attempts. + */ + private fun failStartup(message: String) { + val cb = callback + stopHotspot() + cb?.onError(message) + } + + /** + * A group belonging to another app is up. Stop cleanly — with [createdGroup] + * false the stop path leaves that group untouched — and let the UI ask whether + * starting the hotspot may disconnect it. + */ + private fun reportExistingGroupConflict(groupName: String) { + val cb = callback + stopHotspot() + cb?.onExistingGroupConflict(groupName) + } + + /** + * Start polling for group info to track connected clients. + */ + private fun startGroupInfoPolling() { + requestGroupInfo() + + // Keep polling even while the group info is still null — the first + // requestGroupInfo() after createGroup() can legitimately return null + // while the group is forming. Give up only after a timeout. + var elapsedMillis = 0L + handler.postDelayed(object : Runnable { + override fun run() { + if (channel == null) return + + elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS + if (currentGroup == null && !hasNotifiedStarted && + elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS + ) { + Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms") + failStartup("Hotspot failed to start. Please try again.") + return + } + + requestGroupInfo() + handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS) + } + }, GROUP_INFO_POLL_INTERVAL_MILLIS) + } + + /** + * Request current group information. + */ + @SuppressLint("MissingPermission") + private fun requestGroupInfo() { + val ch = channel ?: return + + try { + wifiP2pManager?.requestGroupInfo(ch) { group -> + // A reply arriving after the hotspot stopped must not revive any + // state the stop just cleared. + if (channel !== ch) return@requestGroupInfo + + reconcileGroupOwnership(group) + + if (group == null) { + Log.w(TAG, "requestGroupInfo returned null group") + return@requestGroupInfo + } + + if (!isOurHostedGroup(group)) { + // Someone else's group is on the radio. Reading anything from it + // — its name, its credentials, its client count — would report + // another app's session as our hotspot, and recording its name + // would let the next start remove it without asking. + Log.w( + TAG, + "Observed group '${group.networkName}' is not the one we created" + ) + return@requestGroupInfo + } + + currentGroup = group + + // Update saved credentials if using system-generated ones + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + savedSsid = group.networkName + savedPassword = group.passphrase + } + + // Authoritative name straight from the framework, for the group we + // just confirmed is ours. + group.networkName?.let { + hostedGroupName = it + ownedGroupName = it + } + + // Notify callback on FIRST successful group info retrieval + if (!hasNotifiedStarted) { + hasNotifiedStarted = true + Log.d(TAG, "Group info received, notifying callback") + callback?.onHotspotStarted() + } else { + // Subsequent updates + callback?.onConnectionInfoUpdated(getConnectionInfo()) + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } + } + + /** + * Is this snapshot the group this session created? + * + * `isGroupOwner` cannot answer that on its own: it reports that *this device* + * hosts the group, which is equally true of an autonomous group another app + * created here. Above Q we chose the network name, so it identifies our group + * exactly. Below Q the framework names it, and the first snapshot after our own + * createGroup succeeded is the only evidence available — after that the name is + * fixed, and a group answering to a different one is not ours. + */ + private fun isOurHostedGroup(group: WifiP2pGroup): Boolean { + if (!group.isGroupOwner) return false + val name = group.networkName ?: return false + + hostedGroupName?.let { return name == it } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + name == savedSsid + } else { + true + } + } + + /** + * Keep [createdGroup] honest about what is actually on the framework. + * + * Our group can disappear without us — Wi-Fi toggled, another app issuing its own + * device-scoped removeGroup(), a driver reset — and another app can then create + * one in its place. Believing the group present is still ours would make stop + * remove that replacement, the exact disruption consent exists to prevent. + * + * Reconciled only once the framework has named our group: before that a null + * snapshot means the group is still forming, not that it is gone. Losing the flag + * to a transient null is safe in a way that keeping it is not — the group we + * created is then left behind, and the next start recognises it by name and + * removes it silently. + */ + private fun reconcileGroupOwnership(group: WifiP2pGroup?) { + val expectedName = hostedGroupName ?: if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ) { + savedSsid + } else { + null + } ?: return + + // A null snapshot is normal before the configured group appears. A non-null + // group with a different name is positive evidence that ours was replaced. + if (hostedGroupName == null && group == null) return + + val stillOurs = group != null && isOurHostedGroup(group) + if (createdGroup && !stillOurs) { + Log.w(TAG, "Group '$expectedName' is no longer ours; leaving what is present alone") + } + createdGroup = stillOurs + } + + /** + * Acquire WakeLock and WifiLock to keep hotspot active. + */ + private fun acquireLocks() { + try { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "BitChat:HotspotWakeLock" + ) + wakeLock?.acquire(30 * 60 * 1000L) + + val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager + val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF + } else { + android.net.wifi.WifiManager.WIFI_MODE_FULL + } + wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock") + wifiLock?.acquire() + + Log.d(TAG, "Acquired WakeLock and WifiLock") + } catch (e: Exception) { + Log.e(TAG, "Error acquiring locks", e) + } + } + + /** + * Release WakeLock and WifiLock. + */ + private fun releaseLocks() { + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + wakeLock = null + + wifiLock?.let { + if (it.isHeld) { + it.release() + } + } + wifiLock = null + + Log.d(TAG, "Released WakeLock and WifiLock") + } catch (e: Exception) { + Log.e(TAG, "Error releasing locks", e) + } + } + + /** + * Get the IP address of the P2P access point. + * Looks for network interface starting with "p2p". + */ + private fun getAccessPointAddress(): String? { + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (iface.name.startsWith("p2p")) { + val addresses = iface.interfaceAddresses + for (addr in addresses) { + val address = addr.address + // IPv4 only (4 bytes) + if (address.address.size == 4) { + return address.hostAddress + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error getting access point address", e) + } + return null + } + + /** + * Generate random SSID. + * Format: DIRECT-BC-XXXXXXXX + */ + private fun generateSsid(): String { + val suffix = (1..SSID_SUFFIX_LENGTH) + .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] } + .joinToString("") + return "${HotspotStartupPolicy.SSID_PREFIX}$suffix" + } + + /** + * Generate random password. + * 16 characters, excluding confusing characters. + */ + private fun generatePassword(): String { + return (1..PASSWORD_LENGTH) + .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] } + .joinToString("") + } + + /** + * Connection information for the hotspot. + */ + data class ConnectionInfo( + val ssid: String, + val password: String, + val ipAddress: String, + val connectedPeers: Int + ) + + /** + * Callback interface for hotspot events. + */ + interface HotspotCallback { + fun onHotspotStarted() + fun onConnectionInfoUpdated(info: ConnectionInfo?) + + /** + * A Wi-Fi Direct group belonging to another app is active and the caller has + * not confirmed replacing it. Ask the user, then retry with this name as + * `confirmedReplacementName` if they accept. Nothing was disturbed. + */ + fun onExistingGroupConflict(groupName: String) + + fun onError(message: String) + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotPermissions.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotPermissions.kt new file mode 100644 index 00000000..122ea8c2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotPermissions.kt @@ -0,0 +1,35 @@ +package com.bitchat.android.hotspot + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat + +internal object HotspotPermissions { + const val ANDROID_17_API_LEVEL = 37 + + @SuppressLint("InlinedApi") + fun requiredForSdk(sdkInt: Int = Build.VERSION.SDK_INT): List { + return when { + sdkInt >= ANDROID_17_API_LEVEL -> listOf( + Manifest.permission.NEARBY_WIFI_DEVICES, + Manifest.permission.ACCESS_LOCAL_NETWORK + ) + sdkInt >= Build.VERSION_CODES.TIRAMISU -> listOf( + Manifest.permission.NEARBY_WIFI_DEVICES + ) + sdkInt >= Build.VERSION_CODES.Q -> listOf( + Manifest.permission.ACCESS_FINE_LOCATION + ) + else -> emptyList() + } + } + + fun missingFrom(context: Context): List { + return requiredForSdk().filter { permission -> + ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt new file mode 100644 index 00000000..80d6f4ee --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt @@ -0,0 +1,119 @@ +package com.bitchat.android.hotspot + +import android.net.wifi.p2p.WifiP2pManager + +/** + * Decides how to react to a Wi-Fi P2P group-creation failure. + * + * Kept free of Android dependencies so the retry strategy is unit testable. + */ +internal object HotspotStartupPolicy { + + const val MAX_ATTEMPTS = 5 + const val INITIAL_RETRY_DELAY_MILLIS = 1_000L + const val MAX_RETRY_DELAY_MILLIS = 8_000L + + const val P2P_DISABLED_MESSAGE = + "Wi-Fi Direct is unavailable. Turn Wi-Fi off and back on, then try again." + /** Marks groups this app creates. Shared with [HotspotManager] so the two cannot drift. */ + const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat + + const val P2P_UNSUPPORTED_MESSAGE = "Wi-Fi Direct is not supported on this device." + const val P2P_BUSY_MESSAGE = "Wi-Fi Direct is busy. Please try again in a moment." + const val GENERIC_FAILURE_MESSAGE = "Failed to start the hotspot. Please try again." + + sealed interface Decision { + data class Retry(val delayMillis: Long) : Decision + data class Fail(val message: String) : Decision + } + + sealed interface StartAction { + data object Create : StartAction + data object RemoveStaleGroupThenCreate : StartAction + + /** A group we cannot show is ours is up; ask the user before disturbing it. */ + data object ConfirmReplaceExisting : StartAction + + data class Fail(val message: String) : StartAction + } + + /** + * Decides what to do before the first group-creation attempt. + * + * A P2P group outlives the process that created it, so an app killed while + * hosting leaves an orphan behind. The framework rejects createGroup with BUSY + * while any group exists, and no amount of retrying clears it. + * + * Wi-Fi Direct is shared with Cast, Android Auto and Quick Share. A group we can + * show is ours is removed silently; anything else needs the user's explicit + * go-ahead before it is touched. Consent is bound to the group it was given + * for: a group with any other name — one that appeared after the dialog, or + * mid-retry — asks again instead of riding on stale approval. + * + * @param existingGroupName network name of the group already present, or null + * @param ownedGroupName last group name this app recorded creating, or null + * @param confirmedGroupName group the user agreed to disconnect, or null + */ + fun startAction( + p2pState: Int?, + existingGroupName: String?, + ownedGroupName: String?, + confirmedGroupName: String? + ): StartAction = when { + p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> StartAction.Fail(P2P_DISABLED_MESSAGE) + existingGroupName == null -> StartAction.Create + isOurs(existingGroupName, ownedGroupName) -> StartAction.RemoveStaleGroupThenCreate + existingGroupName == confirmedGroupName -> StartAction.RemoveStaleGroupThenCreate + else -> StartAction.ConfirmReplaceExisting + } + + /** + * Only the exact name this device recorded creating counts as ours. A prefix + * match is not ownership: this device can be connected to another phone's + * bitchat group — same prefix, their suffix — and silently removing it would + * disconnect that session. An orphan predating the record simply goes through + * the confirmation dialog once. + */ + private fun isOurs(existingGroupName: String, ownedGroupName: String?): Boolean = + ownedGroupName != null && existingGroupName == ownedGroupName + + /** Only an exact owner-role name match authorizes device-scoped removal. */ + fun isExpectedHostedGroup( + existingGroupName: String?, + isGroupOwner: Boolean, + expectedGroupName: String? + ): Boolean = + isGroupOwner && + expectedGroupName != null && + existingGroupName == expectedGroupName + + /** A stale teardown must not erase the ownership marker of a newer session. */ + fun shouldClearOwnedGroupName( + storedGroupName: String?, + removedGroupName: String? + ): Boolean = + removedGroupName != null && storedGroupName == removedGroupName + + /** + * @param reason a [WifiP2pManager] failure reason from `ActionListener.onFailure` + * @param attempt 1-based attempt that just failed + * @param p2pState last known [WifiP2pManager.EXTRA_WIFI_STATE], or null if no + * state broadcast has arrived yet + */ + fun decide(reason: Int, attempt: Int, p2pState: Int?): Decision = when { + reason == WifiP2pManager.P2P_UNSUPPORTED -> Decision.Fail(P2P_UNSUPPORTED_MESSAGE) + + reason != WifiP2pManager.BUSY -> Decision.Fail(GENERIC_FAILURE_MESSAGE) + + // BUSY is the framework's catch-all reply when the P2P state machine is + // disabled, so retrying cannot help — surface something actionable instead. + p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> Decision.Fail(P2P_DISABLED_MESSAGE) + + attempt >= MAX_ATTEMPTS -> Decision.Fail(P2P_BUSY_MESSAGE) + + else -> Decision.Retry(retryDelayMillis(attempt)) + } + + private fun retryDelayMillis(attempt: Int): Long = + (INITIAL_RETRY_DELAY_MILLIS shl (attempt - 1)).coerceAtMost(MAX_RETRY_DELAY_MILLIS) +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt new file mode 100644 index 00000000..73f9faa4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt @@ -0,0 +1,255 @@ +package com.bitchat.android.hotspot + +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +/** + * ViewModel for managing hotspot state and lifecycle. + */ +class HotspotViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "HotspotViewModel" + + // Upper bound on waiting for the framework to acknowledge group removal + // before the Wi-Fi Aware lease is released anyway. + private const val TEARDOWN_FALLBACK_MILLIS = 10_000L + } + + private val _state = MutableStateFlow(HotspotState.Intro) + val state: StateFlow = _state.asStateFlow() + + private var hotspotManager: HotspotManager? = null + private var webServer: ApkWebServer? = null + + /** APK waiting on the user's answer to the disconnect confirmation. */ + private var pendingApk: File? = null + + /** Group the pending confirmation is about; consent binds to this name only. */ + private var pendingGroupName: String? = null + + /** Once-releasable radio claim owned by the current hotspot session. */ + private var awareLease: WifiAwareController.HotspotLease? = null + private val context = application.applicationContext + + /** + * Start the hotspot with the provided APK file. + */ + fun startHotspot(apkFile: File) { + startHotspot(apkFile, confirmedGroupName = null) + } + + private fun startHotspot(apkFile: File, confirmedGroupName: String?) { + if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) { + Log.w(TAG, "Hotspot already starting or active") + return + } + + Log.d(TAG, "Starting hotspot with APK: ${apkFile.name}") + _state.value = HotspotState.Starting + + viewModelScope.launch { + try { + // Wi-Fi Aware holds a NAN interface that blocks the P2P one; release it + // first or every createGroup comes back BUSY. Restored when we stop. + awareLease = WifiAwareController.acquireHotspotLease() + + // Start hotspot + val manager = HotspotManager(context) + hotspotManager = manager + + manager.startHotspot(object : HotspotManager.HotspotCallback { + override fun onHotspotStarted() { + viewModelScope.launch { + Log.d(TAG, "Hotspot started successfully") + + // Get connection info + val info = manager.getConnectionInfo() + if (info == null) { + failWith("Failed to get hotspot connection info") + return@launch + } + + // Start web server + try { + val server = ApkWebServer(context, apkFile) + server.startServer() + webServer = server + + Log.d(TAG, "Web server started on port ${ApkWebServer.DEFAULT_PORT}") + + // Update state with connection info + _state.value = HotspotState.Active( + ssid = info.ssid, + password = info.password, + ipAddress = info.ipAddress, + port = ApkWebServer.DEFAULT_PORT, + connectedPeers = info.connectedPeers + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to start web server", e) + failWith("Failed to start web server: ${e.message}") + } + } + } + + override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) { + viewModelScope.launch { + // Update peer count if we're active + val currentState = _state.value + if (currentState is HotspotState.Active && info != null) { + _state.value = currentState.copy(connectedPeers = info.connectedPeers) + } + } + } + + override fun onExistingGroupConflict(groupName: String) { + viewModelScope.launch { + // Nothing was disturbed; the manager already stopped + // itself. Release our resources and ask the user. + teardown() + pendingApk = apkFile + pendingGroupName = groupName + _state.value = HotspotState.ConfirmDisconnect + } + } + + override fun onError(message: String) { + viewModelScope.launch { failWith(message) } + } + }, confirmedGroupName) + + } catch (e: Exception) { + Log.e(TAG, "Error starting hotspot", e) + failWith(e.message ?: "Unknown error") + } + } + } + + /** The user agreed that starting may disconnect the existing Wi-Fi Direct group. */ + fun confirmDisconnectAndStart() { + if (_state.value !is HotspotState.ConfirmDisconnect) return + val apk = pendingApk ?: return + val groupName = pendingGroupName + pendingApk = null + pendingGroupName = null + startHotspot(apk, confirmedGroupName = groupName) + } + + fun cancelDisconnect() { + // A tap landing late (e.g. through an exit animation) must not tear down + // the session a just-processed confirmation is starting. + if (_state.value !is HotspotState.ConfirmDisconnect) return + pendingApk = null + pendingGroupName = null + // The conflict path already tore down; this only covers a stray state. + teardown() + _state.value = HotspotState.Intro + } + + /** + * Stop the hotspot and web server. + */ + fun stopHotspot() { + Log.d(TAG, "Stopping hotspot") + pendingApk = null + pendingGroupName = null + teardown() + _state.value = HotspotState.Intro + } + + /** + * Every failure after the hotspot has been requested must land here. + * + * Skipping any part of this leaves something running that shouldn't be: the web + * server keeps serving the APK on whatever network the device joins next, and the + * Wi-Fi Aware hold blocks the mesh until the user happens to retry or close the + * screen. + */ + private fun failWith(message: String) { + Log.e(TAG, "Hotspot failed: $message") + pendingApk = null + pendingGroupName = null + teardown() + _state.value = HotspotState.Error(message) + } + + /** Releases every resource startHotspot may have acquired. Safe to call twice. */ + private fun teardown() { + webServer?.stopServer() + webServer = null + + val manager = hotspotManager + hotspotManager = null + + // Nothing of ours to hand back. An earlier teardown may already have passed + // its once-releasable lease to the manager's completion callback. + val lease = awareLease ?: return + awareLease = null + + if (manager == null) { + lease.close() + return + } + + // Close the lease only when the manager has finished removing our group. + // Restoring Wi-Fi Aware earlier recreates the NAN/P2P radio contention the + // lease exists to prevent. The lease is idempotent, so a duplicate or late + // completion cannot release a newer session's claim. + manager.stopHotspot { lease.close() } + + // A framework that never acknowledges the removal must not pin Wi-Fi Aware + // down for the life of the process. close() is idempotent and this lease + // belongs to this session alone, so whichever path runs second is a no-op. + // A plain handler rather than viewModelScope: onCleared() cancels the scope + // right when this teardown may be running. + Handler(Looper.getMainLooper()).postDelayed( + { lease.close() }, + TEARDOWN_FALLBACK_MILLIS + ) + } + + /** + * Reset to intro state (for retry after error). + */ + fun resetToIntro() { + stopHotspot() + _state.value = HotspotState.Intro + } + + override fun onCleared() { + super.onCleared() + Log.d(TAG, "ViewModel cleared, stopping hotspot") + stopHotspot() + } + + /** + * Hotspot state sealed class. + */ + sealed class HotspotState { + object Intro : HotspotState() + object Starting : HotspotState() + + /** A foreign Wi-Fi Direct group is up; waiting for the user's go-ahead. */ + object ConfirmDisconnect : HotspotState() + + data class Active( + val ssid: String, + val password: String, + val ipAddress: String, + val port: Int, + val connectedPeers: Int + ) : HotspotState() + data class Error(val message: String) : HotspotState() + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt new file mode 100644 index 00000000..69c2bdf1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt @@ -0,0 +1,126 @@ +package com.bitchat.android.hotspot + +import android.graphics.Bitmap +import android.util.Log +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter + +/** + * Utility for generating QR codes for Wi-Fi connection and URL. + */ +object QrCodeGenerator { + + private const val TAG = "QrCodeGenerator" + + /** + * Generate QR code for Wi-Fi connection. + * Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};; + * + * This format is recognized by most Android/iOS devices for instant Wi-Fi connection. + * + * @param ssid Wi-Fi network name + * @param password Wi-Fi password + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + fun generateWifiQr(ssid: String, password: String, sizePx: Int): Bitmap? { + if (ssid.isBlank() || password.isBlank()) { + Log.w(TAG, "SSID or password is blank") + return null + } + + // Escape special characters + val escapedSsid = escapeWifiString(ssid) + val escapedPassword = escapeWifiString(password) + + // Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};; + val wifiString = "WIFI:S:$escapedSsid;T:WPA;P:$escapedPassword;;" + + Log.d(TAG, "Generating Wi-Fi QR code for SSID: $ssid") + + return generateQrBitmap(wifiString, sizePx) + } + + /** + * Generate QR code for URL. + * + * @param url Website URL (e.g., "http://192.168.49.1:9999") + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + fun generateUrlQr(url: String, sizePx: Int): Bitmap? { + if (url.isBlank()) { + Log.w(TAG, "URL is blank") + return null + } + + Log.d(TAG, "Generating URL QR code: $url") + + return generateQrBitmap(url, sizePx) + } + + /** + * Generate QR code bitmap from string data. + * + * @param data String data to encode + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + if (data.isBlank() || sizePx <= 0) { + Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx") + return null + } + + return try { + val matrix = QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + sizePx, + sizePx + ) + bitmapFromMatrix(matrix) + } catch (e: Exception) { + Log.e(TAG, "Error generating QR code", e) + null + } + } + + /** + * Convert BitMatrix to Bitmap. + * Pattern from VerificationSheet.kt. + */ + private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap { + val width = matrix.width + val height = matrix.height + val bitmap = createBitmap(width, height) + + for (x in 0 until width) { + for (y in 0 until height) { + bitmap[x, y] = if (matrix[x, y]) { + android.graphics.Color.BLACK + } else { + android.graphics.Color.WHITE + } + } + } + + return bitmap + } + + /** + * Escape special characters in Wi-Fi SSID/password for QR code format. + * Special characters that need escaping: \ ; , " : + */ + private fun escapeWifiString(input: String): String { + return input + .replace("\\", "\\\\") // Backslash must be escaped first + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\"", "\\\"") + .replace(":", "\\:") + } +} diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 7dcad252..1efa87a2 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -1,23 +1,27 @@ package com.bitchat.android.identity +import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import java.security.MessageDigest -import java.security.SecureRandom +import android.util.Base64 import android.util.Log +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.util.hexEncodedString +import androidx.core.content.edit /** * Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation * * Handles: * - Static identity key persistence across app sessions - * - Peer ID rotation timing (5-15 minute random intervals) * - Secure storage using Android EncryptedSharedPreferences * - Fingerprint calculation and identity validation */ -class SecureIdentityStateManager(private val context: Context) { +class SecureIdentityStateManager { companion object { private const val TAG = "SecureIdentityStateManager" @@ -26,18 +30,30 @@ class SecureIdentityStateManager(private val context: Context) { private const val KEY_STATIC_PUBLIC_KEY = "static_public_key" private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key" private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key" - private const val KEY_LAST_ROTATION = "last_rotation" - private const val KEY_NEXT_ROTATION_INTERVAL = "next_rotation_interval" - - // Rotation intervals (same as iOS) - private const val MIN_ROTATION_INTERVAL = 5 * 60 * 1000L // 5 minutes - private const val MAX_ROTATION_INTERVAL = 15 * 60 * 1000L // 15 minutes + private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints" + private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints" + private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys" + private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints" + private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames" + private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1" + private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1" + + // BLE, Wi-Fi Aware, and Noise services each hold their own manager + // instance over the same encrypted preferences. Serialize pin updates + // process-wide so concurrent promotions cannot lose one another or + // race a panic wipe. + private val privateMediaPinsLock = Any() + private var privateMediaPinsEpoch = 0L } private val prefs: SharedPreferences - private val random = SecureRandom() - - init { + private val lock = Any() + private var privateMediaPinsEpochAtCreation: Long + + constructor(context: Context) { + privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { + privateMediaPinsEpoch + } // Create master key for encryption val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) @@ -52,6 +68,15 @@ class SecureIdentityStateManager(private val context: Context) { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) } + + /** Test-only storage injection; production always uses encrypted prefs. */ + internal constructor(prefs: SharedPreferences, testOnly: Boolean) { + require(testOnly) { "Plain SharedPreferences are test-only" } + privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { + privateMediaPinsEpoch + } + this.prefs = prefs + } // MARK: - Static Key Management @@ -177,7 +202,7 @@ class SecureIdentityStateManager(private val context: Context) { fun generateFingerprint(publicKeyData: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(publicKeyData) - return hash.joinToString("") { "%02x".format(it) } + return hash.hexEncodedString() } /** @@ -187,71 +212,188 @@ class SecureIdentityStateManager(private val context: Context) { // SHA-256 fingerprint should be 64 hex characters return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$")) } - - // MARK: - Peer ID Rotation Management - - /** - * Check if peer ID should be rotated based on random interval - */ - fun shouldRotatePeerID(): Boolean { - val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L) - val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L) - val now = System.currentTimeMillis() - - if (lastRotation == 0L || nextInterval == 0L) { - // First run or missing data - schedule next rotation and don't rotate now - scheduleNextRotation() - return false + + // MARK: - Verified Fingerprints + + fun getVerifiedFingerprints(): Set { + return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet() + } + + fun isVerifiedFingerprint(fingerprint: String): Boolean { + return getVerifiedFingerprints().contains(fingerprint) + } + + fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) { + if (!isValidFingerprint(fingerprint)) return + synchronized(lock) { + val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + if (verified) { + current.add(fingerprint) + } else { + current.remove(fingerprint) + } + prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) } } - - val shouldRotate = (now - lastRotation) >= nextInterval - if (shouldRotate) { - Log.d(TAG, "Peer ID rotation due: ${(now - lastRotation) / 1000}s since last rotation") + } + + fun getCachedPeerFingerprint(peerID: String): String? { + val pid = peerID.lowercase() + // Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility + // if we are paranoid, but SharedPreferences is generally thread-safe for reads. + // However, to ensure we don't read a partial update (unlikely with SP), we can leave it. + // The critical part is the write. + val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null + return entry.substringAfter(':').takeIf { isValidFingerprint(it) } + } + + fun cachePeerFingerprint(peerID: String, fingerprint: String) { + if (!isValidFingerprint(fingerprint)) return + val pid = peerID.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid:") } + current.add("$pid:$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) } } - - return shouldRotate } + + fun getCachedNoiseKey(peerID: String): String? { + val pid = peerID.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null + return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) } + } + + fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) { + if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return + val pid = peerID.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid=") } + current.add("$pid=${noiseKeyHex.lowercase()}") + prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) } + } + } + + fun getCachedNoiseFingerprint(noiseKeyHex: String): String? { + val key = noiseKeyHex.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null + return entry.substringAfter('=').takeIf { isValidFingerprint(it) } + } + + fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) { + if (!isValidFingerprint(fingerprint)) return + if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return + val key = noiseKeyHex.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) } + } + } + + fun getCachedFingerprintNickname(fingerprint: String): String? { + if (!isValidFingerprint(fingerprint)) return null + val key = fingerprint.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null + val encoded = entry.substringAfter('=') + return runCatching { + val bytes = Base64.decode(encoded, Base64.NO_WRAP) + String(bytes, Charsets.UTF_8) + }.getOrNull() + } + + fun cacheFingerprintNickname(fingerprint: String, nickname: String) { + if (!isValidFingerprint(fingerprint)) return + val key = fingerprint.lowercase() + val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$encoded") + prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) } + } + } + + // MARK: - Authenticated private-media capability pins + + fun isPrivateMediaCapable(fingerprint: String): Boolean { + if (!isValidFingerprint(fingerprint)) return false + return synchronized(privateMediaPinsLock) { + if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) { + return@synchronized false + } + prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet()) + ?.any { it.equals(fingerprint, ignoreCase = true) } == true + } + } + + /** Persist capabilities and Ed25519 key from a decoded Noise 0x21 proof in one edit. */ + @SuppressLint("UseKtx") + fun storeAuthenticatedPeerState( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit = {} + ): Boolean { + if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false + val normalizedFingerprint = fingerprint.lowercase() + return synchronized(privateMediaPinsLock) { + // A controller that survived panic must not republish pre-wipe proof state. + if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false + val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) + ?.toMutableSet() ?: mutableSetOf() + records.removeAll { it.startsWith("$normalizedFingerprint:") } + val capabilitiesHex = java.lang.Long.toUnsignedString(state.capabilities.rawValue, 16) + records.add( + "$normalizedFingerprint:$capabilitiesHex:${state.signingPublicKey.hexEncodedString()}" + ) + + val editor = prefs.edit().putStringSet(KEY_AUTHENTICATED_PEER_STATES, records) + if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) { + val pins = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet()) + ?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf() + pins.add(normalizedFingerprint) + editor.putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, pins) + } + // This result is a security boundary: do not publish the Ed key in memory unless the + // encrypted identity record and its HSTS pin were durably committed together. + editor.commit().also { committed -> + if (committed) onCommitted() + } + } + } + + fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? { + if (!isValidFingerprint(fingerprint)) return null + return synchronized(privateMediaPinsLock) { + if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null + val prefix = "${fingerprint.lowercase()}:" + val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) + ?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null + val fields = record.split(':', limit = 3) + if (fields.size != 3) return@synchronized null + val capabilities = runCatching { + PeerCapabilities(java.lang.Long.parseUnsignedLong(fields[1], 16)) + }.getOrNull() ?: return@synchronized null + val signingKeyHex = fields[2] + if (!signingKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return@synchronized null + val signingKey = runCatching { + signingKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + }.getOrNull() ?: return@synchronized null + AuthenticatedPeerState(capabilities, signingKey) + } + } + + fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? = + getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf() - /** - * Mark rotation as completed and schedule next one - */ - fun markRotationCompleted() { - val now = System.currentTimeMillis() - prefs.edit() - .putLong(KEY_LAST_ROTATION, now) - .apply() - - scheduleNextRotation() - - Log.d(TAG, "Peer ID rotation marked as completed") - } - - /** - * Schedule the next rotation with random interval (5-15 minutes) - */ - private fun scheduleNextRotation() { - val nextInterval = MIN_ROTATION_INTERVAL + random.nextLong(MAX_ROTATION_INTERVAL - MIN_ROTATION_INTERVAL) - - prefs.edit() - .putLong(KEY_NEXT_ROTATION_INTERVAL, nextInterval) - .apply() - - Log.d(TAG, "Next peer ID rotation scheduled in ${nextInterval / 60000} minutes") - } - - /** - * Get time until next rotation (for debugging) - */ - fun getTimeUntilNextRotation(): Long { - val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L) - val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L) - val now = System.currentTimeMillis() - - if (lastRotation == 0L || nextInterval == 0L) return -1 - - val elapsed = now - lastRotation - return maxOf(0L, nextInterval - elapsed) - } + // MARK: - Peer ID Rotation Management (removed) + // Android now derives peer ID from the persisted Noise identity fingerprint. + // No timed peer ID rotation is performed here. // MARK: - Identity Validation @@ -305,14 +447,6 @@ class SecureIdentityStateManager(private val context: Context) { appendLine("Has identity: $hasIdentity") if (hasIdentity) { - val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L) - val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L) - val timeUntilNext = getTimeUntilNextRotation() - - appendLine("Last rotation: ${if (lastRotation > 0) "${(System.currentTimeMillis() - lastRotation) / 1000}s ago" else "never"}") - appendLine("Next rotation in: ${if (timeUntilNext >= 0) "${timeUntilNext / 1000}s" else "not scheduled"}") - appendLine("Rotation interval: ${nextInterval / 1000}s") - try { val keyPair = loadStaticKey() if (keyPair != null) { @@ -331,9 +465,16 @@ class SecureIdentityStateManager(private val context: Context) { /** * Clear all identity data (for panic mode) */ + @SuppressLint("UseKtx") fun clearIdentityData() { try { - prefs.edit().clear().apply() + synchronized(privateMediaPinsLock) { + privateMediaPinsEpoch += 1 + privateMediaPinsEpochAtCreation = privateMediaPinsEpoch + if (!prefs.edit().clear().commit()) { + Log.e(TAG, "Identity preference wipe could not be committed") + } + } Log.w(TAG, "All identity data cleared") } catch (e: Exception) { Log.e(TAG, "Failed to clear identity data: ${e.message}") @@ -387,4 +528,11 @@ class SecureIdentityStateManager(private val context: Context) { } editor.apply() } + + /** Use for panic paths that must finish the disk mutation before identity reset continues. */ + fun clearSecureValuesSynchronously(vararg keys: String): Boolean { + val editor = prefs.edit() + keys.forEach(editor::remove) + return editor.commit() + } } diff --git a/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt new file mode 100644 index 00000000..89b833b1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.noise.NoisePeerIdentity +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString + +/** Canonical, side-effect-free preflight for a self-signed mesh announcement. */ +object AnnouncementIdentityValidator { + private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L + + fun verify( + packet: BitchatPacket, + claimedPeerID: String, + nowMs: Long = System.currentTimeMillis(), + verifyEd25519: (signature: ByteArray, data: ByteArray, publicKey: ByteArray) -> Boolean + ): IdentityAnnouncement? { + if (packet.type != MessageType.ANNOUNCE.value) return null + val now = nowMs.coerceAtLeast(0).toULong() + val skew = if (packet.timestamp >= now) packet.timestamp - now else now - packet.timestamp + if (skew > MAX_CLOCK_SKEW_MS.toULong()) return null + val announcement = IdentityAnnouncement.decode(packet.payload) ?: return null + if (announcement.signingPublicKey.size != 32) return null + + val derivedPeerID = NoisePeerIdentity.derivePeerID(announcement.noisePublicKey) ?: return null + if (packet.senderID.toHexString() != derivedPeerID || claimedPeerID != derivedPeerID) return null + + val signature = packet.signature ?: return null + val canonicalData = packet.toBinaryDataForSigning() ?: return null + return if (verifyEd25519(signature, canonicalData, announcement.signingPublicKey)) { + announcement + } else { + null + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt new file mode 100644 index 00000000..90ef4176 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt @@ -0,0 +1,235 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoisePeerIdentity +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal interface AuthenticatedPeerStateStore { + fun load(fingerprint: String): AuthenticatedPeerState? + fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean + fun isPrivateMediaPinned(fingerprint: String): Boolean +} + +internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore { + private val identityState = SecureIdentityStateManager(context.applicationContext) + + override fun load(fingerprint: String): AuthenticatedPeerState? = + identityState.getAuthenticatedPeerState(fingerprint) + + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean = identityState.storeAuthenticatedPeerState(fingerprint, state, onCommitted) + + override fun isPrivateMediaPinned(fingerprint: String): Boolean = + identityState.isPrivateMediaCapable(fingerprint) +} + +internal sealed interface AuthenticatedPeerStateStatus { + data object Missing : AuthenticatedPeerStateStatus + data object Awaiting : AuthenticatedPeerStateStatus + data object TimedOut : AuthenticatedPeerStateStatus + data class Proven(val state: AuthenticatedPeerState) : AuthenticatedPeerStateStatus +} + +/** Fresh, generation-scoped authenticated peer-state exchange for Noise payload 0x21. */ +internal class AuthenticatedPeerStateCoordinator( + private val scope: CoroutineScope, + private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?, + private val withAuthenticatedSession: ( + String, + AuthenticatedNoiseSession, + () -> Boolean + ) -> Boolean, + private val store: AuthenticatedPeerStateStore, + private val localStateProvider: () -> AuthenticatedPeerState, + private val applyAuthenticatedState: (String, ByteArray, AuthenticatedPeerState) -> Unit, + private val sendState: (String, AuthenticatedPeerState, AuthenticatedNoiseSession) -> Boolean, + private val onResolution: (String) -> Unit, + private val proofTimeoutMs: Long = 5_000L +) { + private data class SessionState( + val authenticatedSession: AuthenticatedNoiseSession, + val fingerprint: String, + var status: AuthenticatedPeerStateStatus, + var echoSent: Boolean, + var timeoutJob: Job? = null + ) + + private val lock = Any() + private val sessions = ConcurrentHashMap() + + fun onSessionAuthenticated( + peerID: String, + authenticatedRemoteStatic: ByteArray, + authenticatedSessionToken: ByteArray + ) { + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return + if (authenticatedSessionToken.size != 32 || + authenticatedSessionToken.all { it == 0.toByte() } + ) return + val authenticatedSession = AuthenticatedNoiseSession( + authenticatedRemoteStatic.copyOf(), + authenticatedSessionToken.copyOf() + ) + ensureSession(peerID, authenticatedSession) + } + + /** Install one watchdog/exchange for this exact live generation, without resetting it. */ + private fun ensureSession( + peerID: String, + authenticatedSession: AuthenticatedNoiseSession + ): SessionState? { + val authenticatedRemoteStatic = authenticatedSession.remoteStaticKey + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return null + if (authenticatedSession.sessionToken.size != 32 || + authenticatedSession.sessionToken.all { it == 0.toByte() } + ) return null + // Ignore a delayed callback or policy snapshot if a later generation is already active. + if (authenticatedSessionProvider(peerID) != authenticatedSession) return null + val session = SessionState( + authenticatedSession = authenticatedSession, + fingerprint = fingerprint(authenticatedRemoteStatic), + status = AuthenticatedPeerStateStatus.Awaiting, + echoSent = false + ) + val installed = withAuthenticatedSession(peerID, authenticatedSession) { + synchronized(lock) { + val existing = sessions[peerID] + if (existing?.authenticatedSession == authenticatedSession) { + return@synchronized false + } + sessions.put(peerID, session)?.timeoutJob?.cancel() + true + } + } + if (!installed) return synchronized(lock) { sessions[peerID] } + + // Emit for every authenticated generation/rekey. Failure does not relax the watchdog. + runCatching { sendState(peerID, localStateProvider(), authenticatedSession) } + + val timeout = scope.launch { + delay(proofTimeoutMs) + val resolved = synchronized(lock) { + val current = sessions[peerID] + if (current !== session || current.status !is AuthenticatedPeerStateStatus.Awaiting) { + false + } else { + current.status = AuthenticatedPeerStateStatus.TimedOut + true + } + } + if (resolved) onResolution(peerID) + } + synchronized(lock) { + if (sessions[peerID] === session && session.status is AuthenticatedPeerStateStatus.Awaiting) { + session.timeoutJob = timeout + } else { + timeout.cancel() + } + } + return session + } + + /** Accept the first valid proof for this generation; repeated equal proofs are idempotent. */ + fun receive( + peerID: String, + state: AuthenticatedPeerState, + decryptedSession: AuthenticatedNoiseSession + ): Boolean { + val remoteStatic = decryptedSession.remoteStaticKey + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStatic)) return false + if (decryptedSession.sessionToken.size != 32 || + decryptedSession.sessionToken.all { it == 0.toByte() } + ) return false + val currentFingerprint = fingerprint(remoteStatic) + + var shouldEcho = false + var echoSession: AuthenticatedNoiseSession? = null + val accepted = withAuthenticatedSession(peerID, decryptedSession) { + synchronized(lock) { + val current = sessions[peerID] ?: return@synchronized false + if (current.fingerprint != currentFingerprint || + current.authenticatedSession != decryptedSession + ) return@synchronized false + val proven = current.status as? AuthenticatedPeerStateStatus.Proven + if (proven != null) return@synchronized proven.state == state + try { + // Persist before publishing the replacement Ed key in memory, so a restart + // cannot reopen copied-static first-announce poisoning. The Noise manager + // lease prevents this generation from being replaced during the transition. + if (!store.persist(currentFingerprint, state) { + // Publish while the persistence epoch lock is still held. A panic wipe + // can therefore happen before both operations or after both, never between. + applyAuthenticatedState(peerID, remoteStatic, state) + } + ) return@synchronized false + current.timeoutJob?.cancel() + current.status = AuthenticatedPeerStateStatus.Proven(state) + if (!current.echoSent) { + current.echoSent = true + shouldEcho = true + echoSession = current.authenticatedSession + } + true + } catch (_: Exception) { + false + } + } + } + if (!accepted) return false + if (shouldEcho) { + val exactSession = echoSession ?: return false + runCatching { sendState(peerID, localStateProvider(), exactSession) } + } + onResolution(peerID) + return true + } + + fun status( + peerID: String, + authenticatedSession: AuthenticatedNoiseSession + ): AuthenticatedPeerStateStatus { + ensureSession(peerID, authenticatedSession) + val currentFingerprint = fingerprint(authenticatedSession.remoteStaticKey) + return synchronized(lock) { + sessions[peerID]?.takeIf { + it.fingerprint == currentFingerprint && + it.authenticatedSession == authenticatedSession + }?.status + ?: AuthenticatedPeerStateStatus.Missing + } + } + + fun persistedSigningKeyFor(noisePublicKey: ByteArray): ByteArray? { + if (noisePublicKey.size != 32) return null + return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf() + } + + fun isPrivateMediaPinned(peerID: String): Boolean { + val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false + return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey)) + } + + fun clear(peerID: String) { + synchronized(lock) { sessions.remove(peerID)?.timeoutJob?.cancel() } + } + + private fun fingerprint(publicKey: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(publicKey) + .joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt new file mode 100644 index 00000000..279fe527 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt @@ -0,0 +1,18 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.MessageType + +/** + * iOS-compatible BLE padding policy. + * + * Keep this aligned with iOS BLEOutboundPacketPolicy.padsBLEFrame(for:): + * only Noise frames are padded over BLE. + */ +object BLEPacketPaddingPolicy { + fun shouldPadForBLE(type: UByte): Boolean { + return when (MessageType.fromValue(type)) { + MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true + else -> false + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index e34d9b59..0e4f29af 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -6,6 +6,8 @@ import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine /** * Power-optimized Bluetooth connection manager with comprehensive memory management @@ -16,7 +18,7 @@ class BluetoothConnectionManager( private val context: Context, private val myPeerID: String, private val fragmentManager: FragmentManager? = null -) : PowerManagerDelegate { +) { companion object { private const val TAG = "BluetoothConnectionManager" @@ -28,7 +30,7 @@ class BluetoothConnectionManager( private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter // Power management - private val powerManager = PowerManager(context) + private val powerManager = PowerManager.getInstance(context.applicationContext) // Coroutines private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -36,19 +38,17 @@ class BluetoothConnectionManager( // Component managers private val permissionManager = BluetoothPermissionManager(context) private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager) - private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager) + private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager, myPeerID) // Delegate for component managers to call back to main manager private val componentDelegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) { - Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)") + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) { device?.let { bluetoothDevice -> - // if connection does not have a peerID yet, we assume that the first package - // we receive from that connection is from the peer - if (!connectionTracker.addressPeerMap.containsKey(device.address)) { - Log.d(TAG, "First packet received from new device: ${bluetoothDevice.address}, assuming peerID: $peerID") - connectionTracker.addressPeerMap[device.address] = peerID - } // Get current RSSI for this device and update if available val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address) if (currentRSSI != null) { @@ -58,12 +58,27 @@ class BluetoothConnectionManager( if (peerID == myPeerID) return // Ignore messages from self - delegate?.onPacketReceived(packet, peerID, device) + delegate?.onPacketReceived(packet, peerID, device, ingressLinkID) } override fun onDeviceConnected(device: BluetoothDevice) { + // Trigger limit enforcement immediately upon any new connection + enforceStrictLimits() delegate?.onDeviceConnected(device) } + + override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) { + packetBroadcaster.onLinkDisconnected(device.address, linkID) + delegate?.onDeviceDisconnected(device, linkID, peerID) + } + + override fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) { + packetBroadcaster.onGattClientWriteComplete(deviceAddress, linkID, status) + } + + override fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) { + packetBroadcaster.onGattServerNotificationComplete(deviceAddress, linkID, status) + } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { delegate?.onRSSIUpdated(deviceAddress, rssi) @@ -71,7 +86,7 @@ class BluetoothConnectionManager( } private val serverManager = BluetoothGattServerManager( - context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate + context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID ) private val clientManager = BluetoothGattClientManager( context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate @@ -85,16 +100,129 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap - + + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID) + + fun getCurrentLinkID(deviceAddress: String): String? = + connectionTracker.getCurrentLinkID(deviceAddress) + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isGattServerEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + + private fun isGattClientEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } + init { - powerManager.delegate = this + connectionScope.launch { + var previousMode: PowerManager.PowerMode? = null + powerManager.profile.collect { profile -> + val modeChanged = previousMode != null && previousMode != profile.mode + previousMode = profile.mode + if (!isActive || !isBleTransportEnabled()) return@collect + + if (modeChanged && isGattServerEnabled()) { + serverManager.restartAdvertising() + } + clientManager.applyPowerProfile(profile) + } + } + // Observe debug settings to enforce role state while active + try { + val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Master transport enable/disable + connectionScope.launch { + dbg.bleEnabled.collect { enabled -> + if (enabled) return@collect + if (isActive) { + disableTransport() + } + } + } + // Role enable/disable + connectionScope.launch { + dbg.gattServerEnabled.collect { enabled -> + if (!isActive) return@collect + if (enabled && isBleTransportEnabled()) startServer() else stopServer() + } + } + connectionScope.launch { + dbg.gattClientEnabled.collect { enabled -> + if (!isActive) return@collect + if (enabled && isBleTransportEnabled()) startClient() else stopClient() + } + } + + // Centralized limit enforcement on any setting change + connectionScope.launch { + combine( + dbg.maxConnectionsOverall, + dbg.maxServerConnections, + dbg.maxClientConnections + ) { _, _, _ -> + // We don't need the values here, we just need to trigger enforcement + Unit + }.collect { + if (isActive) { + enforceStrictLimits() + } + } + } + } catch (_: Exception) { } + } + + /** + * Centralized connection limit enforcement + */ + private fun enforceStrictLimits() { + if (!isActive) return + + try { + val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + val maxOverall = dbg.maxConnectionsOverall.value + val maxServer = dbg.maxServerConnections.value + val maxClient = dbg.maxClientConnections.value + + // Get list of connections to evict to satisfy all constraints + val toEvict = connectionTracker.getConnectionsToEvict(maxOverall, maxServer, maxClient) + + if (toEvict.isNotEmpty()) { + Log.i(TAG, "Enforcing limits (max: $maxOverall, s: $maxServer, c: $maxClient) - evicting ${toEvict.size} connections") + + toEvict.forEach { conn -> + if (conn.isClient) { + try { conn.gatt?.disconnect() } catch (_: Exception) { } + } else { + serverManager.disconnectDevice(conn.device) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error enforcing limits: ${e.message}") + } } /** * Start all Bluetooth services with power optimization */ fun startServices(): Boolean { - Log.i(TAG, "Starting power-optimized Bluetooth services...") + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services") + disableTransport() + return false + } if (!permissionManager.hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") @@ -127,18 +255,28 @@ class BluetoothConnectionManager( // Start power manager powerManager.start() - // Start server manager - if (!serverManager.start()) { - Log.e(TAG, "Failed to start server manager") - this@BluetoothConnectionManager.isActive = false - return@launch + // Start server/client based on debug settings + val startServer = isGattServerEnabled() + val startClient = isGattClientEnabled() + + if (startServer) { + if (!serverManager.start()) { + Log.e(TAG, "Failed to start server manager") + this@BluetoothConnectionManager.isActive = false + return@launch + } + } else { + Log.i(TAG, "GATT Server disabled by debug settings; not starting") } - - // Start client manager - if (!clientManager.start()) { - Log.e(TAG, "Failed to start client manager") - this@BluetoothConnectionManager.isActive = false - return@launch + + if (startClient) { + if (!clientManager.start()) { + Log.e(TAG, "Failed to start client manager") + this@BluetoothConnectionManager.isActive = false + return@launch + } + } else { + Log.i(TAG, "GATT Client disabled by debug settings; not starting") } Log.i(TAG, "Bluetooth services started successfully") @@ -152,23 +290,31 @@ class BluetoothConnectionManager( return false } } + + /** + * Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled. + */ + fun disableTransport() { + Log.i(TAG, "Disabling BLE transport") + isActive = false + connectionScope.launch { + clientManager.stop() + serverManager.stop() + connectionTracker.stop() + } + } /** * Stop all Bluetooth services with proper cleanup */ fun stopServices() { - Log.i(TAG, "Stopping power-optimized Bluetooth services") - isActive = false - + connectionScope.launch { // Stop component managers clientManager.stop() serverManager.stop() - // Stop power manager - powerManager.stop() - // Stop connection tracker connectionTracker.stop() @@ -178,28 +324,138 @@ class BluetoothConnectionManager( Log.i(TAG, "All Bluetooth services stopped") } } - - /** - * Set app background state for power optimization - */ - fun setAppBackgroundState(inBackground: Boolean) { - powerManager.setAppBackgroundState(inBackground) - } + /** + * Indicates whether this instance can be safely reused for a future start. + * Returns false if its coroutine scope has been cancelled. + */ + fun isReusable(): Boolean { + return connectionScope.isActive + } + /** * Broadcast packet to connected devices with connection limit enforcement * Automatically fragments large packets to fit within BLE MTU limits */ - fun broadcastPacket(routed: RoutedPacket) { - if (!isActive) return - - packetBroadcaster.broadcastPacket( + fun broadcastPacket(routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + + return packetBroadcaster.broadcastPacket( routed, serverManager.getGattServer(), serverManager.getCharacteristic() ) } + + suspend fun broadcastControlPacketAndAwaitAcceptance(routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + + return packetBroadcaster.broadcastControlPacketAndAwaitAcceptance( + routed, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendToPeer( + peerID, + routed, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + + fun cancelTransfer(transferId: String): Boolean { + return packetBroadcaster.cancelTransfer(transferId) + } + + /** + * Send a packet directly to a specific peer, without broadcasting to others. + */ + fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToPeer( + RoutedPacket(packet), + peerID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + + fun sendPacketToLink(deviceAddress: String, linkID: String, packet: BitchatPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToLink( + RoutedPacket(packet), + deviceAddress, + linkID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + + // Expose role controls for debug UI + fun startServer() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattServerEnabled()) serverManager.start() } + } + fun stopServer() { connectionScope.launch { serverManager.stop() } } + fun startClient() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattClientEnabled()) clientManager.start() } + } + fun stopClient() { connectionScope.launch { clientManager.stop() } } + + // Inject nickname resolver for broadcaster logs + fun setNicknameResolver(resolver: (String) -> String?) { packetBroadcaster.setNicknameResolver(resolver) } + + // Debug snapshots for connected devices + fun getConnectedDeviceEntries(): List> { + return try { + connectionTracker.getConnectedDevices().values.map { dc -> + val rssi = if (dc.rssi != Int.MIN_VALUE) dc.rssi else null + Triple(dc.device.address, dc.isClient, rssi) + } + } catch (e: Exception) { + emptyList() + } + } + + // Expose local adapter address for debug UI + fun getLocalAdapterAddress(): String? = try { bluetoothAdapter?.address } catch (e: Exception) { null } + + fun isClientConnection(address: String): Boolean? { + return try { connectionTracker.getConnectedDevices()[address]?.isClient } catch (e: Exception) { null } + } + + /** + * Public: connect/disconnect helpers for debug UI + */ + fun connectToAddress(address: String): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return clientManager.connectToAddress(address) + } + fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) } + + + // Optionally disconnect all connections (server and client) + fun disconnectAll() { + connectionScope.launch { + // Stop and restart to force disconnects + clientManager.stop() + serverManager.stop() + delay(200) + if (isActive && isBleTransportEnabled()) { + // Restart managers if service is active + if (isGattServerEnabled()) serverManager.start() + if (isGattClientEnabled()) clientManager.start() + } + } + } + + /** * Get connected device count */ @@ -223,44 +479,21 @@ class BluetoothConnectionManager( } } - // MARK: - PowerManagerDelegate Implementation - - override fun onPowerModeChanged(newMode: PowerManager.PowerMode) { - Log.i(TAG, "Power mode changed to: $newMode") - - connectionScope.launch { - // Avoid rapid scan restarts by checking if we need to change scan behavior - val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() - - // Update advertising with new power settings - serverManager.restartAdvertising() - - // Only restart scanning if the duty cycle behavior changed - val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() - if (wasUsingDutyCycle != nowUsingDutyCycle) { - Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan") - clientManager.restartScanning() - } else { - Log.d(TAG, "Duty cycle behavior unchanged, keeping existing scan state") - } - - // Enforce connection limits - connectionTracker.enforceConnectionLimits() - } - } - - override fun onScanStateChanged(shouldScan: Boolean) { - clientManager.onScanStateChanged(shouldScan) - } - - // MARK: - Private Implementation - All moved to component managers } /** * Delegate interface for Bluetooth connection manager callbacks */ interface BluetoothConnectionManagerDelegate { - fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) + fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) fun onDeviceConnected(device: BluetoothDevice) + fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) fun onRSSIUpdated(deviceAddress: String, rssi: Int) + fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) = Unit + fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) = Unit } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 92d91ff5..94feb68d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.UUID /** * Tracks all Bluetooth connections and handles cleanup @@ -16,29 +17,22 @@ import java.util.concurrent.CopyOnWriteArrayList class BluetoothConnectionTracker( private val connectionScope: CoroutineScope, private val powerManager: PowerManager -) { +) : MeshConnectionTracker(connectionScope, TAG) { companion object { private const val TAG = "BluetoothConnectionTracker" - private const val CONNECTION_RETRY_DELAY = 5000L - private const val MAX_CONNECTION_ATTEMPTS = 3 - private const val CLEANUP_DELAY = 500L - private const val CLEANUP_INTERVAL = 30000L // 30 seconds + private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS } // Connection tracking - reduced memory footprint private val connectedDevices = ConcurrentHashMap() private val subscribedDevices = CopyOnWriteArrayList() val addressPeerMap = ConcurrentHashMap() - + // Track whether we have seen the first ANNOUNCE on a given device connection + private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() - - // Connection attempt tracking with automatic cleanup - private val pendingConnections = ConcurrentHashMap() - - // State management - private var isActive = false + private val connectionStateLock = Any() /** * Consolidated device connection information @@ -49,55 +43,68 @@ class BluetoothConnectionTracker( val characteristic: BluetoothGattCharacteristic? = null, val rssi: Int = Int.MIN_VALUE, val isClient: Boolean = false, - val connectedAt: Long = System.currentTimeMillis() + val connectedAt: Long = System.currentTimeMillis(), + val peerID: String? = null, + /** Unique to this GATT connection, even when Android reuses the device address. */ + val linkID: String = UUID.randomUUID().toString() ) - /** - * Connection attempt tracking with automatic expiry - */ - data class ConnectionAttempt( - val attempts: Int, - val lastAttempt: Long = System.currentTimeMillis() - ) { - fun isExpired(): Boolean = - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 - - fun shouldRetry(): Boolean = - attempts < MAX_CONNECTION_ATTEMPTS && - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + override fun start() { + super.start() } - /** - * Start the connection tracker - */ - fun start() { - isActive = true - startPeriodicCleanup() - } - - /** - * Stop the connection tracker - */ - fun stop() { - isActive = false + override fun stop() { + super.stop() cleanupAllConnections() clearAllConnections() } + + // Abstract implementations + override fun isConnected(id: String): Boolean = connectedDevices.containsKey(id) + + override fun disconnect(id: String) { + connectedDevices[id]?.gatt?.let { + try { it.disconnect() } catch (_: Exception) { } + } + cleanupDeviceConnection(id) + Log.d(TAG, "Requested disconnect for $id") + } + + override fun getConnectionCount(): Int = connectedDevices.size /** * Add a device connection */ fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") - connectedDevices[deviceAddress] = deviceConn - pendingConnections.remove(deviceAddress) + synchronized(connectionStateLock) { + connectedDevices[deviceAddress] = deviceConn + // A route observation belongs to this GATT generation, not its reusable address. + addressPeerMap.remove(deviceAddress) + } + removePendingConnection(deviceAddress) + // Mark as awaiting first ANNOUNCE on this connection + firstAnnounceSeen[deviceAddress] = false } /** * Update a device connection */ fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { - connectedDevices[deviceAddress] = deviceConn + synchronized(connectionStateLock) { + connectedDevices[deviceAddress] = deviceConn + } + } + + fun updateDeviceConnectionIfCurrent( + deviceAddress: String, + linkID: String, + update: (DeviceConnection) -> DeviceConnection + ): Boolean = synchronized(connectionStateLock) { + val current = connectedDevices[deviceAddress] ?: return@synchronized false + if (current.linkID != linkID) return@synchronized false + connectedDevices[deviceAddress] = update(current) + true } /** @@ -106,6 +113,23 @@ class BluetoothConnectionTracker( fun getDeviceConnection(deviceAddress: String): DeviceConnection? { return connectedDevices[deviceAddress] } + + fun getCurrentLinkID(deviceAddress: String): String? = + connectedDevices[deviceAddress]?.linkID + + /** + * Records that the current link delivered a validated, non-relayed ANNOUNCE for [peerID]. + * + * A peer may be reachable over more than one link, so observing one link must not discard the + * other observations. The link generation check prevents a late packet from an old GATT + * connection from being applied to a replacement connection that reused the same address. + */ + fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + synchronized(connectionStateLock) { + if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false + addressPeerMap[deviceAddress] = peerID + true + } /** * Get all connected devices @@ -163,95 +187,117 @@ class BluetoothConnectionTracker( /** * Check if device is already connected */ - fun isDeviceConnected(deviceAddress: String): Boolean { - return connectedDevices.containsKey(deviceAddress) + fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress) + + /** + * Check if a peer is already connected (by PeerID) + */ + fun isPeerConnected(peerID: String): Boolean { + // Only consider actual connected devices that have identified themselves + return connectedDevices.values.any { it.peerID == peerID } } /** - * Check if connection attempt is allowed + * Disconnect a specific device (by MAC address) */ - fun isConnectionAttemptAllowed(deviceAddress: String): Boolean { - val existingAttempt = pendingConnections[deviceAddress] - return existingAttempt?.let { - it.isExpired() || it.shouldRetry() - } ?: true - } - - /** - * Add a pending connection attempt - */ - fun addPendingConnection(deviceAddress: String): Boolean { - Log.d(TAG, "Tracker: Adding pending connection for $deviceAddress") - synchronized(pendingConnections) { - // Double-check inside synchronized block - val currentAttempt = pendingConnections[deviceAddress] - if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { - Log.d(TAG, "Tracker: Connection attempt already in progress for $deviceAddress") - return false - } - if (currentAttempt != null) { - Log.d(TAG, "Tracker: current attempt: $currentAttempt") - } - - // Update connection attempt atomically - val attempts = (currentAttempt?.attempts ?: 0) + 1 - pendingConnections[deviceAddress] = ConnectionAttempt(attempts) - Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)") - return true - } - } - - /** - * Remove a pending connection - */ - fun removePendingConnection(deviceAddress: String) { - pendingConnections.remove(deviceAddress) - } + fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress) /** * Get connected device count */ - fun getConnectedDeviceCount(): Int = connectedDevices.size + fun getConnectedDeviceCount(): Int = getConnectionCount() /** * Check if connection limit is reached */ - fun isConnectionLimitReached(): Boolean { - return connectedDevices.size >= powerManager.getMaxConnections() + /** + * Check if a new client connection is allowed based on limits + */ + fun canConnectAsClient(maxOverall: Int, maxClient: Int): Boolean { + val total = connectedDevices.size + val clients = connectedDevices.values.count { it.isClient } + return total < maxOverall && clients < maxClient } /** - * Enforce connection limits by disconnecting oldest connections + * Calculate which connections should be evicted to satisfy limits. + * Logic: + * 1. Enforce strict role limits (maxClient, maxServer) - evict oldest excess. + * 2. Enforce overall limit (maxOverall) - evict oldest remaining, preferring clients. */ - fun enforceConnectionLimits() { - val maxConnections = powerManager.getMaxConnections() - if (connectedDevices.size > maxConnections) { - Log.i(TAG, "Enforcing connection limit: ${connectedDevices.size} > $maxConnections") + fun getConnectionsToEvict(maxOverall: Int, maxServer: Int, maxClient: Int): List { + val toEvict = mutableSetOf() + val currentDevices = connectedDevices.values.toList() + + // 1. Enforce Role Limits + val clients = currentDevices.filter { it.isClient }.sortedBy { it.connectedAt } + if (clients.size > maxClient) { + toEvict.addAll(clients.take(clients.size - maxClient)) + } + + val servers = currentDevices.filter { !it.isClient }.sortedBy { it.connectedAt } + if (servers.size > maxServer) { + toEvict.addAll(servers.take(servers.size - maxServer)) + } + + // 2. Enforce Overall Limit + // Count how many would remain after the above evictions + val remaining = currentDevices.filter { !toEvict.contains(it) } + if (remaining.size > maxOverall) { + val excessCount = remaining.size - maxOverall - // Disconnect oldest client connections first - val sortedConnections = connectedDevices.values - .filter { it.isClient } - .sortedBy { it.connectedAt } + // Explicitly prefer evicting clients first + val clientCandidates = remaining.filter { it.isClient }.sortedBy { it.connectedAt } + val serverCandidates = remaining.filter { !it.isClient }.sortedBy { it.connectedAt } - val toDisconnect = sortedConnections.take(connectedDevices.size - maxConnections) - toDisconnect.forEach { deviceConn -> - Log.d(TAG, "Disconnecting ${deviceConn.device.address} due to connection limit") - deviceConn.gatt?.disconnect() + var needed = excessCount + + // Take from clients first + val fromClients = clientCandidates.take(needed) + toEvict.addAll(fromClients) + needed -= fromClients.size + + // If still need more, take from servers + if (needed > 0) { + val fromServers = serverCandidates.take(needed) + toEvict.addAll(fromServers) } } + + return toEvict.toList() } /** * Clean up a specific device connection */ fun cleanupDeviceConnection(deviceAddress: String) { - connectedDevices.remove(deviceAddress)?.let { deviceConn -> + synchronized(connectionStateLock) { + connectedDevices.remove(deviceAddress) subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) + firstAnnounceSeen.remove(deviceAddress) } - pendingConnections.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") } + + fun cleanupDeviceConnectionIfCurrent( + deviceAddress: String, + expectedLinkID: String + ): Boolean = synchronized(connectionStateLock) { + val current = connectedDevices[deviceAddress] ?: return@synchronized false + if (current.linkID != expectedLinkID) { + return@synchronized false + } + if (connectedDevices.remove(deviceAddress, current)) { + subscribedDevices.removeAll { it.address == deviceAddress } + addressPeerMap.remove(deviceAddress) + firstAnnounceSeen.remove(deviceAddress) + Log.d(TAG, "Cleaned up device connection for $deviceAddress") + true + } else { + false + } + } /** * Clean up all connections @@ -283,36 +329,21 @@ class BluetoothConnectionTracker( addressPeerMap.clear() pendingConnections.clear() scanRSSI.clear() + firstAnnounceSeen.clear() } - + /** - * Start periodic cleanup of expired connections + * Mark that we have received the first ANNOUNCE over this device connection. */ - private fun startPeriodicCleanup() { - connectionScope.launch { - while (isActive) { - delay(CLEANUP_INTERVAL) - - if (!isActive) break - - try { - // Clean up expired pending connections - val expiredConnections = pendingConnections.filter { it.value.isExpired() } - expiredConnections.keys.forEach { pendingConnections.remove(it) } - - // Log cleanup if any - if (expiredConnections.isNotEmpty()) { - Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts") - } - - // Log current state - Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending") - - } catch (e: Exception) { - Log.w(TAG, "Error in periodic cleanup: ${e.message}") - } - } - } + fun noteAnnounceReceived(deviceAddress: String) { + firstAnnounceSeen[deviceAddress] = true + } + + /** + * Check whether the first ANNOUNCE has been seen for a device connection. + */ + fun hasSeenFirstAnnounce(deviceAddress: String): Boolean { + return firstAnnounceSeen[deviceAddress] == true } /** @@ -341,4 +372,4 @@ class BluetoothConnectionTracker( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index f47aba15..a0f7c207 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -9,11 +9,14 @@ import android.content.Context import android.os.ParcelUuid import android.util.Log import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.AppConstants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* import kotlinx.coroutines.Job +import com.bitchat.android.ui.debug.DebugSettingsManager +import com.bitchat.android.ui.debug.DebugScanResult /** * Manages GATT client operations, scanning, and client-side connections @@ -29,13 +32,11 @@ class BluetoothGattClientManager( companion object { private const val TAG = "BluetoothGattClientManager" - // Use exact same UUIDs as iOS version - private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") - private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") - private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - - // RSSI monitoring constants - private const val RSSI_UPDATE_INTERVAL = 5000L // 5 seconds + // Self-healing scan recovery tuning + private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures + private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay + private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive + private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long } // Core Bluetooth components @@ -43,18 +44,56 @@ class BluetoothGattClientManager( context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isClientRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } + /** + * Public: Connect to a device by MAC address (for debug UI) + */ + fun connectToAddress(deviceAddress: String): Boolean { + if (!isClientRoleEnabled()) { + Log.d(TAG, "connectToAddress skipped: BLE client disabled") + return false + } + val device = bluetoothAdapter?.getRemoteDevice(deviceAddress) + return if (device != null) { + val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50 + connectToDevice(device, rssi) + true + } else { + Log.w(TAG, "connectToAddress: No device for $deviceAddress") + false + } + } + // Scan management private var scanCallback: ScanCallback? = null // Scan rate limiting to prevent "scanning too frequently" errors private var lastScanStartTime = 0L private var lastScanStopTime = 0L - private var isCurrentlyScanning = false + @Volatile private var isCurrentlyScanning = false private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts - - // RSSI monitoring state - private var rssiMonitoringJob: Job? = null + + // Self-healing scan state. + // scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover + // from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled). + @Volatile private var scanningDesired = false + @Volatile private var lastScanResultTime = 0L + private var scanRetryCount = 0 + private var scanWatchdogJob: Job? = null + private var scanDutyCycleJob: Job? = null // State management private var isActive = false @@ -63,6 +102,15 @@ class BluetoothGattClientManager( * Start client manager */ fun start(): Boolean { + // Respect debug setting + if (!isClientRoleEnabled()) { + Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings") + return false + } + + if (isActive) { + return true + } if (!permissionManager.hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") return false @@ -81,14 +129,7 @@ class BluetoothGattClientManager( isActive = true connectionScope.launch { - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Using power-aware duty cycling") - } else { - startScanning() - } - - // Start RSSI monitoring - startRSSIMonitoring() + applyPowerProfile(powerManager.profile.value) } return true @@ -98,11 +139,28 @@ class BluetoothGattClientManager( * Stop client manager */ fun stop() { + scanningDesired = false + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null + stopScanWatchdog() + if (!isActive) { + // Idempotent stop + stopScanning() + return + } + isActive = false connectionScope.launch { + // Disconnect all client connections decisively + try { + val conns = connectionTracker.getConnectedDevices().values.filter { it.isClient && it.gatt != null } + conns.forEach { dc -> + try { dc.gatt?.disconnect() } catch (_: Exception) { } + } + } catch (_: Exception) { } + stopScanning() - stopRSSIMonitoring() Log.i(TAG, "GATT client manager stopped") } } @@ -111,71 +169,39 @@ class BluetoothGattClientManager( * Handle scan state changes from power manager */ fun onScanStateChanged(shouldScan: Boolean) { - if (shouldScan) { + val enabled = isClientRoleEnabled() + scanningDesired = shouldScan && enabled + if (shouldScan && enabled) { startScanning() } else { stopScanning() } } - /** - * Start periodic RSSI monitoring for all client connections - */ - private fun startRSSIMonitoring() { - rssiMonitoringJob?.cancel() - rssiMonitoringJob = connectionScope.launch { - while (isActive) { - try { - // Request RSSI from all client connections - val connectedDevices = connectionTracker.getConnectedDevices() - connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn -> - try { - Log.d(TAG, "Requesting RSSI from ${deviceConn.device.address}") - deviceConn.gatt?.readRemoteRssi() - } catch (e: Exception) { - Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") - } - } - delay(RSSI_UPDATE_INTERVAL) - } catch (e: Exception) { - Log.w(TAG, "Error in RSSI monitoring: ${e.message}") - delay(RSSI_UPDATE_INTERVAL) - } - } - } - } - - /** - * Stop RSSI monitoring - */ - private fun stopRSSIMonitoring() { - rssiMonitoringJob?.cancel() - rssiMonitoringJob = null - } - /** * Start scanning with rate limiting */ @Suppress("DEPRECATION") private fun startScanning() { - if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive) return + // Respect debug setting + val enabled = isClientRoleEnabled() + if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return // Rate limit scan starts to prevent "scanning too frequently" errors val currentTime = System.currentTimeMillis() if (isCurrentlyScanning) { - Log.d(TAG, "Scan already in progress, skipping start request") return } - + val timeSinceLastStart = currentTime - lastScanStartTime if (timeSinceLastStart < scanRateLimit) { val remainingWait = scanRateLimit - timeSinceLastStart - Log.w(TAG, "Scan rate limited: need to wait ${remainingWait}ms before starting scan") + Log.d(TAG, "Scan rate limited: waiting ${remainingWait}ms before starting scan") // Schedule delayed scan start connectionScope.launch { delay(remainingWait) - if (isActive && !isCurrentlyScanning) { + if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) { startScanning() } } @@ -183,49 +209,55 @@ class BluetoothGattClientManager( } val scanFilter = ScanFilter.Builder() - .setServiceUuid(ParcelUuid(SERVICE_UUID)) + .setServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) .build() - val scanFilters = listOf(scanFilter) - - Log.d(TAG, "Starting BLE scan with target service UUID: $SERVICE_UUID") - + val scanFilters = listOf(scanFilter) + scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { - // Log.d(TAG, "Scan result received: ${result.device.address}") handleScanResult(result) } - + override fun onBatchScanResults(results: MutableList) { - Log.d(TAG, "Batch scan results received: ${results.size} devices") results.forEach { result -> handleScanResult(result) } } - + override fun onScanFailed(errorCode: Int) { - Log.e(TAG, "Scan failed: $errorCode") isCurrentlyScanning = false lastScanStopTime = System.currentTimeMillis() - + when (errorCode) { - 1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED") - 2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED") - 3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR") - 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") - 5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES") - 6 -> { - Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY") - Log.w(TAG, "Scan failed due to rate limiting - will retry after delay") - connectionScope.launch { - delay(10000) // Wait 10 seconds before retrying - if (isActive) { - startScanning() - } - } + 1 -> { + // Already started: the stack thinks a scan is running. Re-arm from a clean + // state so we don't stay wedged (stop then restart with backoff). + stopScanning() + scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS) + } + 2 -> { + // App registration failed: common transient stack fault. Previously had NO + // retry, which left discovery dead until a manual BLE toggle. + scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS) + } + 3 -> { + scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS) + } + 4 -> Unit // permanent: don't retry + 5 -> { + // Out of hardware resources: back off longer so other scanners/connections + // can free up before we try again. + scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3) + } + 6 -> { + scheduleScanRestart("too-frequently", 10_000L) + } + else -> { + scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS) } - else -> Log.e(TAG, "Unknown scan failure code: $errorCode") } + Log.e(TAG, "Scan failed: $errorCode") } } @@ -234,7 +266,7 @@ class BluetoothGattClientManager( isCurrentlyScanning = true bleScanner.startScan(scanFilters, powerManager.getScanSettings(), scanCallback) - Log.d(TAG, "BLE scan started successfully") + Log.i(TAG, "BLE scan started") } catch (e: Exception) { Log.e(TAG, "Exception starting scan: ${e.message}") isCurrentlyScanning = false @@ -250,9 +282,9 @@ class BluetoothGattClientManager( if (isCurrentlyScanning) { try { - scanCallback?.let { + scanCallback?.let { bleScanner.stopScan(it) - Log.d(TAG, "BLE scan stopped successfully") + Log.i(TAG, "BLE scan stopped") } } catch (e: Exception) { Log.w(TAG, "Error stopping scan: ${e.message}") @@ -262,6 +294,76 @@ class BluetoothGattClientManager( lastScanStopTime = System.currentTimeMillis() } } + + /** + * Schedule a scan restart with incremental backoff. Used to recover from transient scan + * failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a + * manual BLE toggle. + */ + private fun scheduleScanRestart(reason: String, baseDelayMs: Long) { + scanRetryCount++ + val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS) + Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)") + connectionScope.launch { + delay(delayMs) + if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) { + startScanning() + } + } + } + + /** + * Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking + * onScanFailed (internal stack reset, Doze, background throttling), which leaves the app + * believing it is scanning while it is not. This re-arms the scanner in those cases. + */ + private fun startScanWatchdog() { + scanWatchdogJob?.cancel() + scanWatchdogJob = connectionScope.launch { + while (isActive) { + delay(SCAN_WATCHDOG_INTERVAL_MS) + try { + // Only act when we are supposed to be scanning. Honors duty-cycle OFF windows + // and the client-disabled state via scanningDesired. + if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue + if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue + + val now = System.currentTimeMillis() + if (!isCurrentlyScanning) { + Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan") + startScanning() + } else if (lastScanResultTime > 0L && + now - lastScanResultTime > SCAN_STALE_RESULT_MS && + now - lastScanStartTime > SCAN_STALE_RESULT_MS) { + // We think we're scanning but haven't seen anything for a long time. The scan + // may have silently died (flag wedged true). Force a clean re-arm. + Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart") + forceRestartScan() + } + } catch (e: Exception) { + Log.w(TAG, "Scan watchdog error: ${e.message}") + } + } + } + } + + private fun stopScanWatchdog() { + scanWatchdogJob?.cancel() + scanWatchdogJob = null + } + + /** + * Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag. + */ + private fun forceRestartScan() { + stopScanning() + connectionScope.launch { + delay(500) + if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) { + startScanning() + } + } + } /** * Handle scan result and initiate connection if appropriate @@ -273,19 +375,57 @@ class BluetoothGattClientManager( val scanRecord = result.scanRecord // CRITICAL: Only process devices that have our service UUID - val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } == true + val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == AppConstants.Mesh.Gatt.SERVICE_UUID } == true if (!hasOurService) { return } - // Log.d(TAG, "Received scan result from $deviceAddress - already connected: ${connectionTracker.isDeviceConnected(deviceAddress)}") - + // Proof the scanner is alive and finding our network: refresh liveness and clear backoff. + lastScanResultTime = System.currentTimeMillis() + scanRetryCount = 0 + + // Try to extract peerID from Service Data (if available) for stable identity + val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) + val peerID = if (serviceData != null && serviceData.size >= 8) { + serviceData.joinToString("") { "%02x".format(it) } + } else { + null + } + + if (peerID != null) { + if (connectionTracker.isPeerConnected(peerID)) { + return + } + } + // Store RSSI from scan results for later use (especially for server connections) connectionTracker.updateScanRSSI(deviceAddress, rssi) + + // Publish scan result to debug UI buffer + try { + DebugSettingsManager.getInstance().addScanResult( + DebugScanResult( + deviceName = device.name, + deviceAddress = deviceAddress, + rssi = rssi, + peerID = peerID // Use the discovered peerID if available + ) + ) + } catch (_: Exception) { } // Power-aware RSSI filtering if (rssi < powerManager.getRSSIThreshold()) { - Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}") + // Even if we skip connecting, still publish scan result to debug UI + try { + DebugSettingsManager.getInstance().addScanResult( + DebugScanResult( + deviceName = device.name, + deviceAddress = deviceAddress, + rssi = rssi, + peerID = peerID + ) + ) + } catch (_: Exception) { } return } @@ -296,18 +436,21 @@ class BluetoothGattClientManager( // Check if connection attempt is allowed if (!connectionTracker.isConnectionAttemptAllowed(deviceAddress)) { - Log.d(TAG, "Connection to $deviceAddress not allowed due to recent attempts") return } - if (connectionTracker.isConnectionLimitReached()) { - Log.d(TAG, "Connection limit reached (${powerManager.getMaxConnections()})") + // Check if connection limit is reached + val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } + val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections() + val maxClient = dbg?.maxClientConnections?.value ?: maxOverall + + if (!connectionTracker.canConnectAsClient(maxOverall, maxClient)) { return } // Add pending connection and start connection if (connectionTracker.addPendingConnection(deviceAddress)) { - connectToDevice(device, rssi) + connectToDevice(device, rssi, peerID) } } @@ -315,18 +458,17 @@ class BluetoothGattClientManager( * Connect to a device as GATT client */ @Suppress("DEPRECATION") - private fun connectToDevice(device: BluetoothDevice, rssi: Int) { + private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) { + if (!isClientRoleEnabled()) return if (!permissionManager.hasBluetoothPermissions()) return val deviceAddress = device.address - Log.i(TAG, "Connecting to bitchat device: $deviceAddress") - + val linkID = UUID.randomUUID().toString() + Log.d(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)") + val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - Log.d(TAG, "Client: Connection state change - Device: $deviceAddress, Status: $status, NewState: $newState") - if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { - Log.i(TAG, "Client: Successfully connected to $deviceAddress. Requesting MTU...") // Request a larger MTU. Must be done before any data transfer. connectionScope.launch { delay(200) // A small delay can improve reliability of MTU request. @@ -334,14 +476,16 @@ class BluetoothGattClientManager( } } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { if (status != BluetoothGatt.GATT_SUCCESS) { - Log.w(TAG, "Client: Disconnected from $deviceAddress with error status $status") - if (status == 147) { - Log.e(TAG, "Client: Connection establishment failed (status 147) for $deviceAddress") - } + Log.w(TAG, "Disconnected from $deviceAddress with error status $status (client)") } else { - Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress") - connectionTracker.cleanupDeviceConnection(deviceAddress) + Log.i(TAG, "Disconnected from $deviceAddress (client)") } + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[deviceAddress] + connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID) + + // Notify higher layers about device disconnection to update direct flags + delegate?.onDeviceDisconnected(gatt.device, linkID, disconnectedPeerID) connectionScope.launch { delay(500) // CLEANUP_DELAY @@ -356,17 +500,16 @@ class BluetoothGattClientManager( override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { val deviceAddress = gatt.device.address - Log.i(TAG, "Client: MTU changed for $deviceAddress to $mtu with status $status") if (status == BluetoothGatt.GATT_SUCCESS) { - Log.i(TAG, "MTU successfully negotiated for $deviceAddress. Discovering services.") - // Now that MTU is set, connection is fully ready. val deviceConn = BluetoothConnectionTracker.DeviceConnection( device = gatt.device, gatt = gatt, rssi = rssi, - isClient = true + isClient = true, + peerID = peerID, // Store the peerID discovered during scan + linkID = linkID ) connectionTracker.addDeviceConnection(deviceAddress, deviceConn) @@ -381,25 +524,27 @@ class BluetoothGattClientManager( override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { - val service = gatt.getService(SERVICE_UUID) + val service = gatt.getService(AppConstants.Mesh.Gatt.SERVICE_UUID) if (service != null) { - val characteristic = service.getCharacteristic(CHARACTERISTIC_UUID) + val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) if (characteristic != null) { - connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> - val updatedConn = deviceConn.copy(characteristic = characteristic) - connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) - Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress") + if (connectionTracker.updateDeviceConnectionIfCurrent( + deviceAddress, + linkID + ) { it.copy(characteristic = characteristic) } + ) { + // Characteristic stored on the current device connection } gatt.setCharacteristicNotification(characteristic, true) - val descriptor = characteristic.getDescriptor(DESCRIPTOR_UUID) + val descriptor = characteristic.getDescriptor(AppConstants.Mesh.Gatt.DESCRIPTOR_UUID) if (descriptor != null) { descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) connectionScope.launch { delay(200) - Log.i(TAG, "Client: Connection setup complete for $deviceAddress") + Log.i(TAG, "Connected to $deviceAddress (client)") delegate?.onDeviceConnected(device) } } else { @@ -422,43 +567,33 @@ class BluetoothGattClientManager( override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { val value = characteristic.value - Log.i(TAG, "Client: Received packet from ${gatt.device.address}, size: ${value.size} bytes") val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } - Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID") - delegate?.onPacketReceived(packet, peerID, gatt.device) + delegate?.onPacketReceived(packet, peerID, gatt.device, linkID) } else { - Log.w(TAG, "Client: Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes") - Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") + Log.d(TAG, "Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes") + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) { + delegate?.onGattClientWriteComplete(gatt.device.address, linkID, status) } } - override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { - val deviceAddress = gatt.device.address - if (status == BluetoothGatt.GATT_SUCCESS) { - Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm") - - // Update the connection tracker with new RSSI value - connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> - val updatedConn = deviceConn.copy(rssi = rssi) - connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) - } - } else { - Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status") - } - } } try { - Log.d(TAG, "Client: Attempting GATT connection to $deviceAddress with autoConnect=false") val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) if (gatt == null) { Log.e(TAG, "connectGatt returned null for $deviceAddress") // keep the pending connection so we can avoid too many reconnections attempts, TODO: needs testing // connectionTracker.removePendingConnection(deviceAddress) - } else { - Log.d(TAG, "Client: GATT connection initiated successfully for $deviceAddress") } } catch (e: Exception) { Log.e(TAG, "Client: Exception connecting to $deviceAddress: ${e.message}") @@ -471,19 +606,45 @@ class BluetoothGattClientManager( * Restart scanning for power mode changes */ fun restartScanning() { - if (!isActive) return + // Respect debug setting + val enabled = isClientRoleEnabled() + if (!isActive || !enabled) return connectionScope.launch { stopScanning() delay(1000) // Extra delay to avoid rate limiting - - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Switching to duty cycle scanning mode") - // Duty cycle will handle scanning - } else { - Log.i(TAG, "Switching to continuous scanning mode") - startScanning() + applyPowerProfile(powerManager.profile.value) + } + } + + /** + * Apply the current process-wide profile without ever disabling background discovery. + */ + fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) { + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null + if (!isActive || !isClientRoleEnabled()) { + onScanStateChanged(false) + return + } + + if (profile.ble.continuousScan) { + startScanWatchdog() + onScanStateChanged(true) + return + } + + // Duty-cycled scans are re-armed every window, so the continuous-scan watchdog would only + // create background wakeups during intentional OFF periods. + stopScanWatchdog() + scanDutyCycleJob = connectionScope.launch { + while (isActive && isClientRoleEnabled()) { + onScanStateChanged(true) + delay(profile.ble.scanOnMs) + if (!isActive || !isClientRoleEnabled()) break + onScanStateChanged(false) + delay(profile.ble.scanOffMs) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 99c23f0c..038cd960 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -9,10 +9,12 @@ import android.content.Context import android.os.ParcelUuid import android.util.Log import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.AppConstants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* +import java.util.concurrent.ConcurrentHashMap /** * Manages GATT server operations, advertising, and server-side connections @@ -23,15 +25,15 @@ class BluetoothGattServerManager( private val connectionTracker: BluetoothConnectionTracker, private val permissionManager: BluetoothPermissionManager, private val powerManager: PowerManager, - private val delegate: BluetoothConnectionManagerDelegate? + private val delegate: BluetoothConnectionManagerDelegate?, + private val myPeerID: String ) { companion object { private const val TAG = "BluetoothGattServerManager" - // Use exact same UUIDs as iOS version - private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") - private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") - private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + // Self-healing advertising recovery tuning + private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures + private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay } // Core Bluetooth components @@ -42,16 +44,51 @@ class BluetoothGattServerManager( // GATT server for peripheral mode private var gattServer: BluetoothGattServer? = null + private val serverLinkIDs = ConcurrentHashMap() private var characteristic: BluetoothGattCharacteristic? = null private var advertiseCallback: AdvertiseCallback? = null + private var advertiseRetryCount = 0 // State management private var isActive = false + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isServerRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + + /** + * Disconnect a specific device (used by ConnectionManager to enforce overall limits) + */ + fun disconnectDevice(device: BluetoothDevice) { + try { + gattServer?.cancelConnection(device) + } catch (e: Exception) { + Log.w(TAG, "Error disconnecting device ${device.address}: ${e.message}") + } + } /** * Start GATT server */ fun start(): Boolean { + // Respect debug setting + if (!isServerRoleEnabled()) { + Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings") + return false + } + + if (isActive) { + return true + } if (!permissionManager.hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") return false @@ -82,14 +119,34 @@ class BluetoothGattServerManager( * Stop GATT server */ fun stop() { + if (!isActive) { + // Idempotent stop + stopAdvertising() + // Ensure server is closed if present + gattServer?.close() + gattServer = null + serverLinkIDs.clear() + return + } + isActive = false - + connectionScope.launch { stopAdvertising() + // Try to cancel any active connections explicitly before closing + try { + // Disconnect ALL server connections + val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient } + servers.forEach { d -> + try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { } + } + } catch (_: Exception) { } + // Close GATT server gattServer?.close() gattServer = null + serverLinkIDs.clear() Log.i(TAG, "GATT server stopped") } @@ -116,13 +173,14 @@ class BluetoothGattServerManager( override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { // Guard against callbacks after service shutdown if (!isActive) { - Log.d(TAG, "Server: Ignoring connection state change after shutdown") return } - + when (newState) { BluetoothProfile.STATE_CONNECTED -> { - Log.i(TAG, "Server: Device connected ${device.address}") + Log.i(TAG, "Connected to ${device.address} (server)") + val linkID = UUID.randomUUID().toString() + serverLinkIDs[device.address] = linkID // Get best available RSSI (scan RSSI for server connections) val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE @@ -130,7 +188,8 @@ class BluetoothGattServerManager( val deviceConn = BluetoothConnectionTracker.DeviceConnection( device = device, rssi = rssi, - isClient = false + isClient = false, + linkID = linkID ) connectionTracker.addDeviceConnection(device.address, deviceConn) @@ -142,8 +201,15 @@ class BluetoothGattServerManager( } } BluetoothProfile.STATE_DISCONNECTED -> { - Log.i(TAG, "Server: Device disconnected ${device.address}") - connectionTracker.cleanupDeviceConnection(device.address) + Log.i(TAG, "Disconnected from ${device.address} (server)") + val linkID = serverLinkIDs.remove(device.address) + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[device.address] + if (linkID != null) { + connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID) + } + // Notify delegate about device disconnection so higher layers can update direct flags + delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID) } } } @@ -151,13 +217,10 @@ class BluetoothGattServerManager( override fun onServiceAdded(status: Int, service: BluetoothGattService) { // Guard against callbacks after service shutdown if (!isActive) { - Log.d(TAG, "Server: Ignoring service added callback after shutdown") return } - - if (status == BluetoothGatt.GATT_SUCCESS) { - Log.d(TAG, "Server: Service added successfully: ${service.uuid}") - } else { + + if (status != BluetoothGatt.GATT_SUCCESS) { Log.e(TAG, "Server: Failed to add service: ${service.uuid}, status: $status") } } @@ -173,20 +236,30 @@ class BluetoothGattServerManager( ) { // Guard against callbacks after service shutdown if (!isActive) { - Log.d(TAG, "Server: Ignoring characteristic write after shutdown") return } - - if (characteristic.uuid == CHARACTERISTIC_UUID) { - Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes") + + if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) { + val linkID = serverLinkIDs[device.address] + if (linkID == null) { + Log.d(TAG, "Server: Dropping packet from stale connection ${device.address}") + if (responseNeeded) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_FAILURE, + 0, + null + ) + } + return + } val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } - Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID") - delegate?.onPacketReceived(packet, peerID, device) + delegate?.onPacketReceived(packet, peerID, device, linkID) } else { - Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes") - Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") + Log.d(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes") } if (responseNeeded) { @@ -206,14 +279,12 @@ class BluetoothGattServerManager( ) { // Guard against callbacks after service shutdown if (!isActive) { - Log.d(TAG, "Server: Ignoring descriptor write after shutdown") return } - + if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) { connectionTracker.addSubscribedDevice(device) - Log.d(TAG, "Server: Connection setup complete for ${device.address}") connectionScope.launch { delay(100) if (isActive) { // Check if still active @@ -226,23 +297,29 @@ class BluetoothGattServerManager( gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) } } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + delegate?.onGattServerNotificationComplete( + device.address, + serverLinkIDs[device.address], + status + ) + } } // Proper cleanup sequencing to prevent race conditions gattServer?.let { server -> - Log.d(TAG, "Cleaning up existing GATT server") try { server.close() } catch (e: Exception) { Log.w(TAG, "Error closing existing GATT server: ${e.message}") } } - + // Small delay to ensure cleanup is complete Thread.sleep(100) - + if (!isActive) { - Log.d(TAG, "Service inactive, skipping GATT server creation") return } @@ -251,7 +328,7 @@ class BluetoothGattServerManager( // Create characteristic with notification support characteristic = BluetoothGattCharacteristic( - CHARACTERISTIC_UUID, + AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or @@ -261,12 +338,12 @@ class BluetoothGattServerManager( ) val descriptor = BluetoothGattDescriptor( - DESCRIPTOR_UUID, + AppConstants.Mesh.Gatt.DESCRIPTOR_UUID, BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE ) characteristic?.addDescriptor(descriptor) - val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY) + val service = BluetoothGattService(AppConstants.Mesh.Gatt.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY) service.addCharacteristic(characteristic) gattServer?.addService(service) @@ -279,30 +356,90 @@ class BluetoothGattServerManager( */ @Suppress("DEPRECATION") private fun startAdvertising() { - if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null || !isActive || bluetoothAdapter == null || !bluetoothAdapter.isMultipleAdvertisementSupported()) { - throw Exception("Missing Bluetooth permissions or BLE advertiser not available") + // Respect debug setting + val enabled = isServerRoleEnabled() + + // Guard conditions – never throw here to avoid crashing the app from a background coroutine + if (!permissionManager.hasBluetoothPermissions()) { + Log.w(TAG, "Not starting advertising: missing Bluetooth permissions") + return + } + if (bluetoothAdapter == null) { + Log.w(TAG, "Not starting advertising: bluetoothAdapter is null") + return + } + if (!isActive) { + return + } + if (!enabled) { + Log.d(TAG, "Not starting advertising: GATT Server disabled via debug settings") + return + } + if (bleAdvertiser == null) { + Log.w(TAG, "Not starting advertising: BLE advertiser not available on this device") + return + } + if (!bluetoothAdapter.isMultipleAdvertisementSupported) { + Log.w(TAG, "Not starting advertising: multiple advertisement not supported on this device") + return } val settings = powerManager.getAdvertiseSettings() val data = AdvertiseData.Builder() - .addServiceUuid(ParcelUuid(SERVICE_UUID)) + .addServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) + .setIncludeTxPowerLevel(false) + .setIncludeDeviceName(false) + .build() + + // Add stable identity (first 8 bytes of peerID) to Scan Response + // This allows scanners to deduplicate devices even if MAC address rotates + val peerIDBytes = try { + myPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray().take(8).toByteArray() + } catch (e: Exception) { + ByteArray(0) + } + + val scanResponse = AdvertiseData.Builder() + .addServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID), peerIDBytes) .setIncludeTxPowerLevel(false) .setIncludeDeviceName(false) .build() advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { - Log.i(TAG, "Advertising started (power mode: ${powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]})") + advertiseRetryCount = 0 + val mode = try { + powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0] + } catch (_: Exception) { "unknown" } + Log.i(TAG, "Advertising started (power mode: $mode)") } - + override fun onStartFailure(errorCode: Int) { Log.e(TAG, "Advertising failed: $errorCode") + // Previously this only logged, so if advertising failed this device became + // undiscoverable until a manual BLE toggle. Retry transient failures with backoff. + when (errorCode) { + ADVERTISE_FAILED_ALREADY_STARTED -> Unit // already advertising, no retry + ADVERTISE_FAILED_DATA_TOO_LARGE -> Unit // config issue, not retrying + ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> Unit // unsupported, not retrying + ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> { + scheduleAdvertiseRestart("too-many-advertisers") + } + ADVERTISE_FAILED_INTERNAL_ERROR -> { + scheduleAdvertiseRestart("internal-error") + } + else -> { + scheduleAdvertiseRestart("unknown-$errorCode") + } + } } } try { - bleAdvertiser.startAdvertising(settings, data, advertiseCallback) + bleAdvertiser.startAdvertising(settings, data, scanResponse, advertiseCallback) + } catch (se: SecurityException) { + Log.e(TAG, "SecurityException starting advertising (missing permission?): ${se.message}") } catch (e: Exception) { Log.e(TAG, "Exception starting advertising: ${e.message}") } @@ -315,22 +452,44 @@ class BluetoothGattServerManager( private fun stopAdvertising() { if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null) return try { - advertiseCallback?.let { bleAdvertiser.stopAdvertising(it) } + advertiseCallback?.let { cb -> bleAdvertiser.stopAdvertising(cb) } } catch (e: Exception) { Log.w(TAG, "Error stopping advertising: ${e.message}") } } + /** + * Schedule an advertising restart with incremental backoff after a transient failure. + */ + private fun scheduleAdvertiseRestart(reason: String) { + advertiseRetryCount++ + val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS) + Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)") + connectionScope.launch { + delay(delayMs) + if (isActive && isServerRoleEnabled()) { + stopAdvertising() + delay(100) + startAdvertising() + } + } + } + /** * Restart advertising (for power mode changes) */ fun restartAdvertising() { - if (!isActive) return - + // Respect debug setting + val enabled = isServerRoleEnabled() + if (!isActive || !enabled) { + stopAdvertising() + return + } + connectionScope.launch { stopAdvertising() delay(100) startAdvertising() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 20cd8902..22f17867 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -4,14 +4,23 @@ import android.content.Context import android.util.Log import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString +import com.bitchat.android.services.VerificationService +import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import java.util.* import kotlin.math.sign import kotlin.random.Random @@ -29,25 +38,88 @@ import kotlin.random.Random * - BluetoothConnectionManager: BLE connections and GATT operations * - PacketProcessor: Incoming packet routing */ -class BluetoothMeshService(private val context: Context) { +class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer { + private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { private const val TAG = "BluetoothMeshService" - private const val MAX_TTL: UByte = 7u + private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS } - // My peer identification - same format as iOS - val myPeerID: String = generateCompatiblePeerID() - // Core components - each handling specific responsibilities private val encryptionService = EncryptionService(context) + + // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) + val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) private val peerManager = PeerManager() private val fragmentManager = FragmentManager() + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope) + private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) + private val authenticatedPeerState by lazy { + AuthenticatedPeerStateCoordinator( + scope = serviceScope, + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + withAuthenticatedSession = encryptionService::withAuthenticatedSession, + store = authenticatedPeerStateStore, + localStateProvider = { + AuthenticatedPeerState( + PeerCapabilities.LOCAL_SUPPORTED, + requireNotNull(encryptionService.getSigningPublicKey()) + ) + }, + applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, + sendState = ::sendAuthenticatedPeerState, + onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + ) + } + private val privateMediaSecurity by lazy { PrivateMediaSecurityController( + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + peerStateStatusProvider = authenticatedPeerState::status, + isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned + ) } + private val privateMediaPreparer by lazy { + PrivateMediaTransferPreparer( + senderID = hexStringToByteArray(myPeerID), + ttl = MAX_TTL, + policyProvider = privateMediaSecurity::sendPolicy, + encrypt = { plaintext, peerID, authenticatedSession -> + try { + PrivateMediaEncryptionResult.Success( + encryptionService.encryptForSession( + plaintext, + peerID, + authenticatedSession + ) + ) + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: Exception) { + PrivateMediaEncryptionResult.Failed + } + }, + finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict, + fragment = fragmentManager::createFragments + ) + } private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() - private val messageHandler = MessageHandler(myPeerID) + private val messageHandler = MessageHandler(myPeerID, context.applicationContext) internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) + private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray) + private val voiceFrameQueue = Channel(capacity = 128) + private lateinit var gossipSyncManager: GossipSyncManager + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext) + ) // Service state management private var isActive = false @@ -56,12 +128,101 @@ class BluetoothMeshService(private val context: Context) { var delegate: BluetoothMeshDelegate? = null // Coroutines - private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Tracks whether this instance has been terminated via stopServices() + private var terminated = false init { + serviceScope.launch { + for (request in voiceFrameQueue) dispatchVoiceFrame(request) + } + Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") + VerificationService.configure(encryptionService) setupDelegates() messageHandler.packetProcessor = packetProcessor //startPeriodicDebugLogging() + + // Flush queued private messages as soon as a BLE Noise session authenticates, + // instead of relying on the foreground-only UI poll. + encryptionService.onSessionEstablished = { peerID -> + Log.d(TAG, "BLE Noise session established with ${peerID.take(8)}") + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.onSessionEstablished(peerID) + } catch (_: Exception) { } + } + + // Initialize sync manager (needs serviceScope) + gossipSyncManager = GossipSyncManager( + myPeerID = myPeerID, + scope = serviceScope, + configProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) + } catch (_: Exception) { 500 } + + override fun gcsMaxBytes(): Int = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400) + } catch (_: Exception) { 400 } + + override fun gcsTargetFpr(): Double = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsFprPercent(1.0) / 100.0 + } catch (_: Exception) { 0.01 } + } + ) + + com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet -> + signPacketBeforeBroadcast(packet) + } + if (isBleTransportEnabled()) { + TransportBridgeService.register("BLE", this) + } + + // Inject dynamic direct connection check into PeerManager + // Matches iOS logic: checks if we have an active hardware mapping for this peer + peerManager.isPeerDirectlyConnected = { peerID -> + connectionManager.addressPeerMap.containsValue(peerID) + } + } + + override fun send(packet: RoutedPacket) { + if (!isBleTransportEnabled()) return + connectionManager.broadcastPacket(packet) + } + + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + if (!isBleTransportEnabled()) return false + return connectionManager.broadcastControlPacketAndAwaitAcceptance(packet) + } + + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + if (!isBleTransportEnabled()) return + connectionManager.sendPacketToPeer(peerID, packet) + } + + private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean { + if (!isBleTransportEnabled()) return false + val queued = connectionManager.broadcastPacket(routed) + if (!queued) return false + TransportBridgeService.broadcast("BLE", routed) + return true + } + + private suspend fun broadcastRoutedPacketAndReport(routed: RoutedPacket): Boolean { + if (!isBleTransportEnabled()) return false + val acceptedByBle = + connectionManager.broadcastControlPacketAndAwaitAcceptance(routed) + val acceptedByBridgedTransport = + TransportBridgeService.broadcastAndReport("BLE", routed) + return acceptedByBle || acceptedByBridgedTransport + } + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } } /** @@ -74,7 +235,7 @@ class BluetoothMeshService(private val context: Context) { delay(10000) // 10 seconds if (isActive) { // Double-check before logging val debugInfo = getDebugStatus() - Log.d(TAG, "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===") + Log.d(TAG, "Periodic debug status:\n$debugInfo") } } catch (e: Exception) { Log.e(TAG, "Error in periodic debug logging: ${e.message}") @@ -83,36 +244,53 @@ class BluetoothMeshService(private val context: Context) { } } - /** - * Send broadcast announcement every 30 seconds - */ - private fun sendPeriodicBroadcastAnnounce() { - serviceScope.launch { - while (isActive) { - try { - delay(30000) // 30 seconds - sendBroadcastAnnounce() - } catch (e: Exception) { - Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") - } - } - } - } - /** * Setup delegate connections between components */ private fun setupDelegates() { + // Provide nickname resolver to BLE broadcaster and debug manager + try { + val resolver: (String) -> String? = { pid -> peerManager.getPeerNickname(pid) } + connectionManager.setNicknameResolver(resolver) + debugManager?.setNicknameResolver(resolver) + } catch (_: Exception) { } // PeerManager delegates to main mesh service delegate peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { + // Update process-wide state first + try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { } + // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } + override fun onPeerRemoved(peerID: String) { + authenticatedPeerState.clear(peerID) + try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } + // Remove from mesh graph topology to prevent routing through stale peers + try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { } + + // Also drop any Noise session state for this peer when they go offline + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}") + } + } } // SecurityManager delegate for key exchange notifications securityManager.delegate = object : SecurityManagerDelegate { - override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + override fun onKeyExchangeCompleted( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + authenticatedSessionToken: ByteArray, + directRelayAddress: String?, + ingressLinkID: String? + ) { + authenticatedPeerState.onSessionAuthenticated( + peerID, + authenticatedRemoteStaticKey, + authenticatedSessionToken + ) // Send announcement and cached messages after key exchange serviceScope.launch { delay(100) @@ -134,9 +312,17 @@ class BluetoothMeshService(private val context: Context) { payload = response, ttl = MAX_TTL ) - connectionManager.broadcastPacket(RoutedPacket(responsePacket)) - Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)") + // Sign the handshake response + val signedPacket = signPacketBeforeBroadcast(responsePacket) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } + + override fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) + } + + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) } // StoreForwardManager delegates @@ -150,7 +336,7 @@ class BluetoothMeshService(private val context: Context) { } override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + broadcastRoutedPacket(RoutedPacket(packet)) } } @@ -185,17 +371,26 @@ class BluetoothMeshService(private val context: Context) { return peerManager.getPeerInfo(peerID) } - override fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean { - return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + override fun updatePeerInfoFromVerifiedAnnouncement(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, capabilities: com.bitchat.android.model.PeerCapabilities?): Boolean { + return peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + nickname, + noisePublicKey, + signingPublicKey, + isVerified, + capabilities + ) } - + // Packet operations override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + broadcastRoutedPacket(routed) } override fun getBroadcastRecipient(): ByteArray { @@ -211,18 +406,32 @@ class BluetoothMeshService(private val context: Context) { return securityManager.encryptForPeer(data, recipientPeerID) } - override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + override fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): com.bitchat.android.noise.NoiseDecryptionResult? { return securityManager.decryptFromPeer(encryptedData, senderPeerID) } override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { return encryptionService.verifyEd25519Signature(signature, data, publicKey) } + + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) // Noise protocol operations override fun hasNoiseSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } + + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}") + } + } override fun initiateNoiseHandshake(peerID: String) { try { @@ -240,8 +449,9 @@ class BluetoothMeshService(private val context: Context) { ttl = MAX_TTL ) - connectionManager.broadcastPacket(RoutedPacket(packet)) - Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)") + // Sign the handshake packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } else { Log.w(TAG, "Failed to generate Noise handshake data for $peerID") } @@ -259,23 +469,13 @@ class BluetoothMeshService(private val context: Context) { null } } - - override fun updatePeerIDBinding(newPeerID: String, nickname: String, - publicKey: ByteArray, previousPeerID: String?) { - Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...") - // Update peer mapping in the PeerManager for peer ID rotation support - peerManager.addOrUpdatePeer(newPeerID, nickname) - - // Store fingerprint for the peer via centralized fingerprint manager - val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) - - // If there was a previous peer ID, remove it to avoid duplicates - previousPeerID?.let { oldPeerID -> - peerManager.removePeer(oldPeerID) - } - - Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...") + override fun onAuthenticatedPeerStateReceived( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) { + authenticatedPeerState.receive(peerID, state, authenticatedSession) } // Message operations @@ -285,7 +485,28 @@ class BluetoothMeshService(private val context: Context) { // Callbacks override fun onMessageReceived(message: BitchatMessage) { + // Private-message admission is authoritative. In particular, do not forward a + // callback or notify after panic mode rejected the message while wiping state. + if ( + !com.bitchat.android.services.IncomingMessageAdmission + .admitToAppState(message) + ) return + + // And forward to UI delegate if attached delegate?.didReceiveMessage(message) + + // If no UI delegate attached (app closed), show DM notification via service manager + if (delegate == null && message.isPrivate && message.sender != "system") { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } + } } override fun onChannelLeave(channel: String, fromPeer: String) { @@ -293,12 +514,34 @@ class BluetoothMeshService(private val context: Context) { } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + // Status events can arrive while MainActivity has detached the UI delegate. + // Persist first so the next UI collector observes the advancement. + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Delivered(peerID, Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveDeliveryAck(messageID, peerID) } override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Read(peerID, Date()) + ) + } catch (_: Exception) { } delegate?.didReceiveReadReceipt(messageID, peerID) } + + override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } } // PacketProcessor delegates @@ -328,23 +571,64 @@ class BluetoothMeshService(private val context: Context) { return runBlocking { securityManager.handleNoiseHandshake(routed) } } - override fun handleNoiseEncrypted(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } - override fun handleAnnounce(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleAnnounce(routed) } + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + val result = messageHandler.handleAnnounceWithResult(routed) + if (result !is AnnounceHandlingResult.Accepted) return false + + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation -> + if (connectionManager.observePeerIfCurrent( + observation.relayAddress, + observation.ingressLinkID, + observation.peerID + ) + ) { + Log.d( + TAG, + "Observed direct BLE route ${observation.relayAddress} to ${observation.peerID}" + ) + try { peerManager.refreshPeerList() } catch (_: Exception) { } + try { + gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000) + } catch (_: Exception) { } + } else { + Log.d(TAG, "Ignoring ANNOUNCE from stale BLE link ${observation.relayAddress}") + } + } + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + return true } override fun handleMessage(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleMessage(routed) } + // Track broadcast messages for sync + try { + val pkt = routed.packet + val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + gossipSyncManager.onPublicPacketSeen(pkt) + } + } catch (_: Exception) { } } + + override fun handleVoiceFrame(routed: RoutedPacket): Boolean = + messageHandler.handlePublicVoiceFrame(routed) override fun handleLeave(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleLeave(routed) } } override fun handleFragment(packet: BitchatPacket): BitchatPacket? { + // Track broadcast fragments for gossip sync + try { + val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && packet.type == MessageType.FRAGMENT.value) { + gossipSyncManager.onPublicPacketSeen(packet) + } + } catch (_: Exception) { } return fragmentManager.handleFragment(packet) } @@ -357,22 +641,100 @@ class BluetoothMeshService(private val context: Context) { } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + broadcastRoutedPacket(routed) + } + + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + val sentOverBle = connectionManager.sendToPeer(peerID, routed) + TransportBridgeService.sendToPeer("BLE", peerID, routed.packet) + return sentOverBle + } + + override fun handleRequestSync(routed: RoutedPacket) { + // Decode request and respond with missing packets + val fromPeer = routed.peerID ?: return + val req = RequestSyncPacket.decode(routed.packet.payload) ?: return + gossipSyncManager.handleRequestSync(fromPeer, req) } } // BluetoothConnectionManager delegates connectionManager.delegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { - packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) - } + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: android.bluetooth.BluetoothDevice?, + ingressLinkID: String + ) { + // Log incoming for debug graphs (do not double-count anywhere else) + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( + packet = packet, + fromPeerID = peerID, + fromNickname = null, + fromDeviceAddress = device?.address, + myPeerID = myPeerID + ) + } catch (_: Exception) { } + packetProcessor.processPacket( + RoutedPacket(packet, peerID, device?.address, ingressLinkID = ingressLinkID) + ) + } override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { // Send initial announcements after services are ready serviceScope.launch { + Log.i(TAG, "Device connected: ${device.address}") delay(200) sendBroadcastAnnounce() } + // Verbose debug: device connected + try { + val addr = device.address + val peer = connectionManager.addressPeerMap[addr] + val nick = peer?.let { peerManager.getPeerNickname(it) } ?: "unknown" + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + .logPeerConnection(peer ?: "unknown", nick, addr, isInbound = !connectionManager.isClientConnection(addr)!!) + } catch (_: Exception) { } + } + + override fun onDeviceDisconnected( + device: android.bluetooth.BluetoothDevice, + linkID: String?, + peerID: String? + ) { + Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)") + + // refresh peer list on disconnect. + try { peerManager.refreshPeerList() } catch (_: Exception) { } + + // ConnectionTracker already removes an observed mapping only when this exact + // link is still current. Do not remove by reusable address here: this may be a late + // disconnect callback from a replaced GATT connection. + + // If the peer that used this link does not come back within a short grace + // period (no other link, no traffic), tear down their Noise session instead of + // waiting for the 3-minute stale-peer sweep. + if (peerID != null) { + val deviceAddress = device.address + val disconnectedAt = System.currentTimeMillis() + serviceScope.launch { + delay(PEER_DISCONNECT_GRACE_MS) + try { + val linkBack = + connectionManager.addressPeerMap.containsKey(deviceAddress) || + connectionManager.addressPeerMap.containsValue(peerID) + val lastSeen = peerManager.getPeerInfo(peerID)?.lastSeen ?: 0L + val seenAfterDisconnect = lastSeen > disconnectedAt + if (!linkBack && !seenAfterDisconnect) { + Log.i(TAG, "Peer $peerID did not return after disconnect; removing peer and Noise session") + peerManager.removePeer(peerID) + } + } catch (e: Exception) { + Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}") + } + } + } } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -383,7 +745,7 @@ class BluetoothMeshService(private val context: Context) { } } } - + /** * Start the mesh service */ @@ -393,19 +755,55 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting mesh service") + connectionManager.disableTransport() + TransportBridgeService.unregister("BLE") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } + return + } + if (terminated) { + // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. + Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting") + return + } Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID") if (connectionManager.startServices()) { isActive = true + TransportBridgeService.register("BLE", this) - // Start periodic announcements for peer discovery and connectivity - sendPeriodicBroadcastAnnounce() - Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") + // Start periodic syncs + com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE") } else { Log.e(TAG, "Failed to start Bluetooth services") } } + + /** + * Apply the debug master transport toggle without destroying this mesh instance. + */ + fun setBleTransportEnabled(enabled: Boolean) { + if (enabled) { + startServices() + } else { + pauseServicesForTransportDisable() + } + } + + private fun pauseServicesForTransportDisable() { + Log.i(TAG, "Disabling BLE mesh transport") + isActive = false + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } + connectionManager.disableTransport() + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } /** * Stop all mesh services @@ -418,14 +816,18 @@ class BluetoothMeshService(private val context: Context) { Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } // Send leave announcement sendLeaveAnnouncement() serviceScope.launch { delay(200) // Give leave message time to send - + // Stop all components + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") connectionManager.stopServices() peerManager.shutdown() fragmentManager.shutdown() @@ -434,9 +836,21 @@ class BluetoothMeshService(private val context: Context) { messageHandler.shutdown() packetProcessor.shutdown() + // Mark this instance as terminated and cancel its scope so it won't be reused + terminated = true serviceScope.cancel() + Log.i(TAG, "BluetoothMeshService terminated and scope cancelled") } } + + /** + * Whether this instance can be safely reused. Returns false after stopServices() or if + * any critical internal scope has been cancelled. + */ + fun isReusable(): Boolean { + val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable() + return reusable + } /** * Send public message @@ -456,9 +870,182 @@ class BluetoothMeshService(private val context: Context) { ttl = MAX_TTL ) - connectionManager.broadcastPacket(RoutedPacket(packet)) + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + // Track our own broadcast message for sync + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } } + + /** + * Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels). + */ + private fun sendAuthenticatedPeerState( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ): Boolean { + val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode() + val ciphertext = securityManager.encryptForPeer( + plaintext, + peerID, + authenticatedSession + ) ?: return false + val packet = BitchatPacket( + version = if (ciphertext.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(packet) + if (signed.signature?.size != 64) return false + broadcastRoutedPacket(RoutedPacket(signed)) + return true + } + + fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { + try { + val payload = file.encode() + if (payload == null) { + Log.e(TAG, "Failed to encode file packet in sendFileBroadcast") + return + } + serviceScope.launch { + val packet = BitchatPacket( + version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(packet) + // Use a stable transferId based on the file TLV payload for progress tracking + val transferId = sha256Hex(payload) + broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e(TAG, "sendFileBroadcast failed (size=${file.fileSize}): ${e.message}", e) + } + } + + /** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */ + fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { + val payload = file.encode() ?: return + when (val prepared = prepareFilePrivate( + recipientPeerID, + file, + sha256Hex(payload), + allowLegacyFallback = false + )) { + is PrivateMediaPreparation.Ready -> prepared.transfer.commit() + is PrivateMediaPreparation.RequiresLegacyConsent -> + Log.w(TAG, "Private media requires explicit one-shot legacy consent") + PrivateMediaPreparation.NeedsHandshake -> { + Log.d(TAG, "Private media needs a Noise handshake; initiating without sending") + initiateNoiseHandshake(recipientPeerID) + } + PrivateMediaPreparation.AwaitingPeerState -> Unit + is PrivateMediaPreparation.Rejected -> + Log.w(TAG, "Private media blocked: ${prepared.reason}") + } + } + + fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + if (payload.isEmpty()) return + voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf())) + } + + private fun dispatchVoiceFrame(request: VoiceFrameRequest) { + try { + val recipientPeerID = request.recipientPeerID + val packet = if (recipientPeerID == null) { + BitchatPacket( + version = 1u, + type = MessageType.VOICE_FRAME.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = request.payload, + ttl = MAX_TTL + ) + } else { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) return + val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode() + val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID) + BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = MAX_TTL + ) + } + broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + } catch (e: Exception) { + Log.w(TAG, "Live voice frame send failed: ${e.message}") + } + } + + fun prepareFilePrivate( + recipientPeerID: String, + file: com.bitchat.android.model.BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation { + return when (val outcome = privateMediaPreparer.prepare( + recipientPeerID = recipientPeerID, + recipientID = hexStringToByteArray(recipientPeerID), + file = file, + allowLegacyFallback = allowLegacyFallback + )) { + is PrivateMediaBuildOutcome.RequiresLegacyConsent -> + PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning) + PrivateMediaBuildOutcome.NeedsHandshake -> + PrivateMediaPreparation.NeedsHandshake + PrivateMediaBuildOutcome.AwaitingPeerState -> + PrivateMediaPreparation.AwaitingPeerState + is PrivateMediaBuildOutcome.Rejected -> + PrivateMediaPreparation.Rejected(outcome.reason) + is PrivateMediaBuildOutcome.Ready -> { + val built = outcome.built + val routed = RoutedPacket( + packet = built.packet, + transferId = transferId, + preparedPackets = built.fragments + ) + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer(transferId, built.wireMode) { + if (!isActive || terminated || !isBleTransportEnabled()) { + false + } else { + broadcastRoutedPacket(routed) + } + } + ) + } + } + } + + fun cancelFileTransfer(transferId: String): Boolean { + return connectionManager.cancelTransfer(transferId) + } + + // Local helper to hash payloads to a stable hex ID for progress mapping + private fun sha256Hex(bytes: ByteArray): String = try { + val md = java.security.MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { bytes.size.toString(16) } /** * Send private message - SIMPLIFIED iOS-compatible version @@ -466,25 +1053,12 @@ class BluetoothMeshService(private val context: Context) { */ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { if (content.isEmpty() || recipientPeerID.isEmpty()) return - if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return + // Nicknames are presentation metadata. Routing and encryption are bound to the peer ID, + // so a temporarily unresolved nickname must never suppress a private message. serviceScope.launch { val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() - - Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...") - - // Check if this is a Nostr contact (geohash DM) - if (recipientPeerID.startsWith("nostr_")) { - // Get NostrGeohashService instance and send via Nostr - try { - val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application) - nostrGeohashService.sendNostrGeohashDM(content, recipientPeerID, finalMessageID, myPeerID) - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}") - } - return@launch - } - + // Check if we have an established Noise session if (encryptionService.hasEstablishedSession(recipientPeerID)) { try { @@ -521,23 +1095,20 @@ class BluetoothMeshService(private val context: Context) { ttl = MAX_TTL ) - connectionManager.broadcastPacket(RoutedPacket(packet)) - Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)") - - // FIXED: Don't send didReceiveMessage for our own sent messages - // This was causing self-notifications - iOS doesn't do this - // The UI handles showing sent messages through its own message sending logic - + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + + // The UI handles sent messages through its own sending path. + } catch (e: Exception) { Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}") } } else { // Fire and forget - initiate handshake but don't queue exactly like iOS - Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake") messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) - // FIXED: Don't send didReceiveMessage for our own sent messages - // The UI will handle showing the message in the chat interface + // The UI handles sent messages through its own sending path. } } } @@ -548,20 +1119,17 @@ class BluetoothMeshService(private val context: Context) { */ fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { serviceScope.launch { - Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") - - // Check if this is a Nostr contact (geohash DM) - if (recipientPeerID.startsWith("nostr_")) { - // Get NostrGeohashService instance and send read receipt via Nostr - try { - val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application) - nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID) - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}") - } + // Route geohash read receipts via MessageRouter instead of here + val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() + val isGeoAlias = try { + val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot() + map.containsKey(recipientPeerID) + } catch (_: Exception) { false } + if (isGeoAlias && geo != null) { + geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) return@launch } - + try { // Create read receipt payload using NoisePayloadType exactly like iOS val readReceiptPayload = com.bitchat.android.model.NoisePayload( @@ -581,25 +1149,92 @@ class BluetoothMeshService(private val context: Context) { timestamp = System.currentTimeMillis().toULong(), payload = encrypted, signature = null, - ttl = 7u // Same TTL as iOS messageTTL + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS messageTTL ) - connectionManager.broadcastPacket(RoutedPacket(packet)) - Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") - + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + val retryKey = "$recipientPeerID:$messageID" + readReceiptRetrySender.enqueue( + key = retryKey, + sendAttempt = { attempt -> + // Keep the addressed packet on the normal broadcaster actor so receipt + // attempts are ordered with other BLE traffic and can use mesh routing. + val accepted = + broadcastRoutedPacketAndReport(RoutedPacket(signedPacket)) + Log.d( + TAG, + "Read receipt attempt $attempt accepted=$accepted " + + "peer=${recipientPeerID.take(8)} message=${messageID.take(8)}" + ) + accepted + }, + onComplete = { accepted -> + if (accepted) { + try { + com.bitchat.android.services.SeenMessageStore + .getInstance(context.applicationContext) + .markReadReceiptSent(messageID) + } catch (_: Exception) { } + } + } + ) + } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") } } } + + // MARK: QR Verification over Noise + + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA) + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_CHALLENGE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID, "verify challenge") + } + + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_RESPONSE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID, "verify response") + } + + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) { + serviceScope.launch { + try { + val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + ) + + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + } catch (e: Exception) { + Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}") + } + } + } /** * Send broadcast announce with TLV-encoded identity announcement - exactly like iOS */ fun sendBroadcastAnnounce() { - Log.d(TAG, "Sending broadcast announce") serviceScope.launch { - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -616,12 +1251,26 @@ class BluetoothMeshService(private val context: Context) { } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode announcement as TLV") return@launch } + + // Append gossip TLV containing up to 10 direct neighbors (compact IDs) + try { + val directPeers = getDirectPeerIDsForGossip() + if (directPeers.isNotEmpty()) { + val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers) + tlvPayload = tlvPayload + gossip + } + // Always update our own node in the mesh graph with the neighbor list we used + try { + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } + } catch (_: Exception) { } val announcePacket = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -635,8 +1284,9 @@ class BluetoothMeshService(private val context: Context) { announcePacket.copy(signature = signature) } ?: announcePacket - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) - Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)") + broadcastRoutedPacket(RoutedPacket(signedPacket)) + // Track announce for sync + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } } @@ -646,7 +1296,7 @@ class BluetoothMeshService(private val context: Context) { fun sendAnnouncementToPeer(peerID: String) { if (peerManager.hasAnnouncedToPeer(peerID)) return - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -663,12 +1313,26 @@ class BluetoothMeshService(private val context: Context) { } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode peer announcement as TLV") return } + + // Append gossip TLV containing up to 10 direct neighbors (compact IDs) + try { + val directPeers = getDirectPeerIDsForGossip() + if (directPeers.isNotEmpty()) { + val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers) + tlvPayload = tlvPayload + gossip + } + // Always update our own node in the mesh graph with the neighbor list we used + try { + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } + } catch (_: Exception) { } val packet = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -682,24 +1346,47 @@ class BluetoothMeshService(private val context: Context) { packet.copy(signature = signature) } ?: packet - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) peerManager.markPeerAsAnnouncedTo(peerID) - Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") + + // Track announce for sync + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + + /** + * Collect up to 10 direct neighbors for gossip TLV. + */ + private fun getDirectPeerIDsForGossip(): List { + return try { + // Prefer verified peers that are currently marked as direct + val verified = peerManager.getVerifiedPeers() + val direct = verified.filter { it.value.isDirectConnection }.keys.toSet() + // Publish this transport's direct peers and gossip the cross-transport union so a + // node connected via multiple transports advertises a complete neighbor list. + try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers("BLE", direct) } catch (_: Exception) { } + val union = try { + com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { direct } + } catch (_: Exception) { direct } + union.distinct().take(10) + } catch (_: Exception) { + emptyList() + } } /** * Send leave announcement */ private fun sendLeaveAnnouncement() { - val nickname = delegate?.getNickname() ?: myPeerID val packet = BitchatPacket( type = MessageType.LEAVE.value, ttl = MAX_TTL, senderID = myPeerID, - payload = nickname.toByteArray() + payload = byteArrayOf() ) - connectionManager.broadcastPacket(RoutedPacket(packet)) + // Sign the packet before broadcasting + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } /** @@ -741,6 +1428,13 @@ class BluetoothMeshService(private val context: Context) { return peerManager.getFingerprintForPeer(peerID) } + /** + * Get current active peer count (for status/notifications) + */ + fun getActivePeerCount(): Int { + return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } + } + /** * Get peer info for verification purposes */ @@ -767,6 +1461,10 @@ class BluetoothMeshService(private val context: Context) { fun getIdentityFingerprint(): String { return encryptionService.getIdentityFingerprint() } + + fun getStaticNoisePublicKey(): ByteArray? { + return encryptionService.getStaticPublicKey() + } /** * Check if encryption icon should be shown for a peer @@ -831,15 +1529,6 @@ class BluetoothMeshService(private val context: Context) { } } - /** - * Generate peer ID compatible with iOS - exactly 8 bytes (16 hex characters) - */ - private fun generateCompatiblePeerID(): String { - val randomBytes = ByteArray(8) // 8 bytes = 16 hex characters (like iOS) - Random.nextBytes(randomBytes) - return randomBytes.joinToString("") { "%02x".format(it) } - } - /** * Convert hex string peer ID to binary data (8 bytes) - exactly same as iOS */ @@ -861,23 +1550,89 @@ class BluetoothMeshService(private val context: Context) { return result } + /** + * Sign packet before broadcasting using our signing private key + */ + private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket { + return try { + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { + val destination = recipient.joinToString("") { byte -> "%02x".format(byte) } + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath( + myPeerID, + destination + ) + if (path != null && path.size >= 3) { + val intermediates = path.subList(1, path.size - 1) + packet.copy( + route = intermediates.map(::hexStringToByteArray), + version = 2u + ) + } else { + packet.copy(route = null) + } + } else { + packet + } + } catch (_: Exception) { + packet + } + } + + /** Private media must never fall back to an unsigned packet. */ + private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? { + val routed = applyRouteIfAvailable(packet) + val signingBytes = routed.toBinaryDataForSigning() ?: return null + val signature = encryptionService.signData(signingBytes) ?: return null + return routed.copy(signature = signature) + } + + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + return try { + // Optionally compute and attach a source route for addressed packets + val withRoute = applyRouteIfAvailable(packet) + + // Get the canonical packet data for signing (without signature) + val packetDataForSigning = withRoute.toBinaryDataForSigning() + if (packetDataForSigning == null) { + Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned") + return withRoute + } + + // Sign the packet data using our signing key + val signature = encryptionService.signData(packetDataForSigning) + if (signature != null) { + withRoute.copy(signature = signature) + } else { + Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned") + withRoute + } + } catch (e: Exception) { + Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned") + packet + } + } + // MARK: - Panic Mode Support /** * Clear all internal mesh service data (for panic mode) */ fun clearAllInternalData() { - Log.w(TAG, "🚨 Clearing all mesh service internal data") + Log.w(TAG, "Clearing all mesh service internal data") try { + // Stop services to cease broadcasting old ID immediately + stopServices() + // Clear all managers fragmentManager.clearAllFragments() storeForwardManager.clearAllCache() securityManager.clearAllData() peerManager.clearAllPeers() peerManager.clearAllFingerprints() - Log.d(TAG, "✅ Cleared all mesh service internal data") + try { gossipSyncManager.clear() } catch (_: Exception) { } } catch (e: Exception) { - Log.e(TAG, "❌ Error clearing mesh service internal data: ${e.message}") + Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") } } @@ -885,28 +1640,21 @@ class BluetoothMeshService(private val context: Context) { * Clear all encryption and cryptographic data (for panic mode) */ fun clearAllEncryptionData() { - Log.w(TAG, "🚨 Clearing all encryption data") + Log.w(TAG, "Clearing all encryption data") try { // Clear encryption service persistent identity (includes Ed25519 signing keys) encryptionService.clearPersistentIdentity() - Log.d(TAG, "✅ Cleared all encryption data") } catch (e: Exception) { - Log.e(TAG, "❌ Error clearing encryption data: ${e.message}") + Log.e(TAG, "Error clearing encryption data: ${e.message}") } } } /** - * Delegate interface for mesh service callbacks (maintains exact same interface) + * Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so + * transport-agnostic facades can receive the same callback stream. */ -interface BluetoothMeshDelegate { - fun didReceiveMessage(message: BitchatMessage) - fun didUpdatePeerList(peers: List) - fun didReceiveChannelLeave(channel: String, fromPeer: String) - fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) - fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) - fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? - fun getNickname(): String? - fun isFavorite(peerID: String): Boolean - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager +interface BluetoothMeshDelegate : MeshDelegate { + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 202c64ee..9ff9856d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -1,12 +1,20 @@ + package com.bitchat.android.mesh +import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothStatusCodes +import android.os.Build import android.util.Log import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -14,8 +22,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.actor +import java.util.ArrayDeque /** * Handles packet broadcasting to connected devices using actor pattern for serialization @@ -37,36 +45,131 @@ import kotlinx.coroutines.channels.actor class BluetoothPacketBroadcaster( private val connectionScope: CoroutineScope, private val connectionTracker: BluetoothConnectionTracker, - private val fragmentManager: FragmentManager? + private val fragmentManager: FragmentManager?, + private val myPeerID: String ) { companion object { private const val TAG = "BluetoothPacketBroadcaster" - private const val CLEANUP_DELAY = 500L + private const val MAX_PENDING_SENDS_PER_LINK = 256 + private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576 + private const val SEND_RETRY_DELAY_MS = 15L + private const val MAX_CALLBACK_RETRIES = 3 + } + + // Optional nickname resolver injected by higher layer (peerID -> nickname?) + private var nicknameResolver: ((String) -> String?)? = null + + fun setNicknameResolver(resolver: (String) -> String?) { + nicknameResolver = resolver + } + + /** + * Debug logging helper - can be easily removed/disabled for production + */ + private fun logPacketRelay( + typeName: String, + senderPeerID: String, + senderNick: String?, + incomingPeer: String?, + incomingAddr: String?, + toPeer: String?, + toDeviceAddress: String, + ttl: UByte, + packetVersion: UByte = 1u, + routeInfo: String? = null + ) { + try { + val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) } + val toNick = toPeer?.let { nicknameResolver?.invoke(it) } + val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Always log outgoing for the actual transmission target + manager.logOutgoing( + packetType = typeName, + toPeerID = toPeer, + toNickname = toNick, + toDeviceAddress = toDeviceAddress, + previousHopPeerID = incomingPeer, + packetVersion = packetVersion, + routeInfo = routeInfo + ) + // Keep the verbose relay message for human readability + manager.logPacketRelayDetailed( + packetType = typeName, + senderPeerID = senderPeerID, + senderNickname = senderNick, + fromPeerID = incomingPeer, + fromNickname = fromNick, + fromDeviceAddress = incomingAddr, + toPeerID = toPeer, + toNickname = toNick, + toDeviceAddress = toDeviceAddress, + ttl = ttl, + isRelay = true, + packetVersion = packetVersion, + routeInfo = routeInfo + ) + } catch (_: Exception) { + // Silently ignore debug logging failures + } } // Data class to hold broadcast request information private data class BroadcastRequest( val routed: RoutedPacket, val gattServer: BluetoothGattServer?, - val characteristic: BluetoothGattCharacteristic? + val characteristic: BluetoothGattCharacteristic?, + val accepted: CompletableDeferred? = null ) // Actor scope for the broadcaster private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG) + + private enum class SendDirection { CLIENT_WRITE, SERVER_NOTIFICATION } + + private data class SendKey( + val deviceAddress: String, + val linkID: String, + val direction: SendDirection + ) + + private data class PendingSend( + val data: ByteArray, + val device: BluetoothDevice, + val gatt: BluetoothGatt? = null, + val gattServer: BluetoothGattServer? = null, + val characteristic: BluetoothGattCharacteristic, + var callbackFailures: Int = 0 + ) + + private class LinkSendState { + val pending = ArrayDeque() + var pendingBytes = 0 + var inFlight = false + var retryScheduled = false + } + + private val sendLock = Any() + private val sendStates = mutableMapOf() // SERIALIZATION: Actor to serialize all broadcast operations @OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class) private val broadcasterActor = broadcasterScope.actor( - capacity = Channel.UNLIMITED + capacity = 256 ) { - Log.d(TAG, "🎭 Created packet broadcaster actor") - try { - for (request in channel) { - broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic) + for (request in channel) { + val accepted = try { + broadcastSinglePacketInternal( + request.routed, + request.gattServer, + request.characteristic + ) + } catch (e: Exception) { + Log.w(TAG, "Broadcast request failed: ${e.message}") + false } - } finally { - Log.d(TAG, "🎭 Packet broadcaster actor terminated") + request.accepted?.complete(accepted) } } @@ -74,26 +177,94 @@ class BluetoothPacketBroadcaster( routed: RoutedPacket, gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? - ) { + ): Boolean { + return fragmentingSender.send(routed, "BLE broadcast") { packet -> + broadcastSinglePacket(packet, gattServer, characteristic) + true + } + } + + fun cancelTransfer(transferId: String): Boolean { + return fragmentingSender.cancelTransfer(transferId) + } + + /** + * Send a packet to a specific peer only, without broadcasting. + * Returns true if a direct path was found and used. + */ + fun sendPacketToPeer( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) + } + } + + fun sendPacketToLink( + routed: RoutedPacket, + deviceAddress: String, + linkID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean = fragmentingSender.send(routed, "BLE link $deviceAddress") { packet -> + val data = packet.packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.packet.type) + ) ?: return@send false + val currentLink = connectionTracker.getDeviceConnection(deviceAddress) + ?.takeIf { it.linkID == linkID } + ?: return@send false + if (currentLink.isClient) { + return@send writeToDeviceConn(currentLink, data) + } + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { it.address == deviceAddress } + ?: return@send false + notifyDevice(serverTarget, data, gattServer, characteristic) + } + + private fun sendSinglePacketToPeer( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { val packet = routed.packet - // Check if we need to fragment - if (fragmentManager != null) { - val fragments = fragmentManager.createFragments(packet) - if (fragments.size > 1) { - Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments") - connectionScope.launch { - fragments.forEach { fragment -> - broadcastSinglePacket(RoutedPacket(fragment), gattServer, characteristic) - // 20ms delay between fragments (matching iOS/Rust) - delay(200) - } - } - return + // iOS-compatible: Use selective padding policy for BLE + val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + val data = packet.toBinaryData(padding = padForBLE) ?: return false + val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() + val senderPeerID = routed.peerID ?: packet.senderID.toHexString() + val incomingAddr = routed.relayAddress + val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } + val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } + val route = packet.route + val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null + + // Prefer server-side subscriptions + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } + if (serverTarget != null) { + if (notifyDevice(serverTarget, data, gattServer, characteristic)) { + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo) + return true } } - - // Send single packet if no fragmentation needed - broadcastSinglePacket(routed, gattServer, characteristic) + + // Then client connections + val clientTarget = connectionTracker.getConnectedDevices().values + .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } + if (clientTarget != null) { + if (writeToDeviceConn(clientTarget, data)) { + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo) + return true + } + } + + return false } @@ -116,6 +287,54 @@ class BluetoothPacketBroadcaster( } } } + + /** + * Serializes a small control packet with normal BLE traffic and waits for the platform write + * API to accept at least one notification/write. + */ + suspend fun broadcastControlPacketAndAwaitAcceptance( + routed: RoutedPacket, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + val accepted = CompletableDeferred() + return try { + broadcasterActor.send( + BroadcastRequest(routed, gattServer, characteristic, accepted) + ) + accepted.await() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Failed to queue control packet: ${e.message}") + broadcastSinglePacketInternal(routed, gattServer, characteristic) + } + } + + /** + * Targeted send to a specific peer (by peerID) if directly connected. + * Returns true if sent to at least one matching connection. + */ + fun sendToPeer( + targetPeerID: String, + routed: RoutedPacket, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) + } + } + + private fun hasPeerConnection(targetPeerID: String): Boolean { + val hasServerTarget = connectionTracker.getSubscribedDevices() + .any { connectionTracker.addressPeerMap[it.address] == targetPeerID } + if (hasServerTarget) return true + + return connectionTracker.getConnectedDevices().values + .any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } + } /** * Internal broadcast implementation - runs in serialized actor context @@ -124,14 +343,59 @@ class BluetoothPacketBroadcaster( routed: RoutedPacket, gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? - ) { + ): Boolean { val packet = routed.packet - val data = packet.toBinaryData() ?: return + // iOS-compatible: Use selective padding policy for BLE + val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + val data = packet.toBinaryData(padding = padForBLE) ?: return false + val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() + val senderPeerID = routed.peerID ?: packet.senderID.toHexString() + val incomingAddr = routed.relayAddress + val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } + val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } + val route = packet.route + val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null + + // Source Routing for Originating Packets + // If we are the sender and a source route is defined, we must send ONLY to the first hop. + if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { + val firstHop = packet.route!![0].toHexString() + + var sent = false + + // Try to find first hop in server connections (subscribedDevices) + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { connectionTracker.addressPeerMap[it.address] == firstHop } + + if (serverTarget != null) { + if (notifyDevice(serverTarget, data, gattServer, characteristic)) { + val toPeer = connectionTracker.addressPeerMap[serverTarget.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, serverTarget.address, packet.ttl, packet.version, routeInfo) + sent = true + } + } + + // Try to find first hop in client connections if not sent yet + if (!sent) { + val clientTarget = connectionTracker.getConnectedDevices().values + .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == firstHop } + + if (clientTarget != null) { + if (writeToDeviceConn(clientTarget, data)) { + val toPeer = connectionTracker.addressPeerMap[clientTarget.device.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, clientTarget.device.address, packet.ttl, packet.version, routeInfo) + sent = true + } + } + } + + if (sent) return true + + Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.") + } if (packet.recipientID != SpecialRecipients.BROADCAST) { - val recipientID = packet.recipientID?.let { - String(it).replace("\u0000", "").trim() - } ?: "" + val recipientID = packet.recipientID?.toHexString() ?: "" // Try to find the recipient in server connections (subscribedDevices) val targetDevice = connectionTracker.getSubscribedDevices() @@ -139,9 +403,11 @@ class BluetoothPacketBroadcaster( // If found, send directly if (targetDevice != null) { - Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}") - if (notifyDevice(targetDevice, data, gattServer, characteristic)) - return // Sent, no need to continue + if (notifyDevice(targetDevice, data, gattServer, characteristic)) { + val toPeer = connectionTracker.addressPeerMap[targetDevice.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo) + return true + } } // Try to find the recipient in client connections (connectedDevices) @@ -150,47 +416,55 @@ class BluetoothPacketBroadcaster( // If found, send directly if (targetDeviceConn != null) { - Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}") - if (writeToDeviceConn(targetDeviceConn, data)) - return // Sent, no need to continue + if (writeToDeviceConn(targetDeviceConn, data)) { + val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo) + return true + } } } // Else, continue with broadcasting to all devices val subscribedDevices = connectionTracker.getSubscribedDevices() val connectedDevices = connectionTracker.getConnectedDevices() - - Log.i(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections") - val senderID = String(packet.senderID).replace("\u0000", "") - + val senderID = packet.senderID.toHexString() + var accepted = false + // Send to server connections (devices connected to our GATT server) subscribedDevices.forEach { device -> if (device.address == routed.relayAddress) { - Log.d(TAG, "Skipping broadcast to client back to relayer: ${device.address}") return@forEach } if (connectionTracker.addressPeerMap[device.address] == senderID) { - Log.d(TAG, "Skipping broadcast to client back to sender: ${device.address}") return@forEach } - notifyDevice(device, data, gattServer, characteristic) + val sent = notifyDevice(device, data, gattServer, characteristic) + if (sent) { + accepted = true + val toPeer = connectionTracker.addressPeerMap[device.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo) + } } // Send to client connections (GATT servers we are connected to) connectedDevices.values.forEach { deviceConn -> if (deviceConn.isClient && deviceConn.gatt != null && deviceConn.characteristic != null) { if (deviceConn.device.address == routed.relayAddress) { - Log.d(TAG, "Skipping broadcast to server back to relayer: ${deviceConn.device.address}") return@forEach } if (connectionTracker.addressPeerMap[deviceConn.device.address] == senderID) { - Log.d(TAG, "Skipping roadcast to server back to sender: ${deviceConn.device.address}") return@forEach } - writeToDeviceConn(deviceConn, data) + val sent = writeToDeviceConn(deviceConn, data) + if (sent) { + accepted = true + val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address] + logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo) + } } } + return accepted } /** @@ -202,21 +476,16 @@ class BluetoothPacketBroadcaster( gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? ): Boolean { - return try { - characteristic?.let { char -> - char.value = data - val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false - result - } ?: false - } catch (e: Exception) { - Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}") - connectionScope.launch { - delay(CLEANUP_DELAY) - connectionTracker.removeSubscribedDevice(device) - connectionTracker.addressPeerMap.remove(device.address) - } - false - } + val server = gattServer ?: return false + val char = characteristic ?: return false + val linkID = connectionTracker.getDeviceConnection(device.address) + ?.takeIf { !it.isClient } + ?.linkID + ?: return false + return enqueueSend( + SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION), + PendingSend(data.copyOf(), device, gattServer = server, characteristic = char) + ) } /** @@ -226,20 +495,160 @@ class BluetoothPacketBroadcaster( deviceConn: BluetoothConnectionTracker.DeviceConnection, data: ByteArray ): Boolean { - return try { - deviceConn.characteristic?.let { char -> - char.value = data - val result = deviceConn.gatt?.writeCharacteristic(char) ?: false - result - } ?: false - } catch (e: Exception) { - Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}") - connectionScope.launch { - delay(CLEANUP_DELAY) - connectionTracker.cleanupDeviceConnection(deviceConn.device.address) + val gatt = deviceConn.gatt ?: return false + val char = deviceConn.characteristic ?: return false + return enqueueSend( + SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE), + PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char) + ) + } + + /** + * Android permits only one outstanding GATT operation per link. Queueing here mirrors the + * readiness-driven iOS transport and prevents later voice frames from overwriting an operation + * that the controller has not completed yet. + */ + private fun enqueueSend(key: SendKey, request: PendingSend): Boolean { + val startNow = synchronized(sendLock) { + val state = sendStates.getOrPut(key, ::LinkSendState) + if ( + state.pending.size >= MAX_PENDING_SENDS_PER_LINK || + state.pendingBytes + request.data.size > MAX_PENDING_BYTES_PER_LINK + ) { + Log.w(TAG, "BLE send queue full for ${key.direction}; rejecting ${request.data.size} bytes") + return false } + state.pending.addLast(request) + state.pendingBytes += request.data.size + if (!state.inFlight && !state.retryScheduled) { + state.inFlight = true + true + } else { + false + } + } + if (startNow) startHead(key) + return true + } + + @Suppress("DEPRECATION") + @SuppressLint("MissingPermission", "ObsoleteSdkInt") + private fun startHead(key: SendKey) { + val request = synchronized(sendLock) { sendStates[key]?.pending?.peekFirst() } ?: return + val accepted = try { + when (key.direction) { + SendDirection.CLIENT_WRITE -> { + val gatt = request.gatt + if (gatt == null) { + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + request.characteristic, + request.data, + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ) == BluetoothStatusCodes.SUCCESS + } else { + request.characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + request.characteristic.value = request.data + gatt.writeCharacteristic(request.characteristic) + } + } + SendDirection.SERVER_NOTIFICATION -> { + val server = request.gattServer + if (server == null) { + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + server.notifyCharacteristicChanged( + request.device, + request.characteristic, + false, + request.data + ) == BluetoothStatusCodes.SUCCESS + } else { + request.characteristic.value = request.data + server.notifyCharacteristicChanged(request.device, request.characteristic, false) + } + } + } + } catch (error: Exception) { + Log.w(TAG, "BLE ${key.direction} failed to start: ${error.message}") false } + if (!accepted) rejectStart(key) + } + + private fun rejectStart(key: SendKey): Boolean { + val schedule = synchronized(sendLock) { + val state = sendStates[key] ?: return false + state.inFlight = false + if (state.retryScheduled || state.pending.isEmpty()) false else { + state.retryScheduled = true + true + } + } + if (schedule) { + connectionScope.launch { + delay(SEND_RETRY_DELAY_MS) + val retry = synchronized(sendLock) { + val state = sendStates[key] ?: return@synchronized false + state.retryScheduled = false + if (!state.inFlight && state.pending.isNotEmpty()) { + state.inFlight = true + true + } else false + } + if (retry) startHead(key) + } + } + return false + } + + fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) { + completeSend(SendKey(deviceAddress, linkID, SendDirection.CLIENT_WRITE), status) + } + + fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) { + if (linkID == null) return + completeSend(SendKey(deviceAddress, linkID, SendDirection.SERVER_NOTIFICATION), status) + } + + private fun completeSend(key: SendKey, status: Int) { + var retry = false + val startNext = synchronized(sendLock) { + val state = sendStates[key] ?: return + val head = state.pending.peekFirst() ?: run { + sendStates.remove(key) + return + } + state.inFlight = false + if (status != BluetoothGatt.GATT_SUCCESS && head.callbackFailures < MAX_CALLBACK_RETRIES) { + head.callbackFailures++ + retry = true + false + } else { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "BLE ${key.direction} failed with status $status after retries") + } + state.pending.removeFirst() + state.pendingBytes -= head.data.size + if (state.pending.isEmpty()) { + sendStates.remove(key) + false + } else { + state.inFlight = true + true + } + } + } + if (retry) rejectStart(key) else if (startNext) startHead(key) + } + + fun onLinkDisconnected(deviceAddress: String, linkID: String?) { + synchronized(sendLock) { + sendStates.keys.removeAll { key -> + key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID) + } + } } /** @@ -258,14 +667,11 @@ class BluetoothPacketBroadcaster( * Shutdown the broadcaster actor gracefully */ fun shutdown() { - Log.d(TAG, "Shutting down BluetoothPacketBroadcaster actor") - + synchronized(sendLock) { sendStates.clear() } // Close the actor gracefully broadcasterActor.close() - + // Cancel the broadcaster scope broadcasterScope.cancel() - - Log.d(TAG, "BluetoothPacketBroadcaster shutdown complete") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt index 917de66e..45d48ac6 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt @@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION )) - + return permissions.all { ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt new file mode 100644 index 00000000..e0be2835 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt @@ -0,0 +1,26 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket + +/** + * Describes transport reachability learned from an already-validated ANNOUNCE. + * + * This is deliberately only a routing observation. Noise authenticates the peer independently and + * must not be restarted merely to associate the current transport link with that peer. + */ +internal object DirectLinkAnnouncementPolicy { + data class Observation( + val peerID: String, + val relayAddress: String, + val ingressLinkID: String + ) + + fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? { + if (routed.packet.ttl != maxTtl) return null + return Observation( + peerID = routed.peerID ?: return null, + relayAddress = routed.relayAddress ?: return null, + ingressLinkID = routed.ingressLinkID ?: return null + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt index 6529b2c3..3a10f998 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt @@ -22,17 +22,21 @@ class FragmentManager { companion object { private const val TAG = "FragmentManager" // iOS values: 512 MTU threshold, 469 max fragment size (512 MTU - headers) - private const val FRAGMENT_SIZE_THRESHOLD = 512 // Matches iOS: if data.count > 512 - private const val MAX_FRAGMENT_SIZE = 469 // Matches iOS: maxFragmentSize = 469 - private const val FRAGMENT_TIMEOUT = 30000L // Matches iOS: 30 seconds cleanup - private const val CLEANUP_INTERVAL = 10000L // 10 seconds cleanup check + private const val FRAGMENT_SIZE_THRESHOLD = com.bitchat.android.util.AppConstants.Fragmentation.FRAGMENT_SIZE_THRESHOLD // Matches iOS: if data.count > 512 + private const val MAX_FRAGMENT_SIZE = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE // Matches iOS: maxFragmentSize = 469 + private const val FRAGMENT_TIMEOUT = com.bitchat.android.util.AppConstants.Fragmentation.FRAGMENT_TIMEOUT_MS // Matches iOS: 30 seconds cleanup + private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Fragmentation.CLEANUP_INTERVAL_MS // 10 seconds cleanup check } // Fragment storage - iOS equivalent: incomingFragments: [String: [Int: Data]] private val incomingFragments = ConcurrentHashMap>() // iOS equivalent: fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] private val fragmentMetadata = ConcurrentHashMap>() // originalType, totalFragments, timestamp - + private val fragmentCumulativeSize = ConcurrentHashMap() + + private val fragmentStateLock = Any() + private var globalBufferedBytes: Long = 0L + // Delegate for callbacks var delegate: FragmentManagerDelegate? = null @@ -47,58 +51,116 @@ class FragmentManager { * Create fragments from a large packet - 100% iOS Compatible * Matches iOS sendFragmentedPacket() implementation exactly */ - fun createFragments(packet: BitchatPacket): List { - val encoded = packet.toBinaryData() ?: return emptyList() - - // Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix - val fullData = MessagePadding.unpad(encoded) - - // iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue - if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) { - return listOf(packet) // No fragmentation needed + /** Generic/public packets retain the full UInt16 fragment-count range. */ + fun createFragments(packet: BitchatPacket): List = + createFragments(packet, 0xFFFF) + + /** + * Create a fragment plan with a caller-selected bound. Private media uses + * 256 for cross-platform admission; generic/public traffic retains the + * UInt16 wire limit. + */ + fun createFragments(packet: BitchatPacket, maxFragments: Int): List { + try { + if (maxFragments !in 1..0xFFFF) { + Log.w(TAG, "Rejecting invalid outbound fragment limit: $maxFragments") + return emptyList() + } + val encoded = packet.toBinaryData() + if (encoded == null) { + Log.e(TAG, "Failed to encode packet to binary data") + return emptyList() + } + + // Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix + val fullData = try { + MessagePadding.unpad(encoded) + } catch (e: Exception) { + Log.e(TAG, "Failed to unpad data: ${e.message}", e) + return emptyList() + } + + // iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue + if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) { + return listOf(packet) // No fragmentation needed + } + + val fragments = mutableListOf() + + // iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + val fragmentID = FragmentPayload.generateFragmentID() + + // iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize) + // Calculate dynamic fragment size to fit in MTU (512) + // Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer + val hasRoute = packet.route != null + val version = if (hasRoute) 2 else 1 + val headerSize = if (version == 2) 15 else 13 + val senderSize = 8 + val recipientSize = if (packet.recipientID != null) 8 else 0 + // Route: 1 byte count + 8 bytes per hop + val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0 + val fragmentHeaderSize = 13 // FragmentPayload header + val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead + + // 512 - Overhead + val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer + val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE) + + if (maxDataSize <= 0) { + Log.e(TAG, "Calculated maxDataSize is non-positive ($maxDataSize). Route too large?") + return emptyList() + } + + val requiredFragments = ( + (fullData.size.toLong() + maxDataSize.toLong() - 1L) / maxDataSize.toLong() + ).toInt() + if (requiredFragments > maxFragments) { + Log.w(TAG, "Rejecting outbound packet requiring $requiredFragments fragments (caller cap: $maxFragments)") + return emptyList() + } + + // Do not allocate chunk copies until the plan passes the hard bound. + val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset -> + val endOffset = minOf(offset + maxDataSize, fullData.size) + fullData.sliceArray(offset..() - - // iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) - val fragmentID = FragmentPayload.generateFragmentID() - - // iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize) - val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset -> - val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size) - fullData.sliceArray(offset.. 13 else { return } if (packet.payload.size < FragmentPayload.HEADER_SIZE) { - Log.w(TAG, "Fragment packet too small: ${packet.payload.size}") + Log.d(TAG, "Fragment packet too small: ${packet.payload.size}") return null } @@ -119,57 +181,103 @@ class FragmentManager { // Use FragmentPayload for type-safe decoding val fragmentPayload = FragmentPayload.decode(packet.payload) if (fragmentPayload == null || !fragmentPayload.isValid()) { - Log.w(TAG, "Invalid fragment payload") + Log.d(TAG, "Invalid fragment payload") return null } - + // iOS: let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined() val fragmentIDString = fragmentPayload.getFragmentIDString() - - Log.d(TAG, "Received fragment ${fragmentPayload.index}/${fragmentPayload.total} for fragmentID: $fragmentIDString, originalType: ${fragmentPayload.originalType}") - - // iOS: if incomingFragments[fragmentID] == nil - if (!incomingFragments.containsKey(fragmentIDString)) { - incomingFragments[fragmentIDString] = mutableMapOf() - fragmentMetadata[fragmentIDString] = Triple( - fragmentPayload.originalType, - fragmentPayload.total, - System.currentTimeMillis() - ) + + val maxFragments = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID + if (fragmentPayload.total > maxFragments) { + Log.w(TAG, "Rejecting fragment with excessive total count: ${fragmentPayload.total} > $maxFragments") + return null } - - // iOS: incomingFragments[fragmentID]?[index] = Data(fragmentData) - incomingFragments[fragmentIDString]?.put(fragmentPayload.index, fragmentPayload.data) - - // iOS: if let fragments = incomingFragments[fragmentID], fragments.count == total - val fragmentMap = incomingFragments[fragmentIDString] - if (fragmentMap != null && fragmentMap.size == fragmentPayload.total) { - Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...") - - // iOS reassembly logic: for i in 0..() - for (i in 0 until fragmentPayload.total) { - fragmentMap[i]?.let { data -> - reassembledData.addAll(data.asIterable()) + + synchronized(fragmentStateLock) { + fragmentMetadata[fragmentIDString]?.let { (expectedType, expectedTotal, _) -> + if (expectedTotal != fragmentPayload.total || expectedType != fragmentPayload.originalType) { + Log.w(TAG, "Rejecting fragment for $fragmentIDString: inconsistent metadata") + removeFragmentSetLocked(fragmentIDString) + return null } } - - // Decode the original packet bytes we reassembled, so flags/compression are preserved - iOS fix - val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray()) - if (originalPacket != null) { - // iOS cleanup: incomingFragments.removeValue(forKey: fragmentID) - incomingFragments.remove(fragmentIDString) - fragmentMetadata.remove(fragmentIDString) - - Log.d(TAG, "Successfully reassembled and decoded original packet of ${reassembledData.size} bytes") - return originalPacket - } else { - val metadata = fragmentMetadata[fragmentIDString] - Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})") + + val isNewSet = !incomingFragments.containsKey(fragmentIDString) + if (isNewSet) { + val maxActive = com.bitchat.android.util.AppConstants.Fragmentation.MAX_ACTIVE_FRAGMENT_SETS + if (incomingFragments.size >= maxActive) { + Log.w(TAG, "Rejecting new fragment set $fragmentIDString: active fragment sets ${incomingFragments.size} >= $maxActive") + return null + } + + incomingFragments[fragmentIDString] = mutableMapOf() + fragmentMetadata[fragmentIDString] = Triple( + fragmentPayload.originalType, + fragmentPayload.total, + System.currentTimeMillis() + ) + fragmentCumulativeSize[fragmentIDString] = 0 + } + + val fragmentMap = incomingFragments[fragmentIDString] + if (fragmentMap == null) { + Log.w(TAG, "Dropping fragment set $fragmentIDString due to missing fragment map") + removeFragmentSetLocked(fragmentIDString) + return null + } + + val currentSize = fragmentCumulativeSize[fragmentIDString] + if (currentSize == null) { + Log.w(TAG, "Dropping fragment set $fragmentIDString due to missing size tracker") + removeFragmentSetLocked(fragmentIDString) + return null + } + + val oldEntrySize = fragmentMap[fragmentPayload.index]?.size ?: 0 + val newSize = currentSize - oldEntrySize + fragmentPayload.data.size + val maxTotalBytes = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_TOTAL_BYTES + if (newSize > maxTotalBytes) { + Log.w(TAG, "Rejecting fragment for $fragmentIDString: cumulative size $newSize exceeds cap $maxTotalBytes") + removeFragmentSetLocked(fragmentIDString) + return null + } + + val delta = (fragmentPayload.data.size - oldEntrySize).toLong() + val maxGlobalBytes = com.bitchat.android.util.AppConstants.Fragmentation.MAX_GLOBAL_FRAGMENT_TOTAL_BYTES + if (globalBufferedBytes + delta > maxGlobalBytes) { + Log.w(TAG, "Rejecting fragment for $fragmentIDString: global buffered bytes exceed cap $maxGlobalBytes") + if (isNewSet) { + removeFragmentSetLocked(fragmentIDString) + } + return null + } + + fragmentMap[fragmentPayload.index] = fragmentPayload.data + fragmentCumulativeSize[fragmentIDString] = newSize + globalBufferedBytes += delta + + val expectedTotal = fragmentMetadata[fragmentIDString]?.second ?: fragmentPayload.total + if (fragmentMap.size == expectedTotal) { + // iOS reassembly logic: for i in 0..() + for (i in 0 until expectedTotal) { + fragmentMap[i]?.let { data -> + reassembledData.addAll(data.asIterable()) + } + } + + val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray()) + if (originalPacket != null) { + removeFragmentSetLocked(fragmentIDString) + + val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte()) + return suppressedTtlPacket + } else { + val metadata = fragmentMetadata[fragmentIDString] + Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})") + } } - } else { - val received = fragmentMap?.size ?: 0 - Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/${fragmentPayload.total} fragments for $fragmentIDString") } } catch (e: Exception) { @@ -178,6 +286,15 @@ class FragmentManager { return null } + + private fun removeFragmentSetLocked(fragmentIDString: String) { + incomingFragments.remove(fragmentIDString) + fragmentMetadata.remove(fragmentIDString) + val bytes = fragmentCumulativeSize.remove(fragmentIDString)?.toLong() ?: 0L + if (bytes != 0L) { + globalBufferedBytes = (globalBufferedBytes - bytes).coerceAtLeast(0L) + } + } /** * Helper function to match iOS stride functionality @@ -198,20 +315,16 @@ class FragmentManager { * Clean old fragments (> 30 seconds old) */ private fun cleanupOldFragments() { - val now = System.currentTimeMillis() - val cutoff = now - FRAGMENT_TIMEOUT - - // iOS: let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } - val oldFragments = fragmentMetadata.filter { it.value.third < cutoff }.map { it.key } - - // iOS: for fragmentID in oldFragments { incomingFragments.removeValue(forKey: fragmentID) } - for (fragmentID in oldFragments) { - incomingFragments.remove(fragmentID) - fragmentMetadata.remove(fragmentID) - } - - if (oldFragments.isNotEmpty()) { - Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)") + synchronized(fragmentStateLock) { + val now = System.currentTimeMillis() + val cutoff = now - FRAGMENT_TIMEOUT + + // iOS: let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } + val oldFragments = fragmentMetadata.filter { it.value.third < cutoff }.map { it.key } + + for (fragmentID in oldFragments) { + removeFragmentSetLocked(fragmentID) + } } } @@ -219,17 +332,21 @@ class FragmentManager { * Get debug information - matches iOS debugging */ fun getDebugInfo(): String { - return buildString { - appendLine("=== Fragment Manager Debug Info (iOS Compatible) ===") - appendLine("Active Fragment Sets: ${incomingFragments.size}") - appendLine("Fragment Size Threshold: $FRAGMENT_SIZE_THRESHOLD bytes") - appendLine("Max Fragment Size: $MAX_FRAGMENT_SIZE bytes") - - fragmentMetadata.forEach { (fragmentID, metadata) -> - val (originalType, totalFragments, timestamp) = metadata - val received = incomingFragments[fragmentID]?.size ?: 0 - val ageSeconds = (System.currentTimeMillis() - timestamp) / 1000 - appendLine(" - $fragmentID: $received/$totalFragments fragments, type: $originalType, age: ${ageSeconds}s") + synchronized(fragmentStateLock) { + return buildString { + appendLine("=== Fragment Manager Debug Info (iOS Compatible) ===") + appendLine("Active Fragment Sets: ${incomingFragments.size}") + appendLine("Fragment Size Threshold: $FRAGMENT_SIZE_THRESHOLD bytes") + appendLine("Max Fragment Size: $MAX_FRAGMENT_SIZE bytes") + appendLine("Global Buffered Bytes: $globalBufferedBytes") + + fragmentMetadata.forEach { (fragmentID, metadata) -> + val (originalType, totalFragments, timestamp) = metadata + val received = incomingFragments[fragmentID]?.size ?: 0 + val ageSeconds = (System.currentTimeMillis() - timestamp) / 1000 + val bytes = fragmentCumulativeSize[fragmentID] ?: 0 + appendLine(" - $fragmentID: $received/$totalFragments fragments, bytes=$bytes, type: $originalType, age: ${ageSeconds}s") + } } } } @@ -250,8 +367,12 @@ class FragmentManager { * Clear all fragments */ fun clearAllFragments() { - incomingFragments.clear() - fragmentMetadata.clear() + synchronized(fragmentStateLock) { + incomingFragments.clear() + fragmentMetadata.clear() + fragmentCumulativeSize.clear() + globalBufferedBytes = 0L + } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt new file mode 100644 index 00000000..9c6bd15e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -0,0 +1,173 @@ +package com.bitchat.android.mesh + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared transport send wrapper that applies bitchat packet fragmentation and + * transfer progress before a transport writes packets to its concrete medium. + */ +class FragmentingPacketSender( + private val scope: CoroutineScope, + private val fragmentManager: FragmentManager?, + private val logTag: String, + private val interFragmentDelayMs: Long = 20L +) { + private val transferJobs = ConcurrentHashMap() + + fun send( + routed: RoutedPacket, + description: String, + sendSingle: (RoutedPacket) -> Boolean + ): Boolean { + val transferId = transferIdFor(routed) + val packets = packetsForTransport(routed) + if (packets == null) { + if (transferId != null) { + TransferProgressManager.fail(transferId) + } + return false + } + val total = packets.size + + if (total <= 1) { + if (transferId != null) { + TransferProgressManager.start(transferId, 1) + } + val sent = sendSingle( + routed.copy( + packet = packets.first(), + transferId = transferId, + preparedPackets = null + ) + ) + if (transferId != null) { + if (sent) { + TransferProgressManager.progress(transferId, 1, 1) + TransferProgressManager.complete(transferId, 1) + } else { + TransferProgressManager.fail(transferId) + } + } + return sent + } + + Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description") + if (transferId != null) { + TransferProgressManager.start(transferId, total) + } + + val job = scope.launch(start = CoroutineStart.LAZY) { + var sent = 0 + for (packet in packets) { + if (!isActive) return@launch + if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch + + val fragment = routed.copy( + packet = packet, + transferId = transferId, + preparedPackets = null + ) + val delivered = try { + sendSingle(fragment) + } catch (e: Exception) { + Log.e(logTag, "Fragment send failed for $description: ${e.message}", e) + false + } + + if (!delivered) { + Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments") + return@launch + } + + sent += 1 + if (transferId != null) { + TransferProgressManager.progress(transferId, sent, total) + } + if (sent < total) { + delay(interFragmentDelayMs) + } + } + + if (transferId != null) { + TransferProgressManager.complete(transferId, total) + } + } + + if (transferId != null) { + transferJobs[transferId] = job + job.invokeOnCompletion { transferJobs.remove(transferId, job) } + } + job.start() + return true + } + + fun cancelTransfer(transferId: String): Boolean { + val job = transferJobs.remove(transferId) ?: return false + job.cancel() + return true + } + + private fun packetsForTransport(routed: RoutedPacket): List? { + routed.preparedPackets?.let { prepared -> + if (prepared.isEmpty() || + prepared.size > com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) { + Log.e(logTag, "Rejected invalid prepared fragment plan (${prepared.size} packets)") + return null + } + return prepared + } + + val packet = routed.packet + if (packet.type == MessageType.FRAGMENT.value) { + return listOf(packet) + } + + val manager = fragmentManager ?: return listOf(packet) + return try { + // Receivers hard-cap reassembly at MAX_FRAGMENTS_PER_ID; sending more + // fragments would be undeliverable, so reject here instead. + val fragments = manager.createFragments( + packet, + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID + ) + if (fragments.isEmpty()) { + Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}") + null + } else { + fragments + } + } catch (e: Exception) { + Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e) + null + } + } + + private fun transferIdFor(routed: RoutedPacket): String? { + routed.transferId?.let { return it } + val packet = routed.packet + return if (packet.type == MessageType.FILE_TRANSFER.value) { + sha256Hex(packet.payload) + } else { + null + } + } + + private fun sha256Hex(bytes: ByteArray): String = try { + val md = MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt new file mode 100644 index 00000000..0dd6a552 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt @@ -0,0 +1,143 @@ +package com.bitchat.android.mesh + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Abstract base tracker for mesh connections (BLE, Wi-Fi Aware, etc.) + * Encapsulates common state machine logic: + * - Connection attempt tracking (retries, backoff) + * - Pending connection management + * - Automatic cleanup of expired attempts + */ +abstract class MeshConnectionTracker( + private val scope: CoroutineScope, + protected val tag: String +) { + companion object { + const val CONNECTION_RETRY_DELAY = 5_000L + const val MAX_CONNECTION_ATTEMPTS = 3 + const val CLEANUP_INTERVAL = 30_000L + } + + /** + * Connection attempt tracking with automatic expiry + */ + protected data class ConnectionAttempt( + val attempts: Int, + val lastAttempt: Long = System.currentTimeMillis() + ) { + fun isExpired(): Boolean = + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 + + fun shouldRetry(): Boolean = + attempts < MAX_CONNECTION_ATTEMPTS && + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + } + + // Tracks in-progress or failed attempts + protected val pendingConnections = ConcurrentHashMap() + + private var isActive = false + + /** + * Start the tracker and its cleanup loop + */ + open fun start() { + isActive = true + startPeriodicCleanup() + } + + /** + * Stop the tracker + */ + open fun stop() { + isActive = false + pendingConnections.clear() + } + + /** + * Check if a connection attempt is allowed for this peer/address + */ + fun isConnectionAttemptAllowed(id: String): Boolean { + // If already connected, usually no need to retry (subclasses can override logic if needed, + // but typically the caller checks isConnected() first). + + val existingAttempt = pendingConnections[id] + return existingAttempt?.let { + it.isExpired() || it.shouldRetry() + } ?: true + } + + /** + * Record a new connection attempt. + * Returns true if the attempt was recorded (allowed), false if skipped. + */ + fun addPendingConnection(id: String): Boolean { + synchronized(pendingConnections) { + val currentAttempt = pendingConnections[id] + + // If strictly not allowed right now, reject + if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { + Log.d(tag, "Connection attempt already in progress for $id") + return false + } + + // Update attempt count + // Reset to 1 if expired, otherwise increment + val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 + pendingConnections[id] = ConnectionAttempt(attempts) + Log.d(tag, "Added pending connection for $id (attempts: $attempts)") + return true + } + } + + /** + * Remove a pending attempt (e.g., on success or fatal error) + */ + fun removePendingConnection(id: String) { + pendingConnections.remove(id) + } + + /** + * Abstract: Subclasses must define what "connected" means + */ + abstract fun isConnected(id: String): Boolean + + /** + * Abstract: Subclasses must implement disconnect logic + */ + abstract fun disconnect(id: String) + + /** + * Abstract: Subclasses report their active connection count + */ + abstract fun getConnectionCount(): Int + + private fun startPeriodicCleanup() { + scope.launch { + while (isActive) { + try { + delay(CLEANUP_INTERVAL) + if (!isActive) break + + // Clean up expired pending connections + val expired = pendingConnections.filter { it.value.isExpired() } + expired.keys.forEach { pendingConnections.remove(it) } + + if (expired.isNotEmpty()) { + Log.d(tag, "Cleaned up ${expired.size} expired connection attempts") + } + } catch (e: CancellationException) { + break + } catch (e: Exception) { + Log.w(tag, "Error in periodic cleanup: ${e.message}") + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt new file mode 100644 index 00000000..28cd3d02 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -0,0 +1,1092 @@ +package com.bitchat.android.mesh + +import android.content.Context +import android.util.Log +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared mesh coordinator that wires all mesh-layer components and provides common APIs + * for send/receive operations across transports. + */ +class MeshCore( + private val context: Context, + private val scope: CoroutineScope, + private val transport: MeshTransport, + private val encryptionService: EncryptionService, + val myPeerID: String, + private val maxTtl: UByte, + sharedGossipManager: GossipSyncManager?, + gossipConfigProvider: GossipSyncManager.ConfigProvider, + private val hooks: Hooks = Hooks() +) { + data class Hooks( + /** + * Reflects a decoded message into transport-owned state before delegate dispatch. + * Return false to suppress all downstream effects for a rejected message. + */ + val onMessageReceived: ((BitchatMessage) -> Boolean)? = null, + val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, + val readReceiptInterceptor: ((String, String) -> Boolean)? = null, + val onReadReceiptSent: ((String) -> Unit)? = null, + val announcementNicknameProvider: (() -> String?)? = null, + val leavePayloadProvider: (() -> ByteArray)? = null + ) + + private val peerManager = PeerManager() + val fragmentManager = FragmentManager() + private val readReceiptRetrySender = RetryingControlPacketSender(scope) + private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) + private val authenticatedPeerState by lazy { + AuthenticatedPeerStateCoordinator( + scope = scope, + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + withAuthenticatedSession = encryptionService::withAuthenticatedSession, + store = authenticatedPeerStateStore, + localStateProvider = { + AuthenticatedPeerState( + PeerCapabilities.LOCAL_SUPPORTED, + requireNotNull(encryptionService.getSigningPublicKey()) + ) + }, + applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, + sendState = ::sendAuthenticatedPeerState, + onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + ) + } + private val privateMediaSecurity by lazy { PrivateMediaSecurityController( + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + peerStateStatusProvider = authenticatedPeerState::status, + isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned + ) } + private val privateMediaPreparer by lazy { + PrivateMediaTransferPreparer( + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + ttl = maxTtl, + policyProvider = privateMediaSecurity::sendPolicy, + encrypt = { plaintext, peerID, authenticatedSession -> + try { + PrivateMediaEncryptionResult.Success( + encryptionService.encryptForSession( + plaintext, + peerID, + authenticatedSession + ) + ) + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: Exception) { + PrivateMediaEncryptionResult.Failed + } + }, + finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict, + fragment = fragmentManager::createFragments + ) + } + private val securityManager = SecurityManager(encryptionService, myPeerID) + private val storeForwardManager = StoreForwardManager() + private val messageHandler = MessageHandler(myPeerID, context.applicationContext) + private val packetProcessor = PacketProcessor(myPeerID) + private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray) + private val voiceFrameQueue = Channel(capacity = 128) + private val directPeers = ConcurrentHashMap.newKeySet() + + val gossipSyncManager: GossipSyncManager = + sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider) + private val ownsGossipManager: Boolean = sharedGossipManager == null + + var delegate: MeshDelegate? = null + + private var isActive = false + + init { + scope.launch { + for (request in voiceFrameQueue) dispatchVoiceFrame(request) + } + messageHandler.packetProcessor = packetProcessor + peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) } + setupDelegates() + + if (sharedGossipManager == null) { + gossipSyncManager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + transport.sendPacketToPeer(peerID, packet) + TransportBridgeService.sendToPeer(transport.id, peerID, packet) + } + + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signPacketBeforeBroadcast(packet) + } + } + } + } + + fun startCore() { + if (isActive) return + isActive = true + if (ownsGossipManager) { + gossipSyncManager.start() + } + } + + fun stopCore() { + if (!isActive) return + isActive = false + directPeers.clear() + if (ownsGossipManager) { + gossipSyncManager.stop() + } + } + + fun shutdown() { + directPeers.clear() + peerManager.shutdown() + fragmentManager.shutdown() + securityManager.shutdown() + storeForwardManager.shutdown() + messageHandler.shutdown() + packetProcessor.shutdown() + } + + fun processIncoming( + packet: BitchatPacket, + peerID: String?, + relayAddress: String?, + ingressLinkID: String? = null + ) { + packetProcessor.processPacket( + RoutedPacket( + packet = packet, + peerID = peerID, + relayAddress = relayAddress, + ingressLinkID = ingressLinkID + ) + ) + } + + fun sendFromBridge(packet: RoutedPacket) { + transport.broadcastPacket(packet) + } + + fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean { + return transport.broadcastPacket(packet) + } + + private fun dispatchGlobal(routed: RoutedPacket) { + transport.broadcastPacket(routed) + TransportBridgeService.broadcast(transport.id, routed) + } + + private suspend fun dispatchGlobalAndReport(routed: RoutedPacket): Boolean { + val acceptedByLocalTransport = transport.broadcastPacket(routed) + val acceptedByBridgedTransport = + TransportBridgeService.broadcastAndReport(transport.id, routed) + return acceptedByLocalTransport || acceptedByBridgedTransport + } + + private fun setupDelegates() { + peerManager.delegate = object : PeerManagerDelegate { + override fun onPeerListUpdated(peerIDs: List) { + try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { } + delegate?.didUpdatePeerList(peerIDs) + } + + override fun onPeerRemoved(peerID: String) { + directPeers.remove(peerID) + authenticatedPeerState.clear(peerID) + try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } + try { encryptionService.removePeer(peerID) } catch (_: Exception) { } + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } + } + + securityManager.delegate = object : SecurityManagerDelegate { + override fun onKeyExchangeCompleted( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + authenticatedSessionToken: ByteArray, + directRelayAddress: String?, + ingressLinkID: String? + ) { + authenticatedPeerState.onSessionAuthenticated( + peerID, + authenticatedRemoteStaticKey, + authenticatedSessionToken + ) + scope.launch { + delay(100) + sendAnnouncementToPeer(peerID) + delay(1000) + storeForwardManager.sendCachedMessages(peerID) + } + } + + override fun sendHandshakeResponse(peerID: String, response: ByteArray) { + val responsePacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = response, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(responsePacket))) + } + + override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) + } + + storeForwardManager.delegate = object : StoreForwardManagerDelegate { + override fun isFavorite(peerID: String): Boolean { + return delegate?.isFavorite(peerID) ?: false + } + + override fun isPeerOnline(peerID: String): Boolean { + return peerManager.isPeerActive(peerID) + } + + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + } + + messageHandler.delegate = object : MessageHandlerDelegate { + override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { + return peerManager.addOrUpdatePeer(peerID, nickname) + } + + override fun removePeer(peerID: String) { + peerManager.removePeer(peerID) + } + + override fun updatePeerNickname(peerID: String, nickname: String) { + peerManager.addOrUpdatePeer(peerID, nickname) + } + + override fun getPeerNickname(peerID: String): String? { + return peerManager.getPeerNickname(peerID) + } + + override fun getNetworkSize(): Int { + return peerManager.getActivePeerCount() + } + + override fun getMyNickname(): String? { + return delegate?.getNickname() + } + + override fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) + } + + override fun updatePeerInfoFromVerifiedAnnouncement( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: com.bitchat.android.model.PeerCapabilities? + ): Boolean { + return peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + nickname, + noisePublicKey, + signingPublicKey, + isVerified, + capabilities + ) + } + + override fun sendPacket(packet: BitchatPacket) { + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } + + override fun relayPacket(routed: RoutedPacket) { + dispatchGlobal(routed) + } + + override fun getBroadcastRecipient(): ByteArray { + return SpecialRecipients.BROADCAST + } + + override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean { + return securityManager.verifySignature(packet, peerID) + } + + override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? { + return securityManager.encryptForPeer(data, recipientPeerID) + } + + override fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): com.bitchat.android.noise.NoiseDecryptionResult? { + return securityManager.decryptFromPeer(encryptedData, senderPeerID) + } + + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { + return encryptionService.verifyEd25519Signature(signature, data, publicKey) + } + + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) + + override fun hasNoiseSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) + } + + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w("MeshCore", "Failed to remove Noise session for $peerID: ${e.message}") + } + } + + override fun initiateNoiseHandshake(peerID: String) { + this@MeshCore.initiateNoiseHandshake(peerID) + } + + override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? { + return try { + encryptionService.processHandshakeMessage(payload, peerID) + } catch (_: Exception) { + null + } + } + + override fun onAuthenticatedPeerStateReceived( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) { + authenticatedPeerState.receive(peerID, state, authenticatedSession) + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return delegate?.decryptChannelMessage(encryptedContent, channel) + } + + override fun onMessageReceived(message: BitchatMessage) { + if (hooks.onMessageReceived?.invoke(message) == false) return + delegate?.didReceiveMessage(message) + } + + override fun onChannelLeave(channel: String, fromPeer: String) { + delegate?.didReceiveChannelLeave(channel, fromPeer) + } + + override fun onDeliveryAckReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Delivered(peerID, java.util.Date()) + ) + } catch (_: Exception) { } + delegate?.didReceiveDeliveryAck(messageID, peerID) + } + + override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { + com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Read(peerID, java.util.Date()) + ) + } catch (_: Exception) { } + delegate?.didReceiveReadReceipt(messageID, peerID) + } + + override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } + } + + packetProcessor.delegate = object : PacketProcessorDelegate { + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean { + return securityManager.validatePacket(packet, peerID) + } + + override fun updatePeerLastSeen(peerID: String) { + peerManager.updatePeerLastSeen(peerID) + } + + override fun getPeerNickname(peerID: String): String? { + return peerManager.getPeerNickname(peerID) + } + + override fun getNetworkSize(): Int { + return peerManager.getActivePeerCount() + } + + override fun getBroadcastRecipient(): ByteArray { + return SpecialRecipients.BROADCAST + } + + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean { + return runBlocking { securityManager.handleNoiseHandshake(routed) } + } + + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } + } + + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + val result = messageHandler.handleAnnounceWithResult(routed) + if (result !is AnnounceHandlingResult.Accepted) return false + hooks.onAnnounceProcessed?.invoke(routed, result.isFirst) + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + return true + } + + override fun handleMessage(routed: RoutedPacket) { + scope.launch { messageHandler.handleMessage(routed) } + try { + val pkt = routed.packet + val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + gossipSyncManager.onPublicPacketSeen(pkt) + } + } catch (_: Exception) { } + } + + override fun handleVoiceFrame(routed: RoutedPacket): Boolean = + messageHandler.handlePublicVoiceFrame(routed) + + override fun handleLeave(routed: RoutedPacket) { + scope.launch { messageHandler.handleLeave(routed) } + } + + override fun handleFragment(packet: BitchatPacket): BitchatPacket? { + try { + val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && packet.type == MessageType.FRAGMENT.value) { + gossipSyncManager.onPublicPacketSeen(packet) + } + } catch (_: Exception) { } + return fragmentManager.handleFragment(packet) + } + + override fun sendAnnouncementToPeer(peerID: String) { + this@MeshCore.sendAnnouncementToPeer(peerID) + } + + override fun sendCachedMessages(peerID: String) { + storeForwardManager.sendCachedMessages(peerID) + } + + override fun relayPacket(routed: RoutedPacket) { + dispatchGlobal(routed) + } + + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + val sent = transport.sendPacketToPeer(peerID, routed.packet) + TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet) + return sent + } + + override fun handleRequestSync(routed: RoutedPacket) { + val fromPeer = routed.peerID ?: return + val req = RequestSyncPacket.decode(routed.packet.payload) ?: return + gossipSyncManager.handleRequestSync(fromPeer, req) + } + } + } + + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + if (content.isEmpty()) return + scope.launch { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = content.toByteArray(Charsets.UTF_8), + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + } + + private fun sendAuthenticatedPeerState( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ): Boolean { + val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode() + val ciphertext = securityManager.encryptForPeer( + plaintext, + peerID, + authenticatedSession + ) ?: return false + val packet = BitchatPacket( + version = if (ciphertext.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = maxTtl + ) + val signed = signPacketBeforeBroadcast(packet) + if (signed.signature?.size != 64) return false + dispatchGlobal(RoutedPacket(signed)) + return true + } + + fun sendFileBroadcast(file: BitchatFilePacket) { + try { + val payload = file.encode() ?: return + scope.launch { + val packet = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = maxTtl + ) + val signed = signPacketBeforeBroadcast(packet) + val transferId = MeshPacketUtils.sha256Hex(payload) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e("MeshCore", "sendFileBroadcast failed: ${e.message}", e) + } + } + + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + val payload = file.encode() ?: return + when (val prepared = prepareFilePrivate( + recipientPeerID, + file, + MeshPacketUtils.sha256Hex(payload), + allowLegacyFallback = false + )) { + is PrivateMediaPreparation.Ready -> prepared.transfer.commit() + is PrivateMediaPreparation.RequiresLegacyConsent -> + Log.w("MeshCore", "Private media requires explicit one-shot legacy consent") + PrivateMediaPreparation.NeedsHandshake -> { + Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending") + initiateNoiseHandshake(recipientPeerID) + } + PrivateMediaPreparation.AwaitingPeerState -> Unit + is PrivateMediaPreparation.Rejected -> + Log.w("MeshCore", "Private media blocked: ${prepared.reason}") + } + } + + fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + if (payload.isEmpty()) return + voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf())) + } + + private fun dispatchVoiceFrame(request: VoiceFrameRequest) { + try { + val recipientPeerID = request.recipientPeerID + val packet = if (recipientPeerID == null) { + BitchatPacket( + version = 1u, + type = MessageType.VOICE_FRAME.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = request.payload, + ttl = maxTtl + ) + } else { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) return + val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode() + val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID) + BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = maxTtl + ) + } + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } catch (e: Exception) { + Log.w("MeshCore", "Live voice frame send failed: ${e.message}") + } + } + + fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation { + return when (val outcome = privateMediaPreparer.prepare( + recipientPeerID = recipientPeerID, + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + file = file, + allowLegacyFallback = allowLegacyFallback + )) { + is PrivateMediaBuildOutcome.RequiresLegacyConsent -> + PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning) + PrivateMediaBuildOutcome.NeedsHandshake -> + PrivateMediaPreparation.NeedsHandshake + PrivateMediaBuildOutcome.AwaitingPeerState -> + PrivateMediaPreparation.AwaitingPeerState + is PrivateMediaBuildOutcome.Rejected -> + PrivateMediaPreparation.Rejected(outcome.reason) + is PrivateMediaBuildOutcome.Ready -> { + val built = outcome.built + val routed = RoutedPacket( + packet = built.packet, + transferId = transferId, + preparedPackets = built.fragments + ) + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer(transferId, built.wireMode) { + if (!isActive) { + false + } else { + dispatchGlobal(routed) + true + } + } + ) + } + } + } + + fun cancelFileTransfer(transferId: String): Boolean { + return transport.cancelTransfer(transferId) + } + + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { + if (content.isEmpty() || recipientPeerID.isEmpty()) return + scope.launch { + val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() + + if (encryptionService.hasEstablishedSession(recipientPeerID)) { + try { + val privateMessage = PrivateMessagePacket(messageID = finalMessageID, content = content) + val tlvData = privateMessage.encode() ?: return@launch + val messagePayload = NoisePayload( + type = NoisePayloadType.PRIVATE_MESSAGE, + data = tlvData + ) + val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to encrypt private message: ${e.message}") + } + } else { + initiateNoiseHandshake(recipientPeerID) + } + } + } + + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + scope.launch { + if (hooks.readReceiptInterceptor?.invoke(messageID, recipientPeerID) == true) return@launch + try { + val payload = NoisePayload( + type = NoisePayloadType.READ_RECEIPT, + data = messageID.toByteArray(Charsets.UTF_8) + ).encode() + val enc = encryptionService.encrypt(payload, recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + val retryKey = "$recipientPeerID:$messageID" + readReceiptRetrySender.enqueue( + key = retryKey, + sendAttempt = { + dispatchGlobalAndReport(RoutedPacket(signedPacket)) + }, + onComplete = { accepted -> + if (accepted) { + try { + com.bitchat.android.services.SeenMessageStore + .getInstance(context.applicationContext) + .markReadReceiptSent(messageID) + } catch (_: Exception) { } + hooks.onReadReceiptSent?.invoke(messageID) + } + } + ) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to send read receipt: ${e.message}") + } + } + } + + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_CHALLENGE, + data = com.bitchat.android.services.VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA) + ) + sendNoisePayloadToPeer(payload, peerID) + } + + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = com.bitchat.android.services.VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_RESPONSE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID) + } + + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) { + scope.launch { + try { + val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}") + } + } + } + + fun sendBroadcastAnnounce() { + scope.launch { + val nickname = hooks.announcementNicknameProvider?.invoke() + ?: delegate?.getNickname() + ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: run { + Log.e("MeshCore", "No static public key available for announcement") + return@launch + } + val signingKey = encryptionService.getSigningPublicKey() ?: run { + Log.e("MeshCore", "No signing public key available for announcement") + return@launch + } + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch + val announcePacket = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = tlvPayload + ) + val signedPacket = signPacketBeforeBroadcast(announcePacket) + dispatchGlobal(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + } + + fun sendAnnouncementToPeer(peerID: String) { + if (peerManager.hasAnnouncedToPeer(peerID)) return + val nickname = hooks.announcementNicknameProvider?.invoke() + ?: delegate?.getNickname() + ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: return + val signingKey = encryptionService.getSigningPublicKey() ?: return + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = tlvPayload + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + peerManager.markPeerAsAnnouncedTo(peerID) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + + private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? { + var tlvPayload = announcement.encode() ?: return null + val directPeersForGossip = getDirectPeerIDsForGossip() + try { + if (directPeersForGossip.isNotEmpty()) { + tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip) + } + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } + return tlvPayload + } + + private fun getDirectPeerIDsForGossip(): List { + return try { + val verifiedDirect = peerManager.getVerifiedPeers() + .filter { it.value.isDirectConnection } + .keys + val localDirect = (verifiedDirect + directPeers).toSet() + // Publish this transport's direct peers and gossip the cross-transport union so a + // node connected via multiple transports advertises a complete neighbor list. + try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers(transport.id, localDirect) } catch (_: Exception) { } + val union = try { + com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { localDirect } + } catch (_: Exception) { localDirect } + union.distinct().take(10) + } catch (_: Exception) { + directPeers.toList().take(10) + } + } + + fun sendLeaveAnnouncement() { + val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf() + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = payload + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } + + fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() + + fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() + + fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID) + + fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { + return peerManager.addOrUpdatePeer(peerID, nickname) + } + + fun removePeer(peerID: String) { + directPeers.remove(peerID) + peerManager.removePeer(peerID) + } + + fun setDirectConnection(peerID: String, isDirect: Boolean) { + if (isDirect) { + directPeers.add(peerID) + } else { + directPeers.remove(peerID) + } + peerManager.refreshPeerList() + } + + fun updatePeerRSSI(peerID: String, rssi: Int) { + peerManager.updatePeerRSSI(peerID, rssi) + } + + fun getDebugInfoWithDeviceAddresses(deviceMap: Map): String { + return peerManager.getDebugInfoWithDeviceAddresses(deviceMap) + } + + fun getFingerprintDebugInfo(): String { + return peerManager.getFingerprintDebugInfo() + } + + fun hasEstablishedSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) + } + + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { + return encryptionService.getSessionState(peerID) + } + + fun initiateNoiseHandshake(peerID: String) { + scope.launch { + try { + val handshakeData = encryptionService.initiateHandshake(peerID) ?: return@launch + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = handshakeData, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to initiate Noise handshake with $peerID: ${e.message}") + } + } + } + + fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) + + fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + + fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey() + + fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID) + + fun getEncryptedPeers(): List = emptyList() + + fun getActivePeerCount(): Int = try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } + + fun refreshPeerList() { + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } + + fun getDeviceAddressForPeer(peerID: String): String? = transport.getDeviceAddressForPeer(peerID) + + fun getDeviceAddressToPeerMapping(): Map = transport.getDeviceAddressToPeerMapping() + + fun getDebugStatus( + transportInfo: String, + deviceMap: Map, + extraLines: List = emptyList(), + title: String? = null + ): String { + return buildString { + appendLine("=== ${title ?: "${transport.id} Mesh Debug Status"} ===") + appendLine("My Peer ID: $myPeerID") + if (extraLines.isNotEmpty()) { + extraLines.forEach { appendLine(it) } + } + appendLine(transportInfo) + appendLine(peerManager.getDebugInfo(deviceMap)) + appendLine(fragmentManager.getDebugInfo()) + appendLine(securityManager.getDebugInfo()) + appendLine(storeForwardManager.getDebugInfo()) + appendLine(messageHandler.getDebugInfo()) + appendLine(packetProcessor.getDebugInfo()) + } + } + + fun clearAllInternalData() { + directPeers.clear() + fragmentManager.clearAllFragments() + storeForwardManager.clearAllCache() + securityManager.clearAllData() + peerManager.clearAllPeers() + peerManager.clearAllFingerprints() + try { gossipSyncManager.clear() } catch (_: Exception) { } + } + + fun clearAllEncryptionData() { + encryptionService.clearPersistentIdentity() + } + + private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket { + return try { + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { + val destination = recipient.toHexString() + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath( + myPeerID, + destination + ) + if (path != null && path.size >= 3) { + val intermediates = path.subList(1, path.size - 1) + packet.copy( + route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) }, + version = 2u + ) + } else { + packet.copy(route = null) + } + } else { + packet + } + } catch (_: Exception) { + packet + } + } + + private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? { + val routed = applyRouteIfAvailable(packet) + val signingBytes = routed.toBinaryDataForSigning() ?: return null + val signature = encryptionService.signData(signingBytes) ?: return null + return routed.copy(signature = signature) + } + + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + return try { + val withRoute = applyRouteIfAvailable(packet) + + val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute + val signature = encryptionService.signData(packetDataForSigning) + if (signature != null) { + withRoute.copy(signature = signature) + } else { + withRoute + } + } catch (_: Exception) { + packet + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt new file mode 100644 index 00000000..b54b1152 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatMessage + +/** + * Shared mesh delegate interface for transport-agnostic callbacks. + */ +interface MeshDelegate { + fun didReceiveMessage(message: BitchatMessage) + fun didUpdatePeerList(peers: List) + fun didReceiveChannelLeave(channel: String, fromPeer: String) + fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) + fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {} + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {} + /** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */ + fun didResolvePrivateMediaPolicy(peerID: String) {} + fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? + fun getNickname(): String? + fun isFavorite(peerID: String): Boolean +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt new file mode 100644 index 00000000..514e7a99 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.mesh + +/** + * Shared helpers for mesh packet handling. + */ +object MeshPacketUtils { + /** + * Convert hex string peer ID to binary data (8 bytes), matching iOS behavior. + */ + fun hexStringToByteArray(hexString: String): ByteArray { + val result = ByteArray(8) { 0 } + var tempID = hexString + var index = 0 + + while (tempID.length >= 2 && index < 8) { + val hexByte = tempID.substring(0, 2) + val byte = hexByte.toIntOrNull(16)?.toByte() + if (byte != null) { + result[index] = byte + } + tempID = tempID.substring(2) + index++ + } + return result + } + + /** + * Hash payloads to a stable hex ID for transfer tracking. + */ + fun sha256Hex(bytes: ByteArray): String = try { + val md = java.security.MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt new file mode 100644 index 00000000..7b357149 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -0,0 +1,63 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatFilePacket + +/** + * Transport-agnostic mesh service API for UI and routing layers. + */ +interface MeshService { + val myPeerID: String + var delegate: MeshDelegate? + + fun startServices() + fun stopServices() + + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) + fun sendDeliveryAck(messageID: String, recipientPeerID: String) {} + fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {} + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun sendFileBroadcast(file: BitchatFilePacket) + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) + fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) + fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation + fun cancelFileTransfer(transferId: String): Boolean + + fun sendBroadcastAnnounce() + fun sendAnnouncementToPeer(peerID: String) + + fun getPeerNicknames(): Map + fun getPeerRSSI(): Map + fun getActivePeerCount(): Int + fun hasEstablishedSession(peerID: String): Boolean + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState + fun initiateNoiseHandshake(peerID: String) + fun getPeerFingerprint(peerID: String): String? + fun getPeerInfo(peerID: String): PeerInfo? + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean + fun getIdentityFingerprint(): String + fun getStaticNoisePublicKey(): ByteArray? + fun shouldShowEncryptionIcon(peerID: String): Boolean + fun getEncryptedPeers(): List + + fun getDeviceAddressForPeer(peerID: String): String? + fun getDeviceAddressToPeerMapping(): Map + fun printDeviceAddressesForPeers(): String + fun getDebugStatus(): String + + fun clearAllInternalData() + fun clearAllEncryptionData() +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt new file mode 100644 index 00000000..354fcd7e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -0,0 +1,36 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket + +/** + * Transport abstraction used by MeshCore to send packets via a specific medium. + */ +interface MeshTransport { + val id: String + + /** + * Broadcasts a packet and reports whether at least one concrete transport write was accepted. + */ + fun broadcastPacket(routed: RoutedPacket): Boolean + + fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean + + /** + * Send through an exact transport generation rather than a reusable peer alias. + * Transports that cannot prove the link identity must decline the operation. + */ + fun sendPacketToLink( + relayAddress: String, + ingressLinkID: String, + packet: BitchatPacket + ): Boolean = false + + fun cancelTransfer(transferId: String): Boolean = false + + fun getDeviceAddressForPeer(peerID: String): String? = null + + fun getDeviceAddressToPeerMapping(): Map = emptyMap() + + fun getTransportDebugInfo(): String = "" +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 76fea7a1..9434fc6e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -1,91 +1,104 @@ package com.bitchat.android.mesh import android.util.Log +import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import com.bitchat.android.sync.PacketIdUtil import com.bitchat.android.util.toHexString +import com.bitchat.android.features.voice.LiveVoiceManager +import com.bitchat.android.features.voice.LiveVoiceScope import kotlinx.coroutines.* import java.util.* -import kotlin.random.Random + +sealed class AnnounceHandlingResult { + data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult() + object Rejected : AnnounceHandlingResult() +} /** * Handles processing of different message types * Extracted from BluetoothMeshService for better separation of concerns */ -class MessageHandler(private val myPeerID: String) { +class MessageHandler(private val myPeerID: String, private val appContext: android.content.Context) { companion object { private const val TAG = "MessageHandler" + private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L + private const val MAX_CONSECUTIVE_DECRYPT_FAILURES = 3 } - + // Delegate for callbacks var delegate: MessageHandlerDelegate? = null - + // Reference to PacketProcessor for recursive packet handling var packetProcessor: PacketProcessor? = null - + // Coroutines private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + + // Consecutive decrypt failures per peer; only signature-verified packets reach this path, + // so repeated failures mean the established session is stale (peer re-handshaked elsewhere). + private val consecutiveDecryptFailures = java.util.concurrent.ConcurrentHashMap() + /** * Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService + * + * Returns false when the payload could not be decrypted, so callers can treat the + * packet as not liveness-proving (no lastSeen refresh, no relay). */ - suspend fun handleNoiseEncrypted(routed: RoutedPacket) { + suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - - Log.d(TAG, "Processing Noise encrypted message from $peerID (${packet.payload.size} bytes)") - + // Skip our own messages - if (peerID == myPeerID) return - + if (peerID == myPeerID) return true + // Check if this message is for us val recipientID = packet.recipientID?.toHexString() if (recipientID != myPeerID) { - Log.d(TAG, "🔐 Encrypted message not for me (for $recipientID, I am $myPeerID)") - return + return true } - + try { // Decrypt the message using the Noise service - val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) - if (decryptedData == null) { + val decryption = delegate?.decryptFromPeer(packet.payload, peerID) + if (decryption == null) { Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake") - return + registerDecryptFailure(peerID) + return false } - + consecutiveDecryptFailures.remove(peerID) + val decryptedData = decryption.plaintext + if (decryptedData.isEmpty()) { Log.w(TAG, "Decrypted data is empty from $peerID") - return + return true } - - // NEW: Use NoisePayload system exactly like iOS + val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData) if (noisePayload == null) { Log.w(TAG, "Failed to parse NoisePayload from $peerID") - return + return true } - Log.d(TAG, "🔓 Decrypted NoisePayload type ${noisePayload.type} from $peerID") - when (noisePayload.type) { com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { // Decode TLV private message exactly like iOS val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) if (privateMessage != null) { - Log.d(TAG, "🔓 Decrypted TLV PM from $peerID: ${privateMessage.content.take(30)}...") - // Handle favorite/unfavorite notifications embedded as PMs val pmContent = privateMessage.content - if (pmContent.startsWith("[FAVORITED]") || pmContent.startsWith("[UNFAVORITED]")) { + if (FavoriteControlMessage.parse(pmContent) != null) { handleFavoriteNotificationFromMesh(pmContent, peerID) // Acknowledge delivery for UX parity sendDeliveryAck(privateMessage.messageID, peerID) - return + return true } // Create BitchatMessage - preserve source packet timestamp @@ -99,7 +112,7 @@ class MessageHandler(private val myPeerID: String) { isPrivate = true, recipientNickname = delegate?.getMyNickname(), senderPeerID = peerID, - mentions = null // TODO: Parse mentions if needed + mentions = null ) // Notify delegate @@ -110,10 +123,63 @@ class MessageHandler(private val myPeerID: String) { } } + com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> { + // Handle encrypted file transfer; generate unique message ID + val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data) + if (file != null) { + Log.d(TAG, "Encrypted file from $peerID: ${file.fileSize} bytes") + val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() + val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) + val message = BitchatMessage( + id = uniqueMsgId, + sender = delegate?.getPeerNickname(peerID) ?: "Unknown", + content = savedPath, + type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), + timestamp = java.util.Date(packet.timestamp.toLong()), + isRelay = false, + isPrivate = true, + recipientNickname = delegate?.getMyNickname(), + senderPeerID = peerID + ) + + if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) { + delegate?.onMessageReceived(message) + } + + // Send delivery ACK with generated message ID + sendDeliveryAck(uniqueMsgId, peerID) + } else { + Log.w(TAG, "Failed to decode encrypted file transfer from $peerID") + } + } + + com.bitchat.android.model.NoisePayloadType.VOICE_FRAME -> { + return LiveVoiceManager.getInstance(appContext).handleFrame( + peerID = peerID, + nickname = delegate?.getPeerNickname(peerID) ?: peerID, + scope = LiveVoiceScope.DIRECT_MESSAGE, + payload = noisePayload.data, + timestampMs = packet.timestamp.toLong() + ) + } + + com.bitchat.android.model.NoisePayloadType.PEER_STATE -> { + val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data) + if (authenticatedState == null) { + Log.w(TAG, "Dropping malformed authenticated peer state from ${peerID.take(8)}") + } else { + delegate?.onAuthenticatedPeerStateReceived( + peerID, + authenticatedState, + decryption.authenticatedSession + ) + } + } + com.bitchat.android.model.NoisePayloadType.DELIVERED -> { // Handle delivery ACK exactly like iOS val messageID = String(noisePayload.data, Charsets.UTF_8) - Log.d(TAG, "📬 Delivery ACK received from $peerID for message $messageID") + Log.d(TAG, "Delivery ACK from $peerID for $messageID") // Simplified: Call delegate with messageID and peerID directly delegate?.onDeliveryAckReceived(messageID, peerID) @@ -122,16 +188,47 @@ class MessageHandler(private val myPeerID: String) { com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { // Handle read receipt exactly like iOS val messageID = String(noisePayload.data, Charsets.UTF_8) - Log.d(TAG, "👁️ Read receipt received from $peerID for message $messageID") + Log.d(TAG, "Read receipt from $peerID for $messageID") // Simplified: Call delegate with messageID and peerID directly delegate?.onReadReceiptReceived(messageID, peerID) } + com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> { + delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } + com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> { + delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } } } catch (e: Exception) { Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}") } + return true + } + + /** + * Count consecutive decrypt failures from a signature-verified peer that we still hold an + * established session for. After repeated failures the session is stale (the peer completed + * a new handshake elsewhere), so destroy it and start a fresh handshake; the peer's side + * will finish via the responder-candidate path and evict its own stale session. + */ + private fun registerDecryptFailure(peerID: String) { + if (peerID == "unknown" || peerID == myPeerID) return + if (delegate?.hasNoiseSession(peerID) != true) return + val failures = (consecutiveDecryptFailures[peerID] ?: 0) + 1 + if (failures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + consecutiveDecryptFailures.remove(peerID) + Log.w(TAG, "Noise session with $peerID stale after $failures decrypt failures; resetting and re-handshaking") + try { delegate?.removeNoiseSession(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to reset Noise session for $peerID: ${e.message}") + } + try { delegate?.initiateNoiseHandshake(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to re-initiate handshake with $peerID: ${e.message}") + } + } else { + consecutiveDecryptFailures[peerID] = failures + } } /** @@ -153,19 +250,18 @@ class MessageHandler(private val myPeerID: String) { } // Create NOISE_ENCRYPTED packet exactly like iOS - val packet = BitchatPacket( - version = 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(senderPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = encryptedPayload, - signature = null, - ttl = 7u // Same TTL as iOS messageTTL - ) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(senderPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encryptedPayload, + signature = null, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS messageTTL + ) delegate?.sendPacket(packet) - Log.d(TAG, "📤 Sent delivery ACK to $senderPeerID for message $messageID") } catch (e: Exception) { Log.e(TAG, "Failed to send delivery ACK to $senderPeerID: ${e.message}") @@ -176,72 +272,91 @@ class MessageHandler(private val myPeerID: String) { * Handle announce message with TLV decoding and signature verification - exactly like iOS */ suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + return (handleAnnounceWithResult(routed) as? AnnounceHandlingResult.Accepted)?.isFirst ?: false + } + + suspend fun handleAnnounceWithResult(routed: RoutedPacket): AnnounceHandlingResult { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - if (peerID == myPeerID) return false + if (peerID == myPeerID) return AnnounceHandlingResult.Rejected + + // Peers use wall-clock packet timestamps; tolerate moderate device clock skew + // during identity learning, or later signed messages cannot be verified. + val now = System.currentTimeMillis() + val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong()) + if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) { + Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)") + return AnnounceHandlingResult.Rejected + } - // Try to decode as iOS-compatible IdentityAnnouncement with TLV format - val announcement = IdentityAnnouncement.decode(packet.payload) + val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key -> + delegate?.verifyEd25519Signature(signature, data, key) ?: false + } if (announcement == null) { - Log.w(TAG, "Failed to decode announce from $peerID as iOS-compatible TLV format") - return false + Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from ${peerID.take(8)}") + return AnnounceHandlingResult.Rejected } - - // Verify packet signature using the announced signing public key - var verified = false - if (packet.signature != null) { - // Verify that the packet was signed by the signing private key corresponding to the announced signing public key - verified = delegate?.verifyEd25519Signature(packet.signature!!, packet.toBinaryDataForSigning()!!, announcement.signingPublicKey) ?: false - if (!verified) { - Log.w(TAG, "⚠️ Signature verification for announce failed ${peerID.take(8)}") - } + + val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey) + if (persistedSigningKey != null && + !persistedSigningKey.contentEquals(announcement.signingPublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state") + return AnnounceHandlingResult.Rejected } + var verified = true + // Check for existing peer with different noise public key // If existing peer has a different noise public key, do not consider this verified val existingPeer = delegate?.getPeerInfo(peerID) if (existingPeer != null && existingPeer.noisePublicKey != null && !existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey)) { - Log.w(TAG, "⚠️ Announce key mismatch for ${peerID.take(8)}... — keeping unverified") + Log.w(TAG, "Announce key mismatch for ${peerID.take(8)} - keeping unverified") + verified = false + } + + if ( + existingPeer?.signingPublicKey != null && + !existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey) + ) { + Log.w( + TAG, + "Rejecting signing-key replacement for ${peerID.take(8)} without authenticated peer-state proof" + ) verified = false } // Require verified announce; ignore otherwise (no backward compatibility) if (!verified) { - Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...") - return false + return AnnounceHandlingResult.Rejected } - // Successfully decoded TLV format exactly like iOS - Log.d(TAG, "✅ Verified announce from $peerID: nickname=${announcement.nickname}, " + - "noisePublicKey=${announcement.noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., " + - "signingPublicKey=${announcement.signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...") - // Extract nickname and public keys from TLV data val nickname = announcement.nickname val noisePublicKey = announcement.noisePublicKey val signingPublicKey = announcement.signingPublicKey // Update peer info with verification status through new method - val isFirstAnnounce = delegate?.updatePeerInfo( + val isFirstAnnounce = delegate?.updatePeerInfoFromVerifiedAnnouncement( peerID = peerID, nickname = nickname, noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, - isVerified = true + isVerified = true, + capabilities = announcement.capabilities ) ?: false - // Update peer ID binding with noise public key for identity management - delegate?.updatePeerIDBinding( - newPeerID = peerID, - nickname = nickname, - publicKey = noisePublicKey, - previousPeerID = null - ) - - Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID") - return isFirstAnnounce + // Update mesh graph from gossip neighbors (only if TLV present) + try { + val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp) + } catch (_: Exception) { } + + Log.d(TAG, "Verified announce from $peerID (${announcement.nickname})") + return AnnounceHandlingResult.Accepted(isFirstAnnounce) } /** @@ -252,15 +367,12 @@ class MessageHandler(private val myPeerID: String) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)") - // Skip our own handshake messages if (peerID == myPeerID) return // Check if handshake is addressed to us val recipientID = packet.recipientID?.toHexString() if (recipientID != myPeerID) { - Log.d(TAG, "Handshake not for me (for $recipientID, I am $myPeerID)") return } @@ -269,8 +381,6 @@ class MessageHandler(private val myPeerID: String) { val response = delegate?.processNoiseHandshakeMessage(packet.payload, peerID) if (response != null) { - Log.d(TAG, "Generated handshake response for $peerID (${response.size} bytes)") - // Send response using same packet type (simplified iOS approach) val responsePacket = BitchatPacket( version = 1u, @@ -280,17 +390,10 @@ class MessageHandler(private val myPeerID: String) { timestamp = System.currentTimeMillis().toULong(), payload = response, signature = null, - ttl = 7u // Same TTL as iOS + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS ) delegate?.sendPacket(responsePacket) - Log.d(TAG, "📤 Sent handshake response to $peerID") - } - - // Check if session is now established - val hasSession = delegate?.hasNoiseSession(peerID) ?: false - if (hasSession) { - Log.d(TAG, "✅ Noise session established with $peerID") } } catch (e: Exception) { @@ -322,6 +425,27 @@ class MessageHandler(private val myPeerID: String) { } // Message relay is now handled by centralized PacketRelayManager } + + /** Validate and ingest an ephemeral public push-to-talk frame. */ + fun handlePublicVoiceFrame(routed: RoutedPacket): Boolean { + val packet = routed.packet + val peerID = routed.peerID ?: return false + if (peerID == myPeerID) return true + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(delegate?.getBroadcastRecipient())) return false + if (packet.timestamp > Long.MAX_VALUE.toULong()) return false + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + if (ageMs !in -30_000L..30_000L) return false + val peerInfo = delegate?.getPeerInfo(peerID) + if (peerInfo == null || !peerInfo.isVerifiedNickname) return false + return LiveVoiceManager.getInstance(appContext).handleFrame( + peerID = peerID, + nickname = delegate?.getPeerNickname(peerID) ?: peerID, + scope = LiveVoiceScope.PUBLIC_MESH, + payload = packet.payload, + timestampMs = packet.timestamp.toLong() + ) + } /** * Handle broadcast message with verification enforcement @@ -333,21 +457,42 @@ class MessageHandler(private val myPeerID: String) { // Enforce: only accept public messages from verified peers we know val peerInfo = delegate?.getPeerInfo(peerID) if (peerInfo == null || !peerInfo.isVerifiedNickname) { - Log.w(TAG, "🚫 Dropping public message from unverified or unknown peer ${peerID.take(8)}...") + Log.w(TAG, "Dropping public message from unverified peer ${peerID.take(8)}") return } try { - // Parse message + // Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER + val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER + val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload) + if (file != null) { + + val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) + val message = BitchatMessage( + id = PacketIdUtil.computeIdHex(packet).uppercase(), + sender = delegate?.getPeerNickname(peerID) ?: "unknown", + content = savedPath, + type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), + senderPeerID = peerID, + timestamp = Date(packet.timestamp.toLong()) + ) + if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) { + delegate?.onMessageReceived(message) + } + return + } else if (isFileTransfer) { + Log.w(TAG, "FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)}") + } + + // Fallback: plain text val message = BitchatMessage( + id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = String(packet.payload, Charsets.UTF_8), senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()) ) - delegate?.onMessageReceived(message) - } catch (e: Exception) { Log.e(TAG, "Failed to process broadcast message: ${e.message}") } @@ -358,13 +503,50 @@ class MessageHandler(private val myPeerID: String) { */ private suspend fun handlePrivateMessage(packet: BitchatPacket, peerID: String) { try { - // Verify signature if present - if (packet.signature != null && !delegate?.verifySignature(packet, peerID)!!) { + val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == + com.bitchat.android.protocol.MessageType.FILE_TRANSFER + val signatureIsValid = packet.signature != null && + delegate?.verifySignature(packet, peerID) == true + + // Migration fallback is visible to relays, so sender authenticity + // is mandatory. Never accept an unsigned directed raw file. + if (isFileTransfer && !signatureIsValid) { + Log.w(TAG, "Unsigned or invalid signed private file from $peerID") + return + } + + // Preserve prior behavior for other directed packet types: verify + // a signature whenever one is present. + if (!isFileTransfer && packet.signature != null && !signatureIsValid) { Log.w(TAG, "Invalid signature for private message from $peerID") return } - // Parse message + // Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER + val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload) + if (file != null) { + + val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) + val message = BitchatMessage( + id = java.util.UUID.randomUUID().toString().uppercase(), + sender = delegate?.getPeerNickname(peerID) ?: "unknown", + content = savedPath, + type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), + senderPeerID = peerID, + timestamp = Date(packet.timestamp.toLong()), + isPrivate = true, + recipientNickname = delegate?.getMyNickname() + ) + Log.d(TAG, "📄 Saved incoming file to $savedPath") + if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) { + delegate?.onMessageReceived(message) + } + return + } else if (isFileTransfer) { + Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}") + } + + // Fallback: plain text val message = BitchatMessage( sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = String(packet.payload, Charsets.UTF_8), @@ -377,6 +559,8 @@ class MessageHandler(private val myPeerID: String) { Log.e(TAG, "Failed to process private message from $peerID: ${e.message}") } } + + /** * Handle leave message @@ -442,22 +626,19 @@ class MessageHandler(private val myPeerID: String) { */ private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) { try { - val isFavorite = content.startsWith("[FAVORITED]") - val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") } + val control = FavoriteControlMessage.parse(content) ?: return - // Update mutual favorite status in persistence - // Resolve full Noise key if available via delegate peer info val peerInfo = delegate?.getPeerInfo(fromPeerID) val noiseKey = peerInfo?.noisePublicKey if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) - if (npub != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite) + if (control.npub != null) { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, control.npub) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, control.npub) } - // Determine iOS-style guidance text val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - val guidance = if (isFavorite) { + val guidance = if (control.isFavorite) { if (rel?.isFavorite == true) { " — mutual! You can continue DMs via Nostr when out of mesh." } else { @@ -467,15 +648,32 @@ class MessageHandler(private val myPeerID: String) { ". DMs over Nostr will pause unless you both favorite again." } - // Emit system message via delegate callback - val action = if (isFavorite) "favorited" else "unfavorited" + val action = if (control.isFavorite) "favorited" else "unfavorited" + val notice = "${peerInfo.nickname} $action you$guidance" val sys = com.bitchat.android.model.BitchatMessage( sender = "system", - content = "${peerInfo.nickname} $action you$guidance", + content = notice, timestamp = java.util.Date(), isRelay = false ) delegate?.onMessageReceived(sys) + + // Mirror the notice into the private conversation so it's visible while chatting + try { + val conversationID = com.bitchat.android.services.ContactDirectory + .canonicalConversationId(fromPeerID) + val sysPrivate = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = notice, + timestamp = java.util.Date(), + isRelay = false, + isPrivate = true, + senderPeerID = conversationID + ) + delegate?.onMessageReceived(sysPrivate) + } catch (_: Exception) { + // Best-effort; public notice already delivered + } } } catch (_: Exception) { // Best-effort; ignore errors @@ -495,7 +693,14 @@ interface MessageHandlerDelegate { fun getNetworkSize(): Int fun getMyNickname(): String? fun getPeerInfo(peerID: String): PeerInfo? - fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean + fun updatePeerInfoFromVerifiedAnnouncement( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: com.bitchat.android.model.PeerCapabilities? = null + ): Boolean // Packet operations fun sendPacket(packet: BitchatPacket) @@ -505,15 +710,23 @@ interface MessageHandlerDelegate { // Cryptographic operations fun verifySignature(packet: BitchatPacket, peerID: String): Boolean fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? - fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? + fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): com.bitchat.android.noise.NoiseDecryptionResult? fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean + fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null // Noise protocol operations fun hasNoiseSession(peerID: String): Boolean fun initiateNoiseHandshake(peerID: String) + fun removeNoiseSession(peerID: String) {} fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? - fun updatePeerIDBinding(newPeerID: String, nickname: String, - publicKey: ByteArray, previousPeerID: String?) + fun onAuthenticatedPeerStateReceived( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) {} // Message operations fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? @@ -523,4 +736,6 @@ interface MessageHandlerDelegate { fun onChannelLeave(channel: String, fromPeer: String) fun onDeliveryAckReceived(messageID: String, peerID: String) fun onReadReceiptReceived(messageID: String, peerID: String) + fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) + fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index c8726707..37a295e8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.channels.actor * from the same peer simultaneously, causing session management conflicts. */ class PacketProcessor(private val myPeerID: String) { + private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { private const val TAG = "PacketProcessor" @@ -45,15 +46,8 @@ class PacketProcessor(private val myPeerID: String) { private fun getOrCreateActorForPeer(peerID: String) = processorScope.actor( capacity = Channel.UNLIMITED ) { - Log.d(TAG, "🎭 Created packet actor for peer: ${formatPeerForLog(peerID)}") - try { - for (packet in channel) { - Log.d(TAG, "📦 Processing packet type ${packet.packet.type} from ${formatPeerForLog(peerID)} (serialized)") - handleReceivedPacket(packet) - Log.d(TAG, "Completed packet type ${packet.packet.type} from ${formatPeerForLog(peerID)}") - } - } finally { - Log.d(TAG, "🎭 Packet actor for ${formatPeerForLog(peerID)} terminated") + for (packet in channel) { + handleReceivedPacket(packet) } } @@ -70,15 +64,12 @@ class PacketProcessor(private val myPeerID: String) { * SURGICAL FIX: Route to per-peer actor for serialized processing */ fun processPacket(routed: RoutedPacket) { - Log.d(TAG, "processPacket ${routed.packet.type}") val peerID = routed.peerID if (peerID == null) { Log.w(TAG, "Received packet with no peer ID, skipping") return } - - // Get or create actor for this peer val actor = actors.getOrPut(peerID) { getOrCreateActorForPeer(peerID) } @@ -111,6 +102,9 @@ class PacketProcessor(private val myPeerID: String) { override fun broadcastPacket(routed: RoutedPacket) { delegate?.relayPacket(routed) } + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + return delegate?.sendToPeer(peerID, routed) ?: false + } } } @@ -123,33 +117,43 @@ class PacketProcessor(private val myPeerID: String) { // Basic validation and security checks if (!delegate?.validatePacketSecurity(packet, peerID)!!) { - Log.d(TAG, "Packet failed security validation from ${formatPeerForLog(peerID)}") return } var validPacket = true - Log.d(TAG, "Processing packet type ${MessageType.fromValue(packet.type)} from ${formatPeerForLog(peerID)}") val messageType = MessageType.fromValue(packet.type) + // Verbose logging to debug manager (and chat via ChatViewModel observer) + try { + val mt = messageType?.name ?: packet.type.toString() + val routeDevice = routed.relayAddress + val nick = delegate?.getPeerNickname(peerID) + debugManager?.logIncomingPacket(peerID, nick, mt, routeDevice) + } catch (_: Exception) { } + // Handle public packet types (no address check needed) when (messageType) { - MessageType.ANNOUNCE -> handleAnnounce(routed) + MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed) MessageType.MESSAGE -> handleMessage(routed) + MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler + MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false MessageType.LEAVE -> handleLeave(routed) MessageType.FRAGMENT -> handleFragment(routed) + MessageType.REQUEST_SYNC -> handleRequestSync(routed) else -> { // Handle private packet types (address check required) if (packetRelayManager.isPacketAddressedToMe(packet)) { when (messageType) { - MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed) - MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) + MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) + MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) + MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false Log.w(TAG, "Unknown message type: ${packet.type}") } } } else { - Log.d(TAG, "Private packet type ${messageType} not addressed to us (from: ${formatPeerForLog(peerID)} to ${packet.recipientID?.let { it.joinToString("") { b -> "%02x".format(b) } }}), skipping") + // Not addressed to us; only relay handling below applies } } } @@ -166,36 +170,29 @@ class PacketProcessor(private val myPeerID: String) { /** * Handle Noise handshake message - SIMPLIFIED iOS-compatible version */ - private suspend fun handleNoiseHandshake(routed: RoutedPacket) { - val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing Noise handshake from ${formatPeerForLog(peerID)}") - delegate?.handleNoiseHandshake(routed) + private suspend fun handleNoiseHandshake(routed: RoutedPacket): Boolean { + return delegate?.handleNoiseHandshake(routed) ?: false } /** * Handle Noise encrypted transport message + * Returns false when decryption fails so undecryptable packets do not prove liveness. */ - private suspend fun handleNoiseEncrypted(routed: RoutedPacket) { - val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing Noise encrypted message from ${formatPeerForLog(peerID)}") - delegate?.handleNoiseEncrypted(routed) + private suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return delegate?.handleNoiseEncrypted(routed) ?: false } /** * Handle announce message */ - private suspend fun handleAnnounce(routed: RoutedPacket) { - val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}") - delegate?.handleAnnounce(routed) + private suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + return delegate?.handleAnnounce(routed) ?: false } /** * Handle regular message */ private suspend fun handleMessage(routed: RoutedPacket) { - val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing message from ${formatPeerForLog(peerID)}") delegate?.handleMessage(routed) } @@ -203,8 +200,6 @@ class PacketProcessor(private val myPeerID: String) { * Handle leave message */ private suspend fun handleLeave(routed: RoutedPacket) { - val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing leave from ${formatPeerForLog(peerID)}") delegate?.handleLeave(routed) } @@ -212,17 +207,27 @@ class PacketProcessor(private val myPeerID: String) { * Handle message fragments */ private suspend fun handleFragment(routed: RoutedPacket) { - val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing fragment from ${formatPeerForLog(peerID)}") - val reassembledPacket = delegate?.handleFragment(routed.packet) if (reassembledPacket != null) { - Log.d(TAG, "Fragment reassembled, processing complete message") - handleReceivedPacket(RoutedPacket(reassembledPacket, routed.peerID, routed.relayAddress)) + handleReceivedPacket( + RoutedPacket( + packet = reassembledPacket, + peerID = routed.peerID, + relayAddress = routed.relayAddress, + ingressLinkID = routed.ingressLinkID + ) + ) } // Fragment relay is now handled by centralized PacketRelayManager } + + /** + * Handle REQUEST_SYNC packets (public, TTL=1) + */ + private suspend fun handleRequestSync(routed: RoutedPacket) { + delegate?.handleRequestSync(routed) + } /** * Handle delivery acknowledgment @@ -269,8 +274,6 @@ class PacketProcessor(private val myPeerID: String) { // Cancel the main scope processorScope.cancel() - - Log.d(TAG, "PacketProcessor shutdown complete") } } @@ -291,14 +294,17 @@ interface PacketProcessorDelegate { // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean - fun handleNoiseEncrypted(routed: RoutedPacket) - fun handleAnnounce(routed: RoutedPacket) + fun handleNoiseEncrypted(routed: RoutedPacket): Boolean + suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) + fun handleVoiceFrame(routed: RoutedPacket): Boolean = false fun handleLeave(routed: RoutedPacket) fun handleFragment(packet: BitchatPacket): BitchatPacket? + fun handleRequestSync(routed: RoutedPacket) // Communication fun sendAnnouncementToPeer(peerID: String) fun sendCachedMessages(peerID: String) fun relayPacket(routed: RoutedPacket) + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index 5395a153..6b5c6d16 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -1,4 +1,5 @@ package com.bitchat.android.mesh +import com.bitchat.android.protocol.MessageType import android.util.Log import com.bitchat.android.model.RoutedPacket @@ -14,11 +15,18 @@ import kotlin.random.Random * All packets that aren't specifically addressed to us get processed here. */ class PacketRelayManager(private val myPeerID: String) { + private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { private const val TAG = "PacketRelayManager" } + private fun isRelayEnabled(): Boolean = try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().packetRelayEnabled.value + } catch (_: Exception) { true } + + // Logging moved to BluetoothPacketBroadcaster per actual transmission target + // Delegate for callbacks var delegate: PacketRelayManagerDelegate? = null @@ -33,7 +41,7 @@ class PacketRelayManager(private val myPeerID: String) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Evaluating relay for packet type ${packet.type} from $peerID (TTL: ${packet.ttl})") + Log.d(TAG, "Evaluating relay for packet type ${packet.type} from ${peerID} (TTL: ${packet.ttl})") // Double-check this packet isn't addressed to us if (isPacketAddressedToMe(packet)) { @@ -54,12 +62,51 @@ class PacketRelayManager(private val myPeerID: String) { } // Decrement TTL by 1 - val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte()) + val networkSize = delegate?.getNetworkSize() ?: 1 + val decrementedTtl = (packet.ttl - 1u).toUByte() + val voiceTtl = if ( + MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME && networkSize > 6 + ) minOf(decrementedTtl, 5u.toUByte()) else decrementedTtl + val relayPacket = packet.copy(ttl = voiceTtl) Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}") + if (MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME) { + delay(Random.nextLong(8L, 26L)) + } - // Apply relay logic based on packet type - val shouldRelay = shouldRelayPacket(relayPacket, peerID) - + // Source-based routing: if route is set and includes us, try targeted next-hop forwarding + val route = relayPacket.route + if (!route.isNullOrEmpty()) { + // Check for duplicate hops to prevent routing loops + if (route.map { it.toHexString() }.toSet().size < route.size) { + Log.w(TAG, "Packet with duplicate hops dropped") + return + } + val myIdBytes = hexStringToPeerBytes(myPeerID) + val index = route.indexOfFirst { it.contentEquals(myIdBytes) } + if (index >= 0) { + val nextHopIdHex: String? = run { + val nextIndex = index + 1 + if (nextIndex < route.size) { + route[nextIndex].toHexString() + } else { + // We are the last intermediate; try final recipient as next hop + relayPacket.recipientID?.toHexString() + } + } + if (nextHopIdHex != null) { + val success = try { delegate?.sendToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false + if (success) { + Log.i(TAG, "📦 Source-route relay: ${peerID.take(8)} -> ${nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})") + return + } else { + Log.w(TAG, "Source-route next hop ${nextHopIdHex.take(8)} not directly connected; falling back to broadcast") + } + } + } + } + + // Apply relay logic based on packet type and debug switch + val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID) if (shouldRelay) { relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress)) } else { @@ -104,7 +151,7 @@ class PacketRelayManager(private val myPeerID: String) { // Small networks always relay to ensure connectivity if (networkSize <= 3) { - Log.d(TAG, "Small network ($networkSize peers), relaying") + Log.d(TAG, "Small network (${networkSize} peers), relaying") return true } @@ -118,47 +165,11 @@ class PacketRelayManager(private val myPeerID: String) { } val shouldRelay = Random.nextDouble() < relayProb - Log.d(TAG, "Network size: $networkSize, Relay probability: $relayProb, Decision: $shouldRelay") + Log.d(TAG, "Network size: ${networkSize}, Relay probability: ${relayProb}, Decision: ${shouldRelay}") return shouldRelay } - /** - * Relay message with adaptive probability and timing (same as iOS) - * Moved from MessageHandler.kt - */ - suspend fun relayMessage(routed: RoutedPacket) { - val packet = routed.packet - - if (packet.ttl == 0u.toUByte()) { - Log.d(TAG, "TTL expired, not relaying message") - return - } - - val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte()) - - // Check network size and apply adaptive relay probability - val networkSize = delegate?.getNetworkSize() ?: 1 - val relayProb = when { - networkSize <= 10 -> 1.0 - networkSize <= 30 -> 0.85 - networkSize <= 50 -> 0.7 - networkSize <= 100 -> 0.55 - else -> 0.4 - } - - val shouldRelay = relayPacket.ttl >= 4u || networkSize <= 3 || Random.nextDouble() < relayProb - - if (shouldRelay) { - val delay = Random.nextLong(50, 500) // Random delay like iOS - Log.d(TAG, "Relaying message after ${delay}ms delay") - delay(delay) - relayPacket(routed.copy(packet = relayPacket)) - } else { - Log.d(TAG, "Relay decision: NOT relaying message (network size: $networkSize, prob: $relayProb)") - } - } - /** * Actually broadcast the packet for relay */ @@ -174,7 +185,7 @@ class PacketRelayManager(private val myPeerID: String) { return buildString { appendLine("=== Packet Relay Manager Debug Info ===") appendLine("Relay Scope Active: ${relayScope.isActive}") - appendLine("My Peer ID: $myPeerID") + appendLine("My Peer ID: ${myPeerID}") appendLine("Network Size: ${delegate?.getNetworkSize() ?: "unknown"}") } } @@ -198,4 +209,17 @@ interface PacketRelayManagerDelegate { // Packet operations fun broadcastPacket(routed: RoutedPacket) + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean +} + +private fun hexStringToPeerBytes(hex: String): ByteArray { + val result = ByteArray(8) + var idx = 0 + var out = 0 + while (idx + 1 < hex.length && out < 8) { + val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0 + result[out++] = b + idx += 2 + } + return result } diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 26a0ef87..525487e5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -1,6 +1,8 @@ package com.bitchat.android.mesh import android.util.Log +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList @@ -13,10 +15,15 @@ data class PeerInfo( val id: String, var nickname: String, var isConnected: Boolean, + var isDirectConnection: Boolean, var noisePublicKey: ByteArray?, var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification var isVerifiedNickname: Boolean, // NEW: Verification status flag - var lastSeen: Long // Using Long instead of Date for simplicity + var lastSeen: Long, // Using Long instead of Date for simplicity + var capabilities: PeerCapabilities? = null, // null means a signed old-client announce omitted TLV 0x05 + var hasVerifiedAnnouncement: Boolean = false, + /** Noise key that the preserved capability state was actually signed alongside. */ + var verifiedAnnouncementNoisePublicKey: ByteArray? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -27,6 +34,7 @@ data class PeerInfo( if (id != other.id) return false if (nickname != other.nickname) return false if (isConnected != other.isConnected) return false + if (isDirectConnection != other.isDirectConnection) return false if (noisePublicKey != null) { if (other.noisePublicKey == null) return false if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false @@ -37,6 +45,14 @@ data class PeerInfo( } else if (other.signingPublicKey != null) return false if (isVerifiedNickname != other.isVerifiedNickname) return false if (lastSeen != other.lastSeen) return false + if (capabilities != other.capabilities) return false + if (hasVerifiedAnnouncement != other.hasVerifiedAnnouncement) return false + val thisVerifiedAnnouncementKey = verifiedAnnouncementNoisePublicKey + val otherVerifiedAnnouncementKey = other.verifiedAnnouncementNoisePublicKey + if (thisVerifiedAnnouncementKey != null) { + if (otherVerifiedAnnouncementKey == null) return false + if (!thisVerifiedAnnouncementKey.contentEquals(otherVerifiedAnnouncementKey)) return false + } else if (otherVerifiedAnnouncementKey != null) return false return true } @@ -45,10 +61,14 @@ data class PeerInfo( var result = id.hashCode() result = 31 * result + nickname.hashCode() result = 31 * result + isConnected.hashCode() + result = 31 * result + isDirectConnection.hashCode() result = 31 * result + (noisePublicKey?.contentHashCode() ?: 0) result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0) result = 31 * result + isVerifiedNickname.hashCode() result = 31 * result + lastSeen.hashCode() + result = 31 * result + (capabilities?.hashCode() ?: 0) + result = 31 * result + hasVerifiedAnnouncement.hashCode() + result = 31 * result + (verifiedAnnouncementNoisePublicKey?.contentHashCode() ?: 0) return result } } @@ -64,9 +84,10 @@ class PeerManager { companion object { private const val TAG = "PeerManager" - private const val STALE_PEER_TIMEOUT = 180000L // 3 minutes (same as iOS) - private const val CLEANUP_INTERVAL = 60000L // 1 minute } + + // Centralized timeout from AppConstants + private val stalePeerTimeoutMs: Long = com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS // Peer tracking data - enhanced with verification status private val peers = ConcurrentHashMap() // peerID -> PeerInfo @@ -74,11 +95,7 @@ class PeerManager { private val announcedPeers = CopyOnWriteArrayList() private val announcedToPeers = CopyOnWriteArrayList() - // Legacy support for existing code - @Deprecated("Use PeerInfo structure instead") - private val peerNicknames = ConcurrentHashMap() - @Deprecated("Use PeerInfo structure instead") - private val activePeers = ConcurrentHashMap() // peerID -> lastSeen timestamp + // Legacy fields removed: use PeerInfo map exclusively // Centralized fingerprint management private val fingerprintManager = PeerFingerprintManager.getInstance() @@ -86,6 +103,9 @@ class PeerManager { // Delegate for callbacks var delegate: PeerManagerDelegate? = null + // Callback to check if a peer is directly connected (injected by BluetoothMeshService) + var isPeerDirectlyConnected: ((String) -> Boolean)? = null + // Coroutines private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -105,49 +125,163 @@ class PeerManager { noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean + ): Boolean { + val existing = peers[peerID] + return updatePeerInfoInternal( + peerID = peerID, + nickname = nickname, + noisePublicKey = noisePublicKey, + signingPublicKey = signingPublicKey, + isVerified = isVerified, + capabilities = existing?.capabilities, + hasVerifiedAnnouncement = existing?.hasVerifiedAnnouncement == true, + verifiedAnnouncementNoisePublicKey = existing?.verifiedAnnouncementNoisePublicKey + ) + } + + /** + * Apply the exact capability state from a signature-verified announce. + * A null value is meaningful: the peer signed an old-format announce that + * omitted TLV 0x05. Normal peer refreshes use [updatePeerInfo] and retain + * the last signed capability state instead of accidentally erasing it. + */ + fun updatePeerInfoFromVerifiedAnnouncement( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: PeerCapabilities? + ): Boolean = updatePeerInfoInternal( + peerID = peerID, + nickname = nickname, + noisePublicKey = noisePublicKey, + signingPublicKey = signingPublicKey, + isVerified = isVerified, + capabilities = capabilities, + hasVerifiedAnnouncement = true, + verifiedAnnouncementNoisePublicKey = noisePublicKey.copyOf() + ) + + /** Replace capability/Ed identity from Noise 0x21 in one peer-map mutation. */ + @Synchronized + fun applyAuthenticatedPeerState( + peerID: String, + authenticatedNoisePublicKey: ByteArray, + state: AuthenticatedPeerState + ) { + val existing = peers[peerID] + val announcementMatchesAuthenticatedState = existing?.hasVerifiedAnnouncement == true && + existing.verifiedAnnouncementNoisePublicKey?.contentEquals(authenticatedNoisePublicKey) == true && + existing.signingPublicKey?.contentEquals(state.signingPublicKey) == true + val replacement = PeerInfo( + id = peerID, + // A copied-static preannouncement cannot retain its attacker-chosen display name once + // authenticated peer state proves a different Ed key. + nickname = existing?.nickname?.takeIf { announcementMatchesAuthenticatedState } ?: peerID, + isConnected = true, + isDirectConnection = existing?.isDirectConnection ?: false, + noisePublicKey = authenticatedNoisePublicKey.copyOf(), + signingPublicKey = state.signingPublicKey.copyOf(), + isVerifiedNickname = existing?.isVerifiedNickname == true && announcementMatchesAuthenticatedState, + lastSeen = System.currentTimeMillis(), + capabilities = state.capabilities, + hasVerifiedAnnouncement = announcementMatchesAuthenticatedState, + verifiedAnnouncementNoisePublicKey = authenticatedNoisePublicKey.copyOf() + .takeIf { announcementMatchesAuthenticatedState } + ) + peers[peerID] = replacement + if (existing == null || existing != replacement) notifyPeerListUpdate() + } + + private fun updatePeerInfoInternal( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: PeerCapabilities?, + hasVerifiedAnnouncement: Boolean, + verifiedAnnouncementNoisePublicKey: ByteArray? ): Boolean { if (peerID == "unknown") return false + + fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean { + if (a == null && b == null) return true + if (a == null || b == null) return false + return a.contentEquals(b) + } val now = System.currentTimeMillis() val existingPeer = peers[peerID] val isNewPeer = existingPeer == null + val wasVerified = existingPeer?.isVerifiedNickname == true + val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname + val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey) + val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey) + val connectedChanged = existingPeer != null && existingPeer.isConnected != true + val capabilitiesChanged = existingPeer != null && existingPeer.capabilities != capabilities + val announcementStateChanged = existingPeer != null && + existingPeer.hasVerifiedAnnouncement != hasVerifiedAnnouncement // Update or create peer info val peerInfo = PeerInfo( id = peerID, nickname = nickname, isConnected = true, + isDirectConnection = existingPeer?.isDirectConnection ?: false, noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, isVerifiedNickname = isVerified, - lastSeen = now + lastSeen = now, + capabilities = capabilities, + hasVerifiedAnnouncement = hasVerifiedAnnouncement, + verifiedAnnouncementNoisePublicKey = verifiedAnnouncementNoisePublicKey?.copyOf() ) peers[peerID] = peerInfo - // Update legacy structures for compatibility - peerNicknames[peerID] = nickname - activePeers[peerID] = now + // Update derived state only + // No legacy maps; peers map is the single source of truth + // Maintain announcedPeers for first-time announce semantics + val shouldNotify = when { + isNewPeer && isVerified -> true + wasVerified != isVerified -> true + nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged || + capabilitiesChanged || announcementStateChanged -> true + else -> false + } + if (isNewPeer && isVerified) { announcedPeers.add(peerID) - notifyPeerListUpdate() Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)") - return true } else if (isVerified) { Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)") } else { Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)") } + + if (shouldNotify) { + notifyPeerListUpdate() + } - return false + return isNewPeer && isVerified } /** - * Get peer info + * Get peer info with dynamic direct connection status */ fun getPeerInfo(peerID: String): PeerInfo? { - return peers[peerID] + return peers[peerID]?.let { info -> + // Dynamically check direct connection status from ConnectionManager + val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false + if (info.isDirectConnection != isDirect) { + info.copy(isDirectConnection = isDirect) + } else { + info + } + } } /** @@ -158,10 +292,13 @@ class PeerManager { } /** - * Get all verified peers + * Get all verified peers with dynamic direct connection status */ fun getVerifiedPeers(): Map { - return peers.filterValues { it.isVerifiedNickname } + return peers.filterValues { it.isVerifiedNickname }.mapValues { (_, info) -> + val isDirect = isPeerDirectlyConnected?.invoke(info.id) ?: false + if (info.isDirectConnection != isDirect) info.copy(isDirectConnection = isDirect) else info + } } // MARK: - Legacy Methods (maintained for compatibility) @@ -171,8 +308,6 @@ class PeerManager { */ fun updatePeerLastSeen(peerID: String) { if (peerID != "unknown") { - activePeers[peerID] = System.currentTimeMillis() - // Also update PeerInfo if it exists peers[peerID]?.let { info -> peers[peerID] = info.copy(lastSeen = System.currentTimeMillis()) } @@ -181,16 +316,17 @@ class PeerManager { /** * Add or update peer with nickname + * Maintained for compatibility. Uses peers map exclusively now. */ fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { if (peerID == "unknown") return false // Clean up stale peer IDs with the same nickname (exact same logic as iOS) + val now = System.currentTimeMillis() val stalePeerIDs = mutableListOf() - peerNicknames.forEach { (existingPeerID, existingNickname) -> - if (existingNickname == nickname && existingPeerID != peerID) { - val lastSeen = activePeers[existingPeerID] ?: 0 - val wasRecentlySeen = (System.currentTimeMillis() - lastSeen) < 10000 + peers.forEach { (existingPeerID, info) -> + if (info.nickname == nickname && existingPeerID != peerID) { + val wasRecentlySeen = (now - info.lastSeen) < 10000 if (!wasRecentlySeen) { stalePeerIDs.add(existingPeerID) } @@ -199,15 +335,28 @@ class PeerManager { // Remove stale peer IDs stalePeerIDs.forEach { stalePeerID -> - removePeer(stalePeerID, notifyDelegate = false) + removePeer(stalePeerID, notifyPeerList = false) } // Check if this is a new peer announcement val isFirstAnnounce = !announcedPeers.contains(peerID) // Update peer data - peerNicknames[peerID] = nickname - activePeers[peerID] = System.currentTimeMillis() + val existing = peers[peerID] + if (existing != null) { + peers[peerID] = existing.copy(nickname = nickname, lastSeen = now, isConnected = true) + } else { + peers[peerID] = PeerInfo( + id = peerID, + nickname = nickname, + isConnected = true, + isDirectConnection = false, + noisePublicKey = null, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = now + ) + } // Handle first announcement if (isFirstAnnounce) { @@ -222,9 +371,8 @@ class PeerManager { /** * Remove peer */ - fun removePeer(peerID: String, notifyDelegate: Boolean = true) { - val nickname = peerNicknames.remove(peerID) - activePeers.remove(peerID) + fun removePeer(peerID: String, notifyPeerList: Boolean = true) { + val removed = peers.remove(peerID) peerRSSI.remove(peerID) announcedPeers.remove(peerID) announcedToPeers.remove(peerID) @@ -232,8 +380,13 @@ class PeerManager { // Also remove fingerprint mappings fingerprintManager.removePeer(peerID) - if (notifyDelegate && nickname != null) { - notifyPeerListUpdate() + if (removed != null) { + // Lifecycle cleanup must always run. Callers may suppress only the + // intermediate peer-list update while atomically replacing a peer. + try { delegate?.onPeerRemoved(peerID) } catch (_: Exception) {} + if (notifyPeerList) { + notifyPeerListUpdate() + } } } @@ -266,21 +419,22 @@ class PeerManager { * Check if peer is active */ fun isPeerActive(peerID: String): Boolean { - return activePeers.containsKey(peerID) + val info = peers[peerID] ?: return false + return info.isConnected } /** * Get peer nickname */ fun getPeerNickname(peerID: String): String? { - return peerNicknames[peerID] + return peers[peerID]?.nickname } /** * Get all peer nicknames */ fun getAllPeerNicknames(): Map { - return peerNicknames.toMap() + return peers.mapValues { it.value.nickname } } /** @@ -294,22 +448,24 @@ class PeerManager { * Get list of active peer IDs */ fun getActivePeerIDs(): List { - return activePeers.keys.toList().sorted() + return peers.filterValues { it.isConnected } + .keys + .toList() + .sorted() } /** * Get active peer count */ fun getActivePeerCount(): Int { - return activePeers.size + return getActivePeerIDs().size } /** * Clear all peer data */ fun clearAllPeers() { - peerNicknames.clear() - activePeers.clear() + peers.clear() peerRSSI.clear() announcedPeers.clear() announcedToPeers.clear() @@ -324,19 +480,23 @@ class PeerManager { * Get debug information */ fun getDebugInfo(addressPeerMap: Map? = null): String { + val now = System.currentTimeMillis() + val activeIds = getActivePeerIDs().toSet() return buildString { appendLine("=== Peer Manager Debug Info ===") - appendLine("Active Peers: ${activePeers.size}") - activePeers.forEach { (peerID, lastSeen) -> - val nickname = peerNicknames[peerID] ?: "Unknown" - val timeSince = (System.currentTimeMillis() - lastSeen) / 1000 - val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI" + appendLine("Active Peers: ${activeIds.size}") + peers.forEach { (peerID, storedInfo) -> + // Use dynamic direct status for debug log accuracy + val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false + val info = if (storedInfo.isDirectConnection != isDirect) storedInfo.copy(isDirectConnection = isDirect) else storedInfo - // Find device address for this peer ID + val timeSince = (now - info.lastSeen) / 1000 + val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI" val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key val addressInfo = deviceAddress?.let { " [Device: $it]" } ?: " [Device: Unknown]" - - appendLine(" - $peerID ($nickname)$addressInfo - last seen ${timeSince}s ago, RSSI: $rssi") + val status = if (activeIds.contains(peerID)) "ACTIVE" else "INACTIVE" + val direct = if (info.isDirectConnection) "DIRECT" else "ROUTED" + appendLine(" - $peerID (${info.nickname})$addressInfo - $status/$direct, last seen ${timeSince}s ago, RSSI: $rssi") } appendLine("Announced Peers: ${announcedPeers.size}") appendLine("Announced To Peers: ${announcedToPeers.size}") @@ -353,8 +513,8 @@ class PeerManager { appendLine("No device address mappings available") } else { addressPeerMap.forEach { (deviceAddress, peerID) -> - val nickname = peerNicknames[peerID] ?: "Unknown" - val isActive = activePeers.containsKey(peerID) + val nickname = peers[peerID]?.nickname ?: "Unknown" + val isActive = isPeerActive(peerID) val status = if (isActive) "ACTIVE" else "INACTIVE" appendLine(" Device: $deviceAddress -> Peer: $peerID ($nickname) [$status]") } @@ -371,6 +531,10 @@ class PeerManager { val peerList = getActivePeerIDs() delegate?.onPeerListUpdated(peerList) } + + fun refreshPeerList() { + notifyPeerListUpdate() + } /** * Start periodic cleanup of stale peers @@ -378,7 +542,7 @@ class PeerManager { private fun startPeriodicCleanup() { managerScope.launch { while (isActive) { - delay(CLEANUP_INTERVAL) + delay(com.bitchat.android.util.AppConstants.Mesh.PEER_CLEANUP_INTERVAL_MS) cleanupStalePeers() } } @@ -390,9 +554,9 @@ class PeerManager { private fun cleanupStalePeers() { val now = System.currentTimeMillis() - val peersToRemove = activePeers.entries.filter { (_, lastSeen) -> - now - lastSeen > STALE_PEER_TIMEOUT - }.map { it.key } + val peersToRemove = peers.filterValues { (now - it.lastSeen) > stalePeerTimeoutMs } + .keys + .toList() peersToRemove.forEach { peerID -> Log.d(TAG, "Removing stale peer: $peerID") @@ -495,4 +659,5 @@ class PeerManager { */ interface PeerManagerDelegate { fun onPeerListUpdated(peerIDs: List) + fun onPeerRemoved(peerID: String) } diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 5b974575..c5e98566 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -7,298 +7,267 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager +import android.os.Handler +import android.os.Looper import android.util.Log -import kotlinx.coroutines.* -import kotlin.math.max +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** - * Power-aware Bluetooth management for bitchat - * Adjusts scanning, advertising, and connection behavior based on battery state + * Process-wide source of truth for background and battery-aware scheduling. + * + * The manager deliberately exposes policy only. Consumers retain ownership of their hardware and + * network jobs, so changing profile never implies disconnecting a transport or suspending Tor. */ -class PowerManager(private val context: Context) { - +class PowerManager private constructor(context: Context) : LifecycleEventObserver { + + enum class PowerMode { + PERFORMANCE, + BALANCED, + POWER_SAVER, + ULTRA_LOW_POWER + } + + enum class BatteryBand { NORMAL, LOW, CRITICAL } + + data class BleSchedule( + val scanOnMs: Long, + val scanOffMs: Long, + val continuousScan: Boolean, + val maxConnections: Int = 8 + ) + + data class WifiAwareSchedule( + val tcpKeepAliveMs: Long, + val discoveryKeepAliveMs: Long, + val connectionMaintenanceMs: Long, + val discoveryIdleRefreshMs: Long, + val discoverySessionRefreshMinMs: Long, + val discoveryStaleMs: Long + ) + + data class NostrSchedule( + val presenceHeartbeatMinMs: Long, + val presenceHeartbeatMaxMs: Long, + val subscriptionValidationMs: Long + ) + + data class RuntimePerformanceProfile( + val mode: PowerMode, + val batteryBand: BatteryBand, + val isBackground: Boolean, + val isCharging: Boolean, + val hasDirectPeers: Boolean, + val ble: BleSchedule, + val meshAnnouncementIntervalMs: Long, + val wifiAware: WifiAwareSchedule, + val nostr: NostrSchedule + ) + companion object { private const val TAG = "PowerManager" - - // Battery thresholds - private const val CRITICAL_BATTERY = 10 - private const val LOW_BATTERY = 20 - private const val MEDIUM_BATTERY = 50 - - // Scan duty cycle periods (ms) - private const val SCAN_ON_DURATION_NORMAL = 8000L // 8 seconds on - private const val SCAN_OFF_DURATION_NORMAL = 2000L // 2 seconds off - private const val SCAN_ON_DURATION_POWER_SAVE = 2000L // 2 seconds on - private const val SCAN_OFF_DURATION_POWER_SAVE = 8000L // 8 seconds off - private const val SCAN_ON_DURATION_ULTRA_LOW = 1000L // 1 second on - private const val SCAN_OFF_DURATION_ULTRA_LOW = 10000L // 10 seconds off - - // Connection limits - private const val MAX_CONNECTIONS_NORMAL = 8 - private const val MAX_CONNECTIONS_POWER_SAVE = 4 - private const val MAX_CONNECTIONS_ULTRA_LOW = 2 + + @Volatile + private var INSTANCE: PowerManager? = null + + fun getInstance(context: Context): PowerManager = + INSTANCE ?: synchronized(this) { + INSTANCE ?: PowerManager(context.applicationContext).also { INSTANCE = it } + } } - - enum class PowerMode { - PERFORMANCE, // Full power, no restrictions - BALANCED, // Moderate power saving - POWER_SAVER, // Aggressive power saving - ULTRA_LOW_POWER // Minimal operations only - } - - private var currentMode = PowerMode.BALANCED - private var isCharging = false - private var batteryLevel = 100 - private var isAppInBackground = false - - private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var dutyCycleJob: Job? = null - - var delegate: PowerManagerDelegate? = null - - // Battery monitoring + + private val appContext = context.applicationContext + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @Volatile private var batteryLevel = 100 + @Volatile private var isCharging = false + @Volatile private var isAppInBackground = true + @Volatile private var hasDirectPeers = false + @Volatile private var shutdown = false + + private val _profile = MutableStateFlow( + PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + ) + val profile: StateFlow = _profile.asStateFlow() + private val batteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { Intent.ACTION_BATTERY_CHANGED -> { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) - if (level != -1 && scale != -1) { - batteryLevel = (level * 100) / scale - } - + if (level >= 0 && scale > 0) batteryLevel = (level * 100) / scale + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL - - updatePowerMode() + status == BatteryManager.BATTERY_STATUS_FULL + refreshProfile() } Intent.ACTION_POWER_CONNECTED -> { isCharging = true - updatePowerMode() + refreshProfile() } Intent.ACTION_POWER_DISCONNECTED -> { isCharging = false - updatePowerMode() + refreshProfile() } } } } - + init { registerBatteryReceiver() - updatePowerMode() - } - - fun start() { - Log.i(TAG, "Starting power management") - startDutyCycle() - } - - fun stop() { - Log.i(TAG, "Stopping power management") - powerScope.cancel() - unregisterBatteryReceiver() - } - - fun setAppBackgroundState(inBackground: Boolean) { - if (isAppInBackground != inBackground) { - isAppInBackground = inBackground - Log.d(TAG, "App background state changed: $inBackground") - updatePowerMode() + Handler(Looper.getMainLooper()).post { + try { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + isAppInBackground = !ProcessLifecycleOwner.get().lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + refreshProfile() + } catch (e: Exception) { + Log.e(TAG, "Failed to register process lifecycle observer: ${e.message}") + } + } + scope.launch { + AppStateStore.directPeers + .collect { peers -> + hasDirectPeers = peers.isNotEmpty() + refreshProfile() + } } } - + + /** Kept for existing callers; initialization is eager and idempotent. */ + fun start() = Unit + /** - * Get scan settings optimized for current power mode + * Process-level teardown for explicit full application shutdown only. + * Transport stop/restart paths must not call this. */ + fun shutdown() { + if (shutdown) return + shutdown = true + scope.cancel() + try { appContext.unregisterReceiver(batteryReceiver) } catch (_: Exception) { } + Handler(Looper.getMainLooper()).post { + try { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } catch (_: Exception) { } + } + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_START -> { + isAppInBackground = false + refreshProfile() + } + Lifecycle.Event.ON_STOP -> { + isAppInBackground = true + refreshProfile() + } + else -> Unit + } + } + fun getScanSettings(): ScanSettings { + val current = profile.value val builder = ScanSettings.Builder() .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - when (currentMode) { + when (current.mode) { PowerMode.PERFORMANCE -> builder .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) - PowerMode.BALANCED -> builder .setScanMode(ScanSettings.SCAN_MODE_BALANCED) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) - - PowerMode.POWER_SAVER -> builder + PowerMode.POWER_SAVER, + PowerMode.ULTRA_LOW_POWER -> builder .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .setMatchMode(ScanSettings.MATCH_MODE_STICKY) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) - - PowerMode.ULTRA_LOW_POWER -> builder - .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) - .setMatchMode(ScanSettings.MATCH_MODE_STICKY) - .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) } - return builder.setReportDelay(0).build() } - - /** - * Get advertising settings optimized for current power mode - */ - fun getAdvertiseSettings(): AdvertiseSettings { - return when (currentMode) { - PowerMode.PERFORMANCE -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.BALANCED -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.POWER_SAVER -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW) - .setConnectable(true) - .setTimeout(0) - .build() - } + + fun getAdvertiseSettings(): AdvertiseSettings = when (profile.value.mode) { + PowerMode.PERFORMANCE -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setConnectable(true).setTimeout(0).build() + PowerMode.BALANCED -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .setConnectable(true).setTimeout(0).build() + PowerMode.POWER_SAVER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) + .setConnectable(true).setTimeout(0).build() + PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW) + .setConnectable(true).setTimeout(0).build() } - - /** - * Get maximum allowed connections for current power mode - */ - fun getMaxConnections(): Int { - return when (currentMode) { - PowerMode.PERFORMANCE -> MAX_CONNECTIONS_NORMAL - PowerMode.BALANCED -> MAX_CONNECTIONS_NORMAL - PowerMode.POWER_SAVER -> MAX_CONNECTIONS_POWER_SAVE - PowerMode.ULTRA_LOW_POWER -> MAX_CONNECTIONS_ULTRA_LOW - } + + fun getMaxConnections(): Int = profile.value.ble.maxConnections + + fun getRSSIThreshold(): Int = when (profile.value.mode) { + PowerMode.PERFORMANCE -> -95 + PowerMode.BALANCED -> -85 + PowerMode.POWER_SAVER -> -75 + PowerMode.ULTRA_LOW_POWER -> -65 } - - /** - * Get RSSI filter threshold for current power mode - */ - fun getRSSIThreshold(): Int { - return when (currentMode) { - PowerMode.PERFORMANCE -> -95 - PowerMode.BALANCED -> -85 - PowerMode.POWER_SAVER -> -75 - PowerMode.ULTRA_LOW_POWER -> -65 - } - } - - /** - * Check if duty cycling should be used - */ - fun shouldUseDutyCycle(): Boolean { - return currentMode != PowerMode.PERFORMANCE - } - - /** - * Get current power mode information - */ - fun getPowerInfo(): String { - return buildString { + + fun shouldUseDutyCycle(): Boolean = !profile.value.ble.continuousScan + + fun getPowerInfo(): String = profile.value.let { current -> + buildString { appendLine("=== Power Manager Status ===") - appendLine("Current Mode: $currentMode") - appendLine("Battery Level: $batteryLevel%") - appendLine("Is Charging: $isCharging") - appendLine("App In Background: $isAppInBackground") - appendLine("Max Connections: ${getMaxConnections()}") - appendLine("RSSI Threshold: ${getRSSIThreshold()} dBm") - appendLine("Use Duty Cycle: ${shouldUseDutyCycle()}") + appendLine("Current Mode: ${current.mode}") + appendLine("Battery Band: ${current.batteryBand} ($batteryLevel%)") + appendLine("Is Charging: ${current.isCharging}") + appendLine("App In Background: ${current.isBackground}") + appendLine("Has Direct Peers: ${current.hasDirectPeers}") + appendLine("BLE Scan: ${current.ble.scanOnMs}ms ON / ${current.ble.scanOffMs}ms OFF") + appendLine("Max Connections: ${current.ble.maxConnections}") } } - - private fun updatePowerMode() { - val newMode = when { - // Always use performance mode when charging (unless in background too long) - isCharging && !isAppInBackground -> PowerMode.PERFORMANCE - - // Critical battery - use ultra low power - batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER - - // Low battery - use power saver - batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER - - // Background app with medium battery - use power saver - isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER - - // Background app with good battery - use balanced - isAppInBackground -> PowerMode.BALANCED - - // Foreground with good battery - use balanced - else -> PowerMode.BALANCED - } - - if (newMode != currentMode) { - val oldMode = currentMode - currentMode = newMode - Log.i(TAG, "Power mode changed: $oldMode → $newMode (battery: $batteryLevel%, charging: $isCharging, background: $isAppInBackground)") - - delegate?.onPowerModeChanged(currentMode) - - // Restart duty cycle with new parameters - if (shouldUseDutyCycle()) { - startDutyCycle() - } else { - stopDutyCycle() - } + + private fun refreshProfile() { + if (shutdown) return + val next = PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + if (_profile.value != next) { + Log.i( + TAG, + "Profile changed: mode=${next.mode}, battery=${next.batteryBand}, " + + "background=${next.isBackground}, directPeers=${next.hasDirectPeers}" + ) + _profile.value = next } } - - private fun startDutyCycle() { - stopDutyCycle() - - if (!shouldUseDutyCycle()) { - delegate?.onScanStateChanged(true) // Always scan in performance mode - return - } - - val (onDuration, offDuration) = when (currentMode) { - PowerMode.BALANCED -> SCAN_ON_DURATION_NORMAL to SCAN_OFF_DURATION_NORMAL - PowerMode.POWER_SAVER -> SCAN_ON_DURATION_POWER_SAVE to SCAN_OFF_DURATION_POWER_SAVE - PowerMode.ULTRA_LOW_POWER -> SCAN_ON_DURATION_ULTRA_LOW to SCAN_OFF_DURATION_ULTRA_LOW - PowerMode.PERFORMANCE -> return // No duty cycle - } - - dutyCycleJob = powerScope.launch { - while (isActive && shouldUseDutyCycle()) { - // Scan ON period - Log.d(TAG, "Duty cycle: Scan ON for ${onDuration}ms") - delegate?.onScanStateChanged(true) - delay(onDuration) - - // Scan OFF period (keep advertising active) - if (isActive && shouldUseDutyCycle()) { - Log.d(TAG, "Duty cycle: Scan OFF for ${offDuration}ms") - delegate?.onScanStateChanged(false) - delay(offDuration) - } - } - } - - Log.i(TAG, "Started duty cycle: ${onDuration}ms ON, ${offDuration}ms OFF") - } - - private fun stopDutyCycle() { - dutyCycleJob?.cancel() - dutyCycleJob = null - } - + private fun registerBatteryReceiver() { try { val filter = IntentFilter().apply { @@ -306,25 +275,133 @@ class PowerManager(private val context: Context) { addAction(Intent.ACTION_POWER_CONNECTED) addAction(Intent.ACTION_POWER_DISCONNECTED) } - context.registerReceiver(batteryReceiver, filter) + appContext.registerReceiver(batteryReceiver, filter) } catch (e: Exception) { Log.w(TAG, "Failed to register battery receiver: ${e.message}") } } - - private fun unregisterBatteryReceiver() { - try { - context.unregisterReceiver(batteryReceiver) - } catch (e: Exception) { - Log.w(TAG, "Failed to unregister battery receiver: ${e.message}") - } - } } /** - * Delegate interface for power management callbacks + * Pure policy resolver so cadence and battery-boundary behavior can be tested without Android. */ -interface PowerManagerDelegate { - fun onPowerModeChanged(newMode: PowerManager.PowerMode) - fun onScanStateChanged(shouldScan: Boolean) +internal object PowerProfileResolver { + fun resolve( + batteryLevel: Int, + isCharging: Boolean, + isBackground: Boolean, + hasDirectPeers: Boolean + ): PowerManager.RuntimePerformanceProfile { + val batteryBand = when { + batteryLevel <= AppConstants.Power.CRITICAL_BATTERY_PERCENT -> + PowerManager.BatteryBand.CRITICAL + batteryLevel <= AppConstants.Power.LOW_BATTERY_PERCENT -> + PowerManager.BatteryBand.LOW + else -> PowerManager.BatteryBand.NORMAL + } + + val mode = when { + isBackground && batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + isBackground -> PowerManager.PowerMode.POWER_SAVER + isCharging -> PowerManager.PowerMode.PERFORMANCE + batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.PowerMode.POWER_SAVER + else -> PowerManager.PowerMode.BALANCED + } + + val ble = when { + isBackground && hasDirectPeers -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 29_000L, + continuousScan = false + ) + isBackground -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 59_000L, + continuousScan = false + ) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.BleSchedule(8_000L, 2_000L, false) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.BleSchedule(2_000L, 28_000L, false) + else -> + PowerManager.BleSchedule(1_000L, 29_000L, false) + } + + val announcementInterval = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> 60_000L + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> 120_000L + isBackground -> 300_000L + mode == PowerManager.PowerMode.POWER_SAVER -> 60_000L + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> 120_000L + else -> 30_000L + } + + val wifi = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + wifi(10, 60, 60, 5, 5, 10) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + wifi(20, 120, 120, 10, 10, 15) + isBackground -> + wifi(30, 180, 180, 15, 15, 20) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.WifiAwareSchedule(2_000L, 20_000L, 15_000L, 120_000L, 90_000L, 300_000L) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.WifiAwareSchedule(5_000L, 30_000L, 30_000L, 180_000L, 120_000L, 300_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.WifiAwareSchedule(10_000L, 60_000L, 60_000L, 300_000L, 180_000L, 600_000L) + else -> + PowerManager.WifiAwareSchedule(15_000L, 90_000L, 90_000L, 600_000L, 300_000L, 900_000L) + } + + val nostr = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + PowerManager.NostrSchedule(300_000L, 330_000L, 300_000L) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.NostrSchedule(600_000L, 660_000L, 600_000L) + isBackground -> + PowerManager.NostrSchedule(900_000L, 990_000L, 900_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.NostrSchedule(120_000L, 132_000L, 60_000L) + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> + PowerManager.NostrSchedule(300_000L, 330_000L, 120_000L) + else -> + PowerManager.NostrSchedule(40_000L, 80_000L, 30_000L) + } + + return PowerManager.RuntimePerformanceProfile( + mode = mode, + batteryBand = batteryBand, + isBackground = isBackground, + isCharging = isCharging, + hasDirectPeers = hasDirectPeers, + ble = ble, + meshAnnouncementIntervalMs = announcementInterval, + wifiAware = wifi, + nostr = nostr + ) + } + + private fun wifi( + tcpSeconds: Long, + discoverySeconds: Long, + maintenanceSeconds: Long, + idleMinutes: Long, + minRefreshMinutes: Long, + staleMinutes: Long + ) = PowerManager.WifiAwareSchedule( + tcpKeepAliveMs = tcpSeconds * 1_000L, + discoveryKeepAliveMs = discoverySeconds * 1_000L, + connectionMaintenanceMs = maintenanceSeconds * 1_000L, + discoveryIdleRefreshMs = idleMinutes * 60_000L, + discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L, + discoveryStaleMs = staleMinutes * 60_000L + ) } diff --git a/app/src/main/java/com/bitchat/android/mesh/PrivateMediaSecurity.kt b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaSecurity.kt new file mode 100644 index 00000000..a17d2cad --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaSecurity.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.noise.AuthenticatedNoiseSession + +internal sealed interface PrivateMediaPolicyDecision { + data class Encrypted( + val authenticatedSession: AuthenticatedNoiseSession + ) : PrivateMediaPolicyDecision + data object RequiresLegacyConsent : PrivateMediaPolicyDecision + data object NeedsHandshake : PrivateMediaPolicyDecision + data object AwaitingPeerState : PrivateMediaPolicyDecision + data class Blocked(val reason: String) : PrivateMediaPolicyDecision +} + +/** + * Binds an advertised private-media capability to a live Noise remote-static + * key and persists an HSTS-style pin by that authenticated key's SHA-256 + * fingerprint. Announcements alone can never create a pin. + */ +internal class PrivateMediaSecurityController( + private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?, + private val peerStateStatusProvider: ( + String, + AuthenticatedNoiseSession + ) -> AuthenticatedPeerStateStatus, + private val isPrivateMediaPinned: (String) -> Boolean +) { + fun sendPolicy(peerID: String): PrivateMediaPolicyDecision { + val authenticatedSession = authenticatedSessionProvider(peerID) + ?.takeIf { it.remoteStaticKey.size == 32 && it.sessionToken.size == 32 } + ?: return PrivateMediaPolicyDecision.NeedsHandshake + + return when (val status = peerStateStatusProvider(peerID, authenticatedSession)) { + AuthenticatedPeerStateStatus.Awaiting -> PrivateMediaPolicyDecision.AwaitingPeerState + is AuthenticatedPeerStateStatus.Proven -> { + if (status.state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) { + PrivateMediaPolicyDecision.Encrypted(authenticatedSession) + } else if (isPrivateMediaPinned(peerID)) { + PrivateMediaPolicyDecision.Blocked( + "Encrypted private media was previously pinned, but this session proved no support; send blocked" + ) + } else { + PrivateMediaPolicyDecision.RequiresLegacyConsent + } + } + AuthenticatedPeerStateStatus.Missing -> + // A live crypto session can become visible just before its authenticated callback + // installs the coordinator generation. Never treat that gap as a watchdog timeout. + PrivateMediaPolicyDecision.AwaitingPeerState + AuthenticatedPeerStateStatus.TimedOut -> { + if (isPrivateMediaPinned(peerID)) { + PrivateMediaPolicyDecision.Blocked( + "Encrypted private media was previously pinned, but this session did not provide authenticated peer state" + ) + } else { + // A Noise-capable old client that does not know 0x21 may use explicit one-shot + // legacy consent after the five-second generation watchdog. + PrivateMediaPolicyDecision.RequiresLegacyConsent + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt new file mode 100644 index 00000000..65f21acb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt @@ -0,0 +1,215 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import java.util.concurrent.atomic.AtomicBoolean + +enum class PrivateMediaWireMode { + ENCRYPTED_NOISE_0X20, + SIGNED_DIRECTED_RAW_0X22 +} + +class PreparedPrivateMediaTransfer internal constructor( + val transferId: String, + val wireMode: PrivateMediaWireMode, + private val commitAction: () -> Boolean +) { + private val committed = AtomicBoolean(false) + + /** A prepared transfer is single-use, including after a failed commit. */ + fun commit(): Boolean { + if (!committed.compareAndSet(false, true)) return false + return commitAction() + } +} + +sealed interface PrivateMediaPreparation { + data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation + data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation + data object NeedsHandshake : PrivateMediaPreparation + data object AwaitingPeerState : PrivateMediaPreparation + data class Rejected(val reason: String) : PrivateMediaPreparation +} + +internal data class BuiltPrivateMediaTransfer( + val packet: BitchatPacket, + val fragments: List, + val wireMode: PrivateMediaWireMode +) + +internal sealed interface PrivateMediaBuildOutcome { + data class Ready(val built: BuiltPrivateMediaTransfer) : PrivateMediaBuildOutcome + data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome + data object NeedsHandshake : PrivateMediaBuildOutcome + data object AwaitingPeerState : PrivateMediaBuildOutcome + data class Rejected(val reason: String) : PrivateMediaBuildOutcome +} + +internal sealed interface PrivateMediaEncryptionResult { + data class Success(val ciphertext: ByteArray) : PrivateMediaEncryptionResult + data object GenerationChanged : PrivateMediaEncryptionResult + data object Failed : PrivateMediaEncryptionResult +} + +/** Builds, routes, signs, and fragments exactly once before UI local echo. */ +internal class PrivateMediaTransferPreparer( + private val senderID: ByteArray, + private val ttl: UByte, + private val policyProvider: (String) -> PrivateMediaPolicyDecision, + private val encrypt: ( + ByteArray, + String, + AuthenticatedNoiseSession + ) -> PrivateMediaEncryptionResult, + private val finalizeRoutedAndSigned: (BitchatPacket) -> BitchatPacket?, + private val fragment: (BitchatPacket, Int) -> List, + private val now: () -> ULong = { System.currentTimeMillis().toULong() } +) { + fun prepare( + recipientPeerID: String, + recipientID: ByteArray, + file: BitchatFilePacket, + allowLegacyFallback: Boolean + ): PrivateMediaBuildOutcome = prepare( + recipientPeerID, + recipientID, + file, + allowLegacyFallback, + generationRetriesRemaining = 1 + ) + + private fun prepare( + recipientPeerID: String, + recipientID: ByteArray, + file: BitchatFilePacket, + allowLegacyFallback: Boolean, + generationRetriesRemaining: Int + ): PrivateMediaBuildOutcome { + val maxPrivateFragments = + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID + val absolutePayloadUpperBound = + maxPrivateFragments.toLong() * + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE.toLong() + // The file content alone cannot exceed the total bytes carried by every + // possible fragment. Reject before TLV encoding, encryption, signing, + // and packet serialization make additional full-size copies. + if (file.content.size.toLong() > absolutePayloadUpperBound) { + return PrivateMediaBuildOutcome.Rejected( + "File exceeds the private-media v1 limit of 256 final mesh fragments" + ) + } + + val policy = policyProvider(recipientPeerID) + val mode = when (policy) { + is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + PrivateMediaPolicyDecision.RequiresLegacyConsent -> { + if (!allowLegacyFallback) { + return PrivateMediaBuildOutcome.RequiresLegacyConsent( + "This older client cannot receive encrypted private media. " + + "Sending this one file will expose its contents to mesh relays, " + + "although the directed packet will still be signed." + ) + } + PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22 + } + PrivateMediaPolicyDecision.NeedsHandshake -> + return PrivateMediaBuildOutcome.NeedsHandshake + PrivateMediaPolicyDecision.AwaitingPeerState -> + return PrivateMediaBuildOutcome.AwaitingPeerState + is PrivateMediaPolicyDecision.Blocked -> + return PrivateMediaBuildOutcome.Rejected(policy.reason) + } + + val filePayload = file.encode() + ?: return PrivateMediaBuildOutcome.Rejected("Failed to encode private media") + + val packet = when (mode) { + PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> { + val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode() + val encryption = try { + encrypt( + plaintext, + recipientPeerID, + (policy as PrivateMediaPolicyDecision.Encrypted).authenticatedSession + ) + } catch (_: Exception) { + PrivateMediaEncryptionResult.Failed + } + val ciphertext = when (encryption) { + is PrivateMediaEncryptionResult.Success -> encryption.ciphertext + PrivateMediaEncryptionResult.GenerationChanged -> { + if (generationRetriesRemaining > 0) { + return prepare( + recipientPeerID, + recipientID, + file, + allowLegacyFallback, + generationRetriesRemaining - 1 + ) + } + return PrivateMediaBuildOutcome.AwaitingPeerState + } + PrivateMediaEncryptionResult.Failed -> + return PrivateMediaBuildOutcome.Rejected( + "The authenticated Noise session could not encrypt this file" + ) + } + BitchatPacket( + version = if (ciphertext.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = senderID.copyOf(), + recipientID = recipientID.copyOf(), + timestamp = now(), + payload = ciphertext, + signature = null, + ttl = ttl + ) + } + + PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22 -> BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = senderID.copyOf(), + recipientID = recipientID.copyOf(), + timestamp = now(), + payload = filePayload, + signature = null, + ttl = ttl + ) + } + + val finalized = finalizeRoutedAndSigned(packet) + ?: return PrivateMediaBuildOutcome.Rejected( + if (mode == PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22) { + "Could not sign the legacy private-media packet; nothing was sent" + } else { + "Could not sign the encrypted private-media packet; nothing was sent" + } + ) + if (finalized.signature?.size != 64) { + return PrivateMediaBuildOutcome.Rejected( + "Could not produce a valid Ed25519 private-media signature; nothing was sent" + ) + } + + val fragments = fragment(finalized, maxPrivateFragments) + if (fragments.isEmpty()) { + return PrivateMediaBuildOutcome.Rejected( + "File exceeds the private-media v1 limit of 256 final mesh fragments" + ) + } + if (fragments.size > maxPrivateFragments) { + return PrivateMediaBuildOutcome.Rejected( + "File exceeds the private-media v1 limit of 256 final mesh fragments" + ) + } + + return PrivateMediaBuildOutcome.Ready( + BuiltPrivateMediaTransfer(finalized, fragments, mode) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt new file mode 100644 index 00000000..ba81800c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.mesh + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Sends small idempotent control packets redundantly while serializing the attempts submitted + * through this sender. Android BLE only reports that a GATT write/notification was accepted; + * it does not prove that the remote application processed the packet. Reusing the exact encoded + * packet makes retries safe: the receiver's packet/replay protection drops copies it already saw. + */ +internal class RetryingControlPacketSender( + private val scope: CoroutineScope, + private val maxAttempts: Int = 3, + private val retryDelayMs: Long = 750L, + private val interSendDelayMs: Long = 75L +) { + private val sendMutex = Mutex() + private val jobsLock = Any() + private val activeJobs = mutableMapOf() + + init { + require(maxAttempts > 0) + require(retryDelayMs >= 0) + require(interSendDelayMs >= 0) + } + + /** + * Coalesces concurrent requests for the same logical packet. Once the retry window finishes, + * a later user action may enqueue the packet again; duplicate receipt processing is idempotent. + */ + fun enqueue( + key: String, + sendAttempt: suspend (attempt: Int) -> Boolean, + onComplete: (acceptedAtLeastOnce: Boolean) -> Unit = {} + ) { + val job = synchronized(jobsLock) { + if (activeJobs[key]?.isActive == true) return + + scope.launch(start = CoroutineStart.LAZY) { + var acceptedAtLeastOnce = false + var completed = false + try { + repeat(maxAttempts) { index -> + if (!isActive) return@launch + val attempt = index + 1 + sendMutex.withLock { + try { + val accepted = try { + sendAttempt(attempt) + } catch (_: Exception) { + false + } + acceptedAtLeastOnce = accepted || acceptedAtLeastOnce + } finally { + if (interSendDelayMs > 0) delay(interSendDelayMs) + } + } + if (attempt < maxAttempts && retryDelayMs > 0) { + delay(retryDelayMs) + } + } + completed = true + } finally { + synchronized(jobsLock) { + activeJobs.remove(key) + } + if (completed) { + try { onComplete(acceptedAtLeastOnce) } catch (_: Exception) { } + } + } + }.also { activeJobs[key] = it } + } + job.start() + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 59090c52..6fab9f03 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -3,8 +3,11 @@ package com.bitchat.android.mesh import android.util.Log import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.sync.PacketIdUtil import com.bitchat.android.protocol.MessageType import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoiseDecryptionResult import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* @@ -19,16 +22,18 @@ class SecurityManager(private val encryptionService: EncryptionService, private companion object { private const val TAG = "SecurityManager" - private const val MESSAGE_TIMEOUT = 300000L // 5 minutes (same as iOS) - private const val CLEANUP_INTERVAL = 300000L // 5 minutes - private const val MAX_PROCESSED_MESSAGES = 10000 - private const val MAX_PROCESSED_KEY_EXCHANGES = 1000 + private const val MESSAGE_TIMEOUT = com.bitchat.android.util.AppConstants.Security.MESSAGE_TIMEOUT_MS // 5 minutes (same as iOS) + private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes + private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES + private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES + private const val KEY_EXCHANGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS } // Security tracking private val processedMessages = Collections.synchronizedSet(mutableSetOf()) private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf()) private val messageTimestamps = Collections.synchronizedMap(mutableMapOf()) + private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf()) // Delegate for callbacks var delegate: SecurityManagerDelegate? = null @@ -41,49 +46,60 @@ class SecurityManager(private val encryptionService: EncryptionService, private } /** - * Validate packet security (timestamp, replay attacks, duplicates) + * Validate packet security (timestamp, replay attacks, duplicates, signatures) */ fun validatePacket(packet: BitchatPacket, peerID: String): Boolean { // Skip validation for our own packets if (peerID == myPeerID) { - Log.d(TAG, "Skipping validation for our own packet") - return false - } - - // TTL check - if (packet.ttl == 0u.toUByte()) { - Log.d(TAG, "Dropping packet with TTL 0") - return false - } - - // Validate packet payload - if (packet.payload.isEmpty()) { - Log.d(TAG, "Dropping packet with empty payload") return false } // Replay attack protection (same 5-minute window as iOS) val currentTime = System.currentTimeMillis() - val packetTime = packet.timestamp.toLong() - val timeDiff = kotlin.math.abs(currentTime - packetTime) - - if (timeDiff > MESSAGE_TIMEOUT) { - Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s") - return false + val messageType = MessageType.fromValue(packet.type) + + // LEAVE mutates presence immediately and cannot be safely replayed after the in-memory + // duplicate cache expires (or after an app restart). Bound it to the same five-minute + // security window used for duplicate retention while tolerating symmetric clock skew. + if (messageType == MessageType.LEAVE) { + val now = currentTime.coerceAtLeast(0).toULong() + val clockSkew = if (packet.timestamp >= now) { + packet.timestamp - now + } else { + now - packet.timestamp + } + if (clockSkew > MESSAGE_TIMEOUT.toULong()) { + Log.w(TAG, "Dropping stale or future-dated LEAVE from $peerID") + return false + } } - + // Duplicate detection val messageID = generateMessageID(packet, peerID) + if (processedMessages.contains(messageID)) { - Log.d(TAG, "Dropping duplicate packet: $messageID") + // Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL) + // This ensures we observe the same peer on a new direct transport connection, + // while still dropping looped/relayed duplicates. + val isFreshAnnounce = messageType == MessageType.ANNOUNCE && + packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + + if (!isFreshAnnounce) { + return false + } + } + + // Enforce mandatory signature verification + if (!verifyPacketSignature(packet, peerID)) { return false } - - // Add to processed messages + + // Record only authenticated packets. Recording an attacker-controlled + // invalid packet first would let it poison duplicate detection for a + // later legitimate packet with the same timestamp and payload. processedMessages.add(messageID) messageTimestamps[messageID] = currentTime - - Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID") + return true } @@ -97,72 +113,76 @@ class SecurityManager(private val encryptionService: EncryptionService, private // Skip handshakes not addressed to us if (packet.recipientID?.toHexString() != myPeerID) { - Log.d(TAG, "Skipping handshake not addressed to us: $peerID") return false } - + // Skip our own handshake messages if (peerID == myPeerID) return false - if (encryptionService.hasEstablishedSession(peerID)) { - Log.d(TAG, "Handshake already completed with $peerID") - return true - } - if (packet.payload.isEmpty()) { - Log.w(TAG, "Noise handshake packet has empty payload") + Log.d(TAG, "Noise handshake packet has empty payload") return false } - + // Prevent duplicate handshake processing val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}" - + if (processedKeyExchanges.contains(exchangeKey)) { - Log.d(TAG, "Already processed handshake: $exchangeKey") return false } - Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)") - processedKeyExchanges.add(exchangeKey) - + try { - // Process the Noise handshake through the updated EncryptionService - val response = encryptionService.processHandshakeMessage(packet.payload, peerID) + // The session manager preserves an existing transport in a separate responder-candidate + // flow and reports whether this exact frame completed authentication. Never infer that + // from ambient session state: a rejected replacement may leave the old session active. + val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID) + processedKeyExchanges.add(exchangeKey) + keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis() - if (response != null) { - Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response") + if (result.response != null) { // Send handshake response through delegate - delegate?.sendHandshakeResponse(peerID, response) + delegate?.sendHandshakeResponse(peerID, result.response) } - // Check if session is now established (handshake complete) - if (encryptionService.hasEstablishedSession(peerID)) { - Log.d(TAG, "✅ Noise handshake completed with $peerID") - delegate?.onKeyExchangeCompleted(peerID, packet.payload) + if (result.establishedNow) { + val authenticatedRemoteStaticKey = result.authenticatedRemoteStaticKey + if (authenticatedRemoteStaticKey == null) { + Log.e(TAG, "Bound Noise completion for $peerID omitted its authenticated static key") + return false + } + val authenticatedSessionToken = result.authenticatedSessionToken + if (authenticatedSessionToken?.size != 32 || + authenticatedSessionToken.all { it == 0.toByte() } + ) { + Log.e(TAG, "Bound Noise completion for $peerID omitted its generation token") + return false + } + val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + Log.i(TAG, "Noise handshake completed with $peerID") + delegate?.onKeyExchangeCompleted( + peerID = peerID, + authenticatedRemoteStaticKey = authenticatedRemoteStaticKey, + authenticatedSessionToken = authenticatedSessionToken, + directRelayAddress = routed.relayAddress.takeIf { isDirectIngress }, + ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress } + ) } return true } catch (e: Exception) { - Log.e(TAG, "Failed to process Noise handshake from $peerID: ${e.message}") + Log.w(TAG, "Failed to process Noise handshake from $peerID: ${e.message}") return false } } /** - * Verify packet signature + * Verify a packet signature against the signing key learned from the + * peer's verified announcement. Signatures cover the canonical packet, + * not only its payload; otherwise routing and recipient fields could be + * changed without invalidating the signature. */ fun verifySignature(packet: BitchatPacket, peerID: String): Boolean { - return packet.signature?.let { signature -> - try { - val isValid = encryptionService.verify(signature, packet.payload, peerID) - if (!isValid) { - Log.w(TAG, "Invalid signature for packet from $peerID") - } - isValid - } catch (e: Exception) { - Log.e(TAG, "Failed to verify signature from $peerID: ${e.message}") - false - } - } ?: true // No signature means verification passes + return verifyPacketSignature(packet, peerID) } /** @@ -188,13 +208,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private null } } + + fun encryptForPeer( + data: ByteArray, + recipientPeerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray? = try { + encryptionService.encryptForSession(data, recipientPeerID, expectedSession) + } catch (e: Exception) { + Log.e(TAG, "Noise generation changed before encrypting for $recipientPeerID: ${e.message}") + null + } /** * Decrypt payload from specific peer */ - fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): NoiseDecryptionResult? { return try { - encryptionService.decrypt(encryptedData, senderPeerID) + encryptionService.decryptWithSession(encryptedData, senderPeerID) } catch (e: Exception) { Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}") null @@ -208,20 +239,106 @@ class SecurityManager(private val encryptionService: EncryptionService, private return encryptionService.getCombinedPublicKeyData() } - /** - * Generate message ID for duplicate detection - */ + /** Deduplicates by peer and a hash of packet type, sender, timestamp, and full payload. */ private fun generateMessageID(packet: BitchatPacket, peerID: String): String { - return when (MessageType.fromValue(packet.type)) { - MessageType.FRAGMENT -> { - // For fragments, include the payload hash to distinguish different fragments - "${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}" + return "$peerID-${PacketIdUtil.computeIdHex(packet)}" + } + + /** + * Verify packet signature using peer's signing public key + * Returns true only if signature is present and valid + */ + private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean { + try { + // Public packets that mutate identity, presence, or user-visible state must prove the + // signing key learned from a verified announcement. LEAVE is included so an attacker + // cannot evict a claimed peer or amplify a forged departure through relay. + if (MessageType.fromValue(packet.type) !in setOf( + MessageType.ANNOUNCE, + MessageType.MESSAGE, + MessageType.FILE_TRANSFER, + MessageType.VOICE_FRAME, + MessageType.LEAVE + )) { + return true } - else -> { - // For other messages, use a truncated payload hash - val payloadHash = packet.payload.sliceArray(0 until minOf(64, packet.payload.size)).contentHashCode() - "${packet.timestamp}-$peerID-$payloadHash" + + if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) { + val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key -> + encryptionService.verifyEd25519Signature(signature, data, key) + } ?: run { + Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from $peerID") + return false + } + + val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey) + if (persistedSigningKey != null && + !persistedSigningKey.contentEquals(announcement.signingPublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state for $peerID") + return false + } + + val existingPeer = delegate?.getPeerInfo(peerID) + if ( + existingPeer?.noisePublicKey != null && + !existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE Noise-key replacement for $peerID") + return false + } + if ( + existingPeer?.signingPublicKey != null && + !existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE signing-key replacement without authenticated peer state for $peerID") + return false + } + return true } + + // 1. Mandatory Signature Check + if (packet.signature == null) { + Log.w(TAG, "Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") + return false + } + + // 2. Get Signing Public Key + val peerInfo = delegate?.getPeerInfo(peerID) + val signingPublicKey = peerInfo?.signingPublicKey + + if (signingPublicKey == null) { + // If we don't have a key (and it's not an announce), we can't verify. + // For security, we must reject packets from unknown peers unless it's an announce. + Log.w(TAG, "Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})") + return false + } + + // 3. Get Canonical Data + val packetDataForSigning = packet.toBinaryDataForSigning() + if (packetDataForSigning == null) { + Log.w(TAG, "Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})") + return false + } + + // 4. Verify Signature + val signature = packet.signature!! + val isSignatureValid = encryptionService.verifyEd25519Signature( + signature, + packetDataForSigning, + signingPublicKey + ) + + if (isSignatureValid) { + return true + } else { + Log.w(TAG, "Signature INVALID for $peerID (type ${packet.type})") + return false + } + + } catch (e: Exception) { + Log.e(TAG, "Signature verification error for $peerID: ${e.message}") + return false } } @@ -269,40 +386,45 @@ class SecurityManager(private val encryptionService: EncryptionService, private /** * Clean up old processed messages and timestamps */ - private fun cleanupOldData() { - val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT - var removedCount = 0 - + internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) { + val cutoffTime = nowMs - MESSAGE_TIMEOUT + // Clean up old message timestamps and corresponding processed messages val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) -> timestamp < cutoffTime }.map { it.key } - + messagesToRemove.forEach { messageId -> messageTimestamps.remove(messageId) - if (processedMessages.remove(messageId)) { - removedCount++ - } + processedMessages.remove(messageId) } - + // Limit the size of processed messages set if (processedMessages.size > MAX_PROCESSED_MESSAGES) { val excess = processedMessages.size - MAX_PROCESSED_MESSAGES val toRemove = processedMessages.take(excess) processedMessages.removeAll(toRemove.toSet()) removeFromMessageTimestamps(toRemove) - removedCount += excess } - + + // Expire handshake dedup entries by time so a delayed same-ephemeral delivery + // (e.g. a re-handshake retry after a failed attempt) is not blocked forever. + val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT + val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) -> + timestamp < keyExchangeCutoff + }.map { it.key } + + keyExchangesToRemove.forEach { exchangeKey -> + keyExchangeTimestamps.remove(exchangeKey) + processedKeyExchanges.remove(exchangeKey) + } + // Limit the size of processed key exchanges set if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) { val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES val toRemove = processedKeyExchanges.take(excess) processedKeyExchanges.removeAll(toRemove.toSet()) - } - - if (removedCount > 0) { - Log.d(TAG, "Cleaned up $removedCount old processed messages") + toRemove.forEach { keyExchangeTimestamps.remove(it) } } } @@ -322,6 +444,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private processedMessages.clear() processedKeyExchanges.clear() messageTimestamps.clear() + keyExchangeTimestamps.clear() } /** @@ -337,6 +460,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private * Delegate interface for security manager callbacks */ interface SecurityManagerDelegate { - fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) + fun onKeyExchangeCompleted( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + authenticatedSessionToken: ByteArray, + directRelayAddress: String?, + ingressLinkID: String? + ) fun sendHandshakeResponse(peerID: String, response: ByteArray) + fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification + fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null } diff --git a/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt b/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt index f7be3406..d9dd18d0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/StoreForwardManager.kt @@ -16,10 +16,10 @@ class StoreForwardManager { companion object { private const val TAG = "StoreForwardManager" - private const val MESSAGE_CACHE_TIMEOUT = 43200000L // 12 hours for regular peers - private const val MAX_CACHED_MESSAGES = 100 // For regular peers - private const val MAX_CACHED_MESSAGES_FAVORITES = 1000 // For favorites - private const val CLEANUP_INTERVAL = 600000L // 10 minutes + private const val MESSAGE_CACHE_TIMEOUT = com.bitchat.android.util.AppConstants.StoreForward.MESSAGE_CACHE_TIMEOUT_MS // 12 hours for regular peers + private const val MAX_CACHED_MESSAGES = com.bitchat.android.util.AppConstants.StoreForward.MAX_CACHED_MESSAGES // For regular peers + private const val MAX_CACHED_MESSAGES_FAVORITES = com.bitchat.android.util.AppConstants.StoreForward.MAX_CACHED_MESSAGES_FAVORITES // For favorites + private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.StoreForward.CLEANUP_INTERVAL_MS // 10 minutes } /** @@ -165,7 +165,7 @@ class StoreForwardManager { // Send with delays to avoid overwhelming the connection messagesToSend.forEachIndexed { index, storedMessage -> - delay(index * 100L) // 100ms between messages + delay(index * 10L) // 10ms between messages delegate?.sendPacket(storedMessage.packet) } diff --git a/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt b/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt new file mode 100644 index 00000000..8dab20a2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt @@ -0,0 +1,31 @@ +package com.bitchat.android.mesh + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +data class TransferProgressEvent( + val transferId: String, + val sent: Int, + val total: Int, + val completed: Boolean, + val failed: Boolean = false +) + +object TransferProgressManager { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 32) + val events: SharedFlow = _events + + fun start(id: String, total: Int) { emit(id, 0, total, false) } + fun progress(id: String, sent: Int, total: Int) { emit(id, sent, total, sent >= total) } + fun complete(id: String, total: Int) { emit(id, total, total, true) } + fun fail(id: String) { emit(id, 0, 0, done = true, failed = true) } + + private fun emit(id: String, sent: Int, total: Int, done: Boolean, failed: Boolean = false) { + scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done, failed)) } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt new file mode 100644 index 00000000..3efe758d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -0,0 +1,480 @@ +package com.bitchat.android.mesh + +import android.content.Context +import android.util.Log +import com.bitchat.android.favorites.FavoriteControlMessage +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Feature-facing mesh service that hides local transport selection from the rest of the app. + * + * BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh + * behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever + * local transport already has the peer/session, falling back to a connected transport handshake. + */ +class UnifiedMeshService( + private val context: Context, + private val bluetooth: BluetoothMeshService +) : MeshService, BluetoothMeshDelegate { + + companion object { + private const val TAG = "UnifiedMeshService" + } + + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val powerManager = PowerManager.getInstance(context.applicationContext) + private var announcementJob: Job? = null + + override val myPeerID: String + get() = bluetooth.myPeerID + + override var delegate: MeshDelegate? = null + set(value) { + field = value + refreshDelegates() + } + + fun refreshDelegates() { + try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { } + try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { } + } + + override fun startServices() { + if (isBleEnabled()) { + try { bluetooth.startServices() } catch (e: Exception) { + Log.w(TAG, "Failed to start BLE transport: ${e.message}") + } + } else { + try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { } + } + try { WifiAwareController.startIfPossible() } catch (e: Exception) { + Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}") + } + startAnnouncementScheduler() + refreshDelegates() + } + + override fun stopServices() { + announcementJob?.cancel() + announcementJob = null + try { bluetooth.stopServices() } catch (_: Exception) { } + try { WifiAwareController.stop() } catch (_: Exception) { } + } + + private fun startAnnouncementScheduler() { + if (announcementJob?.isActive == true) return + announcementJob = serviceScope.launch { + powerManager.profile + .map { profile -> + profile.meshAnnouncementIntervalMs to profile.hasDirectPeers + } + .distinctUntilChanged() + .collectLatest { (intervalMs, hasRecipients) -> + if (!hasRecipients) return@collectLatest + // Connection-specific paths already send an immediate announce. Begin the + // periodic cadence after the configured interval to avoid a transition burst. + while (isActive) { + delay(intervalMs) + if (powerManager.profile.value.hasDirectPeers) sendBroadcastAnnounce() + } + } + } + } + + override fun sendMessage(content: String, mentions: List, channel: String?) { + when { + isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel) + else -> wifiService()?.sendMessage(content, mentions, channel) + } + } + + override fun sendPrivateMessage( + content: String, + recipientPeerID: String, + recipientNickname: String, + messageID: String? + ) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + } + + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname) + isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname) + } + } + + override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) { + val myNpub = try { + com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub + } catch (_: Exception) { + null + } + val content = FavoriteControlMessage.encode(isFavorite, myNpub) + val nickname = getPeerNicknames()[peerID] ?: peerID + if (hasEstablishedSession(peerID)) { + sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString()) + } + } + + override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + when { + isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + } + } + + override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + when { + isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + } + } + + override fun sendFileBroadcast(file: BitchatFilePacket) { + when { + isBleEnabled() -> bluetooth.sendFileBroadcast(file) + else -> wifiService()?.sendFileBroadcast(file) + } + } + + override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file) + isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendFilePrivate(recipientPeerID, file) + else -> wifiService()?.sendFilePrivate(recipientPeerID, file) + } + } + + override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + if (recipientPeerID == null) { + when { + isBleEnabled() -> bluetooth.sendVoiceFrame(null, payload) + else -> wifiService()?.sendVoiceFrame(null, payload) + } + return + } + when { + isBleReady(recipientPeerID) -> bluetooth.sendVoiceFrame(recipientPeerID, payload) + isWifiReady(recipientPeerID) -> wifiService()?.sendVoiceFrame(recipientPeerID, payload) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendVoiceFrame(recipientPeerID, payload) + else -> wifiService()?.sendVoiceFrame(recipientPeerID, payload) + } + } + + override fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation { + return when { + isBleReady(recipientPeerID) -> bluetooth.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) + isWifiReady(recipientPeerID) -> wifiService()?.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) ?: PrivateMediaPreparation.Rejected("Wi-Fi Aware transport is unavailable") + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) + else -> wifiService()?.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) ?: PrivateMediaPreparation.Rejected("No local transport is available for this peer") + } + } + + override fun cancelFileTransfer(transferId: String): Boolean { + val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false } + val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false } + return bleCancelled || wifiCancelled + } + + override fun sendBroadcastAnnounce() { + if (isBleEnabled()) { + try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { } + } + try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { } + } + + override fun sendAnnouncementToPeer(peerID: String) { + when { + isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID) + else -> wifiService()?.sendAnnouncementToPeer(peerID) + } + } + + override fun getPeerNicknames(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { } + return merged + } + + override fun getPeerRSSI(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { } + return merged + } + + override fun getActivePeerCount(): Int { + return mergedPeerIDs().filter { it != myPeerID }.distinct().size + } + + override fun hasEstablishedSession(peerID: String): Boolean { + return isBleReady(peerID) || isWifiReady(peerID) + } + + override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState { + val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } + val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null } + return when { + bleState is NoiseSession.NoiseSessionState.Established -> bleState + wifiState is NoiseSession.NoiseSessionState.Established -> wifiState + bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState + wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState + bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState + wifiState != null -> wifiState + else -> bleState + } + } + + override fun initiateNoiseHandshake(peerID: String) { + when { + isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID) + isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID) + isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID) + else -> wifiService()?.initiateNoiseHandshake(peerID) + } + } + + override fun getPeerFingerprint(peerID: String): String? { + return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null } + ?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null } + } + + override fun getPeerInfo(peerID: String): PeerInfo? { + val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null } + val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null } + return when { + ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble + wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi + ble?.isConnected == true -> ble + wifi?.isConnected == true -> wifi + else -> ble ?: wifi + } + } + + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean { + val bleUpdated = try { + bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + } catch (_: Exception) { + false + } + val wifiUpdated = try { + wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true + } catch (_: Exception) { + false + } + return bleUpdated || wifiUpdated + } + + override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint() + + override fun getStaticNoisePublicKey(): ByteArray? { + return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey() + } + + override fun shouldShowEncryptionIcon(peerID: String): Boolean { + return hasEstablishedSession(peerID) + } + + override fun getEncryptedPeers(): List { + val encrypted = linkedSetOf() + try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { } + try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { } + mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) } + return encrypted.toList() + } + + override fun getDeviceAddressForPeer(peerID: String): String? { + return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null } + ?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null } + } + + override fun getDeviceAddressToPeerMapping(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { } + return merged + } + + override fun printDeviceAddressesForPeers(): String { + return buildString { + appendLine(bluetooth.printDeviceAddressesForPeers()) + wifiService()?.let { + appendLine() + appendLine(it.printDeviceAddressesForPeers()) + } + } + } + + override fun getDebugStatus(): String { + return buildString { + appendLine("=== Unified Mesh Service Debug Status ===") + appendLine("My Peer ID: $myPeerID") + appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}") + appendLine() + appendLine(bluetooth.getDebugStatus()) + wifiService()?.let { + appendLine() + appendLine(it.getDebugStatus()) + } + } + } + + override fun clearAllInternalData() { + try { bluetooth.clearAllInternalData() } catch (_: Exception) { } + try { wifiService()?.clearAllInternalData() } catch (_: Exception) { } + } + + override fun clearAllEncryptionData() { + try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { } + try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { } + } + + override fun didReceiveMessage(message: BitchatMessage) { + delegate?.didReceiveMessage(message) + } + + override fun didUpdatePeerList(peers: List) { + delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() }) + } + + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { + delegate?.didReceiveChannelLeave(channel, fromPeer) + } + + override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { + delegate?.didReceiveDeliveryAck(messageID, recipientPeerID) + } + + override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { + delegate?.didReceiveReadReceipt(messageID, recipientPeerID) + } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } + + override fun didResolvePrivateMediaPolicy(peerID: String) { + delegate?.didResolvePrivateMediaPolicy(peerID) + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return delegate?.decryptChannelMessage(encryptedContent, channel) + } + + override fun getNickname(): String? = delegate?.getNickname() + + override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false + + private fun mergedPeerIDs(): List { + val ids = linkedSetOf() + try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { } + try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { } + try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { } + return ids.toList() + } + + private fun wifiService(): MeshService? { + return try { + WifiAwareController.getService()?.also { service -> + if (delegate != null && service.delegate !== this) { + service.delegate = this + } + } + } catch (_: Exception) { + null + } + } + + private fun isBleEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isBleConnected(peerID: String): Boolean { + return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } + } + + private fun isWifiConnected(peerID: String): Boolean { + return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } + } + + private fun isBleReady(peerID: String): Boolean { + return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID) + } + + private fun isWifiReady(peerID: String): Boolean { + return try { + val wifi = wifiService() + wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean { + return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false } + } +} diff --git a/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt b/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt new file mode 100644 index 00000000..0dcb23fc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt @@ -0,0 +1,86 @@ +package com.bitchat.android.model + +/** + * Canonical payload carried inside Noise payload type 0x21. + * + * Wire format: + * `[version=0x01][type=0x01][len=1...8][minimal LE capabilities]` + * `[type=0x02][len=32][Ed25519 public key]` + * + * Unknown TLVs are skipped. Both known fields must occur exactly once. + */ +data class AuthenticatedPeerState( + val capabilities: PeerCapabilities, + val signingPublicKey: ByteArray +) { + init { + require(signingPublicKey.size == SIGNING_PUBLIC_KEY_SIZE) { + "Ed25519 public key must be 32 bytes" + } + } + + fun encode(): ByteArray { + val capabilityBytes = capabilities.encoded() + return buildList(1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size) { + add(VERSION.toByte()) + add(CAPABILITIES_TLV.toByte()) + add(capabilityBytes.size.toByte()) + addAll(capabilityBytes.toList()) + add(SIGNING_PUBLIC_KEY_TLV.toByte()) + add(SIGNING_PUBLIC_KEY_SIZE.toByte()) + addAll(signingPublicKey.toList()) + }.toByteArray() + } + + companion object { + const val VERSION = 0x01 + private const val CAPABILITIES_TLV = 0x01 + private const val SIGNING_PUBLIC_KEY_TLV = 0x02 + private const val SIGNING_PUBLIC_KEY_SIZE = 32 + + fun decode(data: ByteArray): AuthenticatedPeerState? { + if (data.firstOrNull()?.toInt()?.and(0xFF) != VERSION) return null + var offset = 1 + var capabilities: PeerCapabilities? = null + var signingPublicKey: ByteArray? = null + + while (offset < data.size) { + if (offset + 2 > data.size) return null + val type = data[offset].toInt() and 0xFF + val length = data[offset + 1].toInt() and 0xFF + offset += 2 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + + when (type) { + CAPABILITIES_TLV -> { + if (capabilities != null || length !in 1..8) return null + val decoded = PeerCapabilities.decode(value) + if (!decoded.encoded().contentEquals(value)) return null + capabilities = decoded + } + + SIGNING_PUBLIC_KEY_TLV -> { + if (signingPublicKey != null || length != SIGNING_PUBLIC_KEY_SIZE) return null + signingPublicKey = value + } + + else -> Unit + } + } + + val decodedCapabilities = capabilities ?: return null + val decodedSigningKey = signingPublicKey ?: return null + return AuthenticatedPeerState(decodedCapabilities, decodedSigningKey) + } + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is AuthenticatedPeerState && + capabilities == other.capabilities && + signingPublicKey.contentEquals(other.signingPublicKey)) + + override fun hashCode(): Int = 31 * capabilities.hashCode() + signingPublicKey.contentHashCode() +} diff --git a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt new file mode 100644 index 00000000..710d72b1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt @@ -0,0 +1,177 @@ +package com.bitchat.android.model + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * BitchatFilePacket: TLV-encoded file transfer payload for BLE mesh. + * TLVs: + * - 0x01: filename (UTF-8) + * - 0x02: file size (8 bytes, UInt64) + * - 0x03: mime type (UTF-8) + * - 0x04: content (bytes) — may appear multiple times for large files + * + * Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs. + * For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each. + * + * Unknown TLV types are SKIPPED, not rejected: the tag list above is a floor, + * not a ceiling, and a decoder that bails on the first tag it does not know + * makes the format unextendable — the whole file is lost over a field that, + * by construction, the sender considered optional. The iOS client has always + * done this (`case nil: continue` in its own decoder), so rejecting here also + * meant the two implementations disagreed about what a valid packet is. + * + * Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this + * TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes. + * Transport-level fragmentation then splits the final packet for BLE MTU. + */ +data class BitchatFilePacket( + val fileName: String, + val fileSize: Long, + val mimeType: String, + val content: ByteArray +) { + private enum class TLVType(val v: UByte) { + FILE_NAME(0x01u), FILE_SIZE(0x02u), MIME_TYPE(0x03u), CONTENT(0x04u); + companion object { + fun from(value: UByte): TLVType? = when (value) { + FILE_NAME.v -> FILE_NAME + FILE_SIZE.v -> FILE_SIZE + MIME_TYPE.v -> MIME_TYPE + CONTENT.v -> CONTENT + else -> null + } + } + } + + fun encode(): ByteArray? { + try { + android.util.Log.d("BitchatFilePacket", "🔄 Encoding: name=$fileName, size=$fileSize, mime=$mimeType") + val nameBytes = fileName.toByteArray(Charsets.UTF_8) + val mimeBytes = mimeType.toByteArray(Charsets.UTF_8) + // Validate bounds for 2-byte TLV lengths (per-TLV). CONTENT may exceed 65535 and will be chunked. + if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) { + android.util.Log.e("BitchatFilePacket", "❌ TLV field too large: name=${nameBytes.size}, mime=${mimeBytes.size} (max: 65535)") + return null + } + if (content.size > 0xFFFF) { + android.util.Log.d("BitchatFilePacket", "📦 Content exceeds 65535 bytes (${content.size}); will be split into multiple CONTENT TLVs") + } else { + android.util.Log.d("BitchatFilePacket", "📏 TLV sizes OK: name=${nameBytes.size}, mime=${mimeBytes.size}, content=${content.size}") + } + val sizeFieldLen = 4 // UInt32 for FILE_SIZE (changed from 8 bytes) + val contentLenFieldLen = 4 // UInt32 for CONTENT TLV as requested + + // Compute capacity: header TLVs + single CONTENT TLV with 4-byte length + val contentTLVBytes = 1 + contentLenFieldLen + content.size + val capacity = (1 + 2 + nameBytes.size) + (1 + 2 + sizeFieldLen) + (1 + 2 + mimeBytes.size) + contentTLVBytes + val buf = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN) + + // FILE_NAME + buf.put(TLVType.FILE_NAME.v.toByte()) + buf.putShort(nameBytes.size.toShort()) + buf.put(nameBytes) + + // FILE_SIZE (4 bytes) + buf.put(TLVType.FILE_SIZE.v.toByte()) + buf.putShort(sizeFieldLen.toShort()) + buf.putInt(fileSize.toInt()) + + // MIME_TYPE + buf.put(TLVType.MIME_TYPE.v.toByte()) + buf.putShort(mimeBytes.size.toShort()) + buf.put(mimeBytes) + + // CONTENT (single TLV with 4-byte length) + buf.put(TLVType.CONTENT.v.toByte()) + buf.putInt(content.size) + buf.put(content) + + val result = buf.array() + android.util.Log.d("BitchatFilePacket", "✅ Encoded successfully: ${result.size} bytes total") + return result + } catch (e: Exception) { + android.util.Log.e("BitchatFilePacket", "❌ Encoding failed: ${e.message}", e) + return null + } + } + + companion object { + fun decode(data: ByteArray): BitchatFilePacket? { + android.util.Log.d("BitchatFilePacket", "🔄 Decoding ${data.size} bytes") + try { + var off = 0 + var name: String? = null + var size: Long? = null + var mime: String? = null + var contentBytes: ByteArray? = null + var skippedUnknownTLVs = 0 + while (off < data.size) { + // Every TLV needs at least a type and a 2-byte length. + // Reject a truncated trailing header instead of silently + // accepting it, matching the iOS decoder. + if (data.size - off < 3) return null + // A null `t` is an unknown tag: read its length like any + // other 2-byte TLV and skip its value, matching iOS. + val t = TLVType.from(data[off].toUByte()) + off += 1 + // CONTENT uses 4-byte length; others use 2-byte length + val len: Int + if (t == TLVType.CONTENT) { + if (off + 4 > data.size) return null + len = ((data[off].toInt() and 0xFF) shl 24) or ((data[off + 1].toInt() and 0xFF) shl 16) or ((data[off + 2].toInt() and 0xFF) shl 8) or (data[off + 3].toInt() and 0xFF) + off += 4 + } else { + if (off + 2 > data.size) return null + len = ((data[off].toInt() and 0xFF) shl 8) or (data[off + 1].toInt() and 0xFF) + off += 2 + } + if (len < 0 || off + len > data.size) return null + if (t == null) { + // Unknown tag: advance past the value without copying it + // and without logging. A peer can pad a packet with + // zero-length unknown TLVs — 3 bytes each — so anything + // per-TLV here is attacker-scaled: at the payload + // ceiling that is millions of copies and formatted log + // lines monopolising the mesh handler. Counted and + // reported once after the loop instead. + off += len + skippedUnknownTLVs += 1 + continue + } + val value = data.copyOfRange(off, off + len) + off += len + when (t) { + TLVType.FILE_NAME -> name = String(value, Charsets.UTF_8) + TLVType.FILE_SIZE -> { + if (len != 4) return null + val bb = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN) + size = bb.int.toLong() + } + TLVType.MIME_TYPE -> mime = String(value, Charsets.UTF_8) + TLVType.CONTENT -> { + // Expect a single CONTENT TLV + if (contentBytes == null) contentBytes = value else { + // If multiple CONTENT TLVs appear, concatenate for tolerance + contentBytes = (contentBytes!! + value) + } + } + } + } + if (skippedUnknownTLVs > 0) { + android.util.Log.d("BitchatFilePacket", "⏭️ Skipped $skippedUnknownTLVs unknown TLV(s)") + } + val n = name ?: return null + val c = contentBytes ?: return null + val s = size ?: c.size.toLong() + val m = mime ?: "application/octet-stream" + val result = BitchatFilePacket(n, s, m, c) + android.util.Log.d("BitchatFilePacket", "✅ Decoded: name=$n, size=$s, mime=$m, content=${c.size} bytes") + return result + } catch (e: Exception) { + android.util.Log.e("BitchatFilePacket", "❌ Decoding failed: ${e.message}", e) + return null + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index c3681720..528a269e 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -7,6 +7,14 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.* +@Parcelize +enum class BitchatMessageType : Parcelable { + Message, + Audio, + Image, + File +} + /** * Delivery status for messages - exact same as iOS version */ @@ -49,6 +57,7 @@ data class BitchatMessage( val id: String = UUID.randomUUID().toString().uppercase(), val sender: String, val content: String, + val type: BitchatMessageType = BitchatMessageType.Message, val timestamp: Date, val isRelay: Boolean = false, val originalSender: String? = null, @@ -59,7 +68,16 @@ data class BitchatMessage( val channel: String? = null, val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, - val deliveryStatus: DeliveryStatus? = null + val deliveryStatus: DeliveryStatus? = null, + val powDifficulty: Int? = null, + /** + * Full canonical Nostr public key supplied by the local Nostr bridge. + * + * This is local identity metadata, not part of the Bitchat binary wire format. It lets UI + * surfaces color the sender by the same stable key while [senderPeerID] remains available for + * mesh IDs and private-chat routing aliases. + */ + val senderNostrPubkey: String? = null ) : Parcelable { /** @@ -278,6 +296,7 @@ data class BitchatMessage( id = id, sender = sender, content = content, + type = BitchatMessageType.Message, timestamp = timestamp, isRelay = isRelay, originalSender = originalSender, @@ -305,6 +324,7 @@ data class BitchatMessage( if (id != other.id) return false if (sender != other.sender) return false if (content != other.content) return false + if (type != other.type) return false if (timestamp != other.timestamp) return false if (isRelay != other.isRelay) return false if (originalSender != other.originalSender) return false @@ -327,6 +347,7 @@ data class BitchatMessage( var result = id.hashCode() result = 31 * result + sender.hashCode() result = 31 * result + content.hashCode() + result = 31 * result + type.hashCode() result = 31 * result + timestamp.hashCode() result = 31 * result + isRelay.hashCode() result = 31 * result + (originalSender?.hashCode() ?: 0) @@ -342,4 +363,3 @@ data class BitchatMessage( } } - diff --git a/app/src/main/java/com/bitchat/android/model/FileSharingManager.kt b/app/src/main/java/com/bitchat/android/model/FileSharingManager.kt new file mode 100644 index 00000000..49c48920 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/FileSharingManager.kt @@ -0,0 +1,92 @@ +package com.bitchat.android.model + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.bitchat.android.features.file.FileUtils +import java.io.File + +/** + * Business logic for file sharing operations + */ +object FileSharingManager { + + private const val TAG = "FileSharingManager" + + /** + * Create a file packet from URI for sending + */ + fun createFilePacketFromUri( + context: Context, + uri: Uri, + originalName: String? = null + ): BitchatFilePacket? { + return try { + // Get file name from URI or use original name + val fileName = originalName ?: getFileNameFromUri(context, uri) ?: "unknown_file" + + // Copy file to our temp storage for sending + val localPath = FileUtils.copyFileForSending(context, uri) ?: return null + + // Determine MIME type + val mimeType = FileUtils.getMimeTypeFromExtension(fileName) + + // Read file content + val file = File(localPath) + val content = file.readBytes() + val fileSize = file.length() + + // Clean up temp file + file.delete() + + val packet = BitchatFilePacket( + fileName = fileName, + fileSize = fileSize, + mimeType = mimeType, + content = content + ) + + Log.d(TAG, "Created file packet: name=$fileName, size=${FileUtils.formatFileSize(fileSize)}, mime=$mimeType") + packet + + } catch (e: Exception) { + Log.e(TAG, "Failed to create file packet from URI", e) + null + } + } + + /** + * Extract filename from URI + */ + private fun getFileNameFromUri(context: Context, uri: Uri): String? { + return try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(nameIndex) + } ?: uri.lastPathSegment + } catch (e: Exception) { + Log.w(TAG, "Failed to get filename from URI", e) + uri.lastPathSegment + } + } + + /** + * Process a received file packet and return file info + */ + data class ReceivedFileInfo( + val fileName: String, + val fileSize: Long, + val mimeType: String, + val content: ByteArray + ) + + fun processReceivedFile(packet: BitchatFilePacket): ReceivedFileInfo { + return ReceivedFileInfo( + fileName = packet.fileName, + fileSize = packet.fileSize, + mimeType = packet.mimeType, + content = packet.content + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt b/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt index b902222b..70e550a6 100644 --- a/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt +++ b/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt @@ -83,6 +83,9 @@ data class FragmentPayload( * Matches iOS implementation exactly */ fun encode(): ByteArray { + require(index in 0..0xFFFF) { "Fragment index would truncate UInt16: $index" } + require(total in 1..0xFFFF) { "Fragment total would truncate UInt16: $total" } + require(index < total) { "Fragment index $index must be below total $total" } val payload = ByteArray(HEADER_SIZE + data.size) // Fragment ID (8 bytes) diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index 2dfbe9c2..c48bd6cc 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -2,7 +2,6 @@ package com.bitchat.android.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -import com.bitchat.android.util.* /** * Identity announcement structure with TLV encoding @@ -12,7 +11,9 @@ import com.bitchat.android.util.* data class IdentityAnnouncement( val nickname: String, val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) - val signingPublicKey: ByteArray // Ed25519 public key for signing + val signingPublicKey: ByteArray, // Ed25519 public key for signing + val capabilities: PeerCapabilities? = null, + val unknownTLVs: List = emptyList() ) : Parcelable { /** @@ -21,7 +22,8 @@ data class IdentityAnnouncement( private enum class TLVType(val value: UByte) { NICKNAME(0x01u), NOISE_PUBLIC_KEY(0x02u), - SIGNING_PUBLIC_KEY(0x03u); // NEW: Ed25519 signing public key + SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key + CAPABILITIES(0x05u); companion object { fun fromValue(value: UByte): TLVType? { @@ -37,7 +39,8 @@ data class IdentityAnnouncement( val nicknameData = nickname.toByteArray(Charsets.UTF_8) // Check size limits - if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255) { + if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 || + unknownTLVs.any { it.value.size > 255 }) { return null } @@ -57,6 +60,21 @@ data class IdentityAnnouncement( result.add(TLVType.SIGNING_PUBLIC_KEY.value.toByte()) result.add(signingPublicKey.size.toByte()) result.addAll(signingPublicKey.toList()) + + // Optional little-endian feature bitfield. Old clients skip this TLV. + capabilities?.encoded()?.let { capabilityBytes -> + result.add(TLVType.CAPABILITIES.value.toByte()) + result.add(capabilityBytes.size.toByte()) + result.addAll(capabilityBytes.toList()) + } + + // Preserve extensions this build does not understand. This includes + // gossip TLV 0x04 when an announcement is decoded through this model. + unknownTLVs.forEach { tlv -> + result.add(tlv.type.toByte()) + result.add(tlv.value.size.toByte()) + result.addAll(tlv.value.toList()) + } return result.toByteArray() } @@ -73,6 +91,8 @@ data class IdentityAnnouncement( var nickname: String? = null var noisePublicKey: ByteArray? = null var signingPublicKey: ByteArray? = null + var capabilities: PeerCapabilities? = null + val unknownTLVs = mutableListOf() while (offset + 2 <= dataCopy.size) { // Read TLV type @@ -102,20 +122,36 @@ data class IdentityAnnouncement( TLVType.SIGNING_PUBLIC_KEY -> { signingPublicKey = value } + TLVType.CAPABILITIES -> { + capabilities = PeerCapabilities.decode(value) + } null -> { - // Unknown TLV; skip (tolerant decoder for forward compatibility) - continue + // Retain unknown extensions so callers can forward or + // re-encode the announcement without erasing them. + unknownTLVs += UnknownAnnouncementTLV(typeValue.toInt(), value) } } } // All three fields are required return if (nickname != null && noisePublicKey != null && signingPublicKey != null) { - IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey) + IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs) } else { null } } + + /** Construct the announcement emitted by this Android build. */ + fun forLocalPeer( + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray + ): IdentityAnnouncement = IdentityAnnouncement( + nickname = nickname, + noisePublicKey = noisePublicKey, + signingPublicKey = signingPublicKey, + capabilities = PeerCapabilities.LOCAL_SUPPORTED + ) } // Override equals and hashCode since we use ByteArray @@ -128,6 +164,8 @@ data class IdentityAnnouncement( if (nickname != other.nickname) return false if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false + if (capabilities != other.capabilities) return false + if (unknownTLVs != other.unknownTLVs) return false return true } @@ -136,10 +174,12 @@ data class IdentityAnnouncement( var result = nickname.hashCode() result = 31 * result + noisePublicKey.contentHashCode() result = 31 * result + signingPublicKey.contentHashCode() + result = 31 * result + (capabilities?.hashCode() ?: 0) + result = 31 * result + unknownTLVs.hashCode() return result } override fun toString(): String { - return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...)" + return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})" } } diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index dd297f88..aacdafd4 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -20,11 +20,27 @@ import kotlinx.parcelize.Parcelize enum class NoisePayloadType(val value: UByte) { PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding READ_RECEIPT(0x02u), // Message was read - DELIVERED(0x03u); // Message was delivered + DELIVERED(0x03u), // Message was delivered + VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame + VERIFY_CHALLENGE(0x10u), // Verification challenge + VERIFY_RESPONSE(0x11u), // Verification response + FILE_TRANSFER(0x20u), + /** Authenticated capabilities + Ed25519 binding for the current Noise generation. */ + PEER_STATE(0x21u); + companion object { + // #1434 prerelease iOS builds briefly emitted private files as 0x09. Keep this + // decode-only: every NoisePayload constructed by Android still encodes FILE_TRANSFER as + // its canonical 0x20 value, so the compatibility alias cannot leak into new traffic. + private val PRERELEASE_FILE_TRANSFER_RAW_VALUE = 0x09u.toUByte() + fun fromValue(value: UByte): NoisePayloadType? { - return values().find { it.value == value } + return if (value == PRERELEASE_FILE_TRANSFER_RAW_VALUE) { + FILE_TRANSFER + } else { + values().find { it.value == value } + } } } } diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt new file mode 100644 index 00000000..e64fc390 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Feature bits advertised in IdentityAnnouncement TLV 0x05. + * + * The wire representation matches iOS: a minimal little-endian bitfield that + * always contains at least one byte. Unknown bits in the low 64 bits are kept + * so a decode/re-encode cycle does not erase capabilities added by newer + * clients. + */ +@Parcelize +data class PeerCapabilities(val rawValue: Long) : Parcelable { + fun contains(capability: PeerCapabilities): Boolean = + (rawValue and capability.rawValue) == capability.rawValue + + fun encoded(): ByteArray { + var remaining = rawValue + val bytes = mutableListOf() + do { + bytes += remaining.toByte() + remaining = remaining ushr 8 + } while (remaining != 0L) + return bytes.toByteArray() + } + + companion object { + val NONE = PeerCapabilities(0) + + val PREKEYS = PeerCapabilities(1L shl 0) + val WIFI_BULK = PeerCapabilities(1L shl 1) + val GATEWAY = PeerCapabilities(1L shl 2) + val GROUPS = PeerCapabilities(1L shl 3) + val BOARD = PeerCapabilities(1L shl 4) + val VOUCH = PeerCapabilities(1L shl 5) + val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6) + val BRIDGE = PeerCapabilities(1L shl 7) + + /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ + val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + + val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9) + + /** Reserved by iOS; decode it but do not advertise or act on it. */ + val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10) + + /** Capabilities implemented by this Android build. */ + val LOCAL_SUPPORTED = PRIVATE_MEDIA + + /** + * Decode the low 64 bits and ignore any future extension bytes, which + * is the same forward-compatible behavior used by iOS. + */ + fun decode(data: ByteArray): PeerCapabilities { + var rawValue = 0L + data.take(8).forEachIndexed { index, byte -> + rawValue = rawValue or ((byte.toLong() and 0xFF) shl (8 * index)) + } + return PeerCapabilities(rawValue) + } + } +} + +/** An announcement TLV not understood by this build, retained verbatim. */ +@Parcelize +class UnknownAnnouncementTLV( + val type: Int, + val value: ByteArray +) : Parcelable { + init { + require(type in 0..0xFF) { "TLV type must fit in one byte" } + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is UnknownAnnouncementTLV && type == other.type && value.contentEquals(other.value)) + + override fun hashCode(): Int = 31 * type + value.contentHashCode() +} diff --git a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt new file mode 100644 index 00000000..ab52f071 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.model + +import com.bitchat.android.sync.SyncDefaults + +/** + * REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters. + * TLV (type, length16, value), types: + * - 0x01: P (uint8) — Golomb-Rice parameter + * - 0x02: M (uint32, big-endian) — hash range (N * 2^P) + * - 0x03: data (opaque) — GR bitstream bytes + */ +data class RequestSyncPacket( + val p: Int, + val m: Long, + val data: ByteArray +) { + fun encode(): ByteArray { + val out = ArrayList() + fun putTLV(t: Int, v: ByteArray) { + out.add(t.toByte()) + val len = v.size + out.add(((len ushr 8) and 0xFF).toByte()) + out.add((len and 0xFF).toByte()) + out.addAll(v.toList()) + } + // P + putTLV(0x01, byteArrayOf(p.toByte())) + // M (uint32) + val m32 = m.coerceAtMost(0xffff_ffffL) + putTLV( + 0x02, + byteArrayOf( + ((m32 ushr 24) and 0xFF).toByte(), + ((m32 ushr 16) and 0xFF).toByte(), + ((m32 ushr 8) and 0xFF).toByte(), + (m32 and 0xFF).toByte() + ) + ) + // data + putTLV(0x03, data) + return out.toByteArray() + } + + companion object { + // Receiver-side safety limit (configurable constant) + const val MAX_ACCEPT_FILTER_BYTES: Int = SyncDefaults.MAX_ACCEPT_FILTER_BYTES + + fun decode(data: ByteArray): RequestSyncPacket? { + var off = 0 + var p: Int? = null + var m: Long? = null + var payload: ByteArray? = null + + while (off + 3 <= data.size) { + val t = (data[off].toInt() and 0xFF); off += 1 + val len = ((data[off].toInt() and 0xFF) shl 8) or (data[off+1].toInt() and 0xFF); off += 2 + if (off + len > data.size) return null + val v = data.copyOfRange(off, off + len); off += len + when (t) { + 0x01 -> if (len == 1) p = (v[0].toInt() and 0xFF) + 0x02 -> if (len == 4) { + val mm = ((v[0].toLong() and 0xFF) shl 24) or + ((v[1].toLong() and 0xFF) shl 16) or + ((v[2].toLong() and 0xFF) shl 8) or + (v[3].toLong() and 0xFF) + m = mm + } + 0x03 -> { + if (v.size > MAX_ACCEPT_FILTER_BYTES) return null + payload = v + } + } + } + + val pp = p ?: return null + val mm = m ?: return null + val dd = payload ?: return null + if (pp < 1 || mm <= 0L) return null + return RequestSyncPacket(pp, mm, dd) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt b/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt index 59697360..a139d6df 100644 --- a/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt @@ -9,5 +9,11 @@ import com.bitchat.android.protocol.BitchatPacket data class RoutedPacket( val packet: BitchatPacket, val peerID: String? = null, // Who sent it (parsed from packet.senderID) - val relayAddress: String? = null // Address it came from (for avoiding loopback) -) \ No newline at end of file + val relayAddress: String? = null, // Address it came from (for avoiding loopback) + val transferId: String? = null, // Optional stable transfer ID for progress tracking + /** Exact fragments admitted during private-media prepare; never rebuild them at commit. */ + val preparedPackets: List? = null, + // Opaque, process-local ingress identity. Unlike relayAddress, this distinguishes replacement + // sockets for the same provisional peer and must never be serialized onto the mesh. + val ingressLinkID: String? = null +) diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt new file mode 100644 index 00000000..b1eed65e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -0,0 +1,554 @@ +package com.bitchat.android.net + +import android.app.Application +import android.util.Log +import com.bitchat.android.util.AppConstants +import info.guardianproject.arti.ArtiLogListener +import info.guardianproject.arti.ArtiProxy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.Job +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.CompletableDeferred + +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.atomic.AtomicLong + +/** + * Tor provider implementation using custom-built Arti (Tor-in-Rust). + * + * This singleton provides Tor anonymity features using a custom Arti build + * compiled with 16KB page size support for Google Play compliance. + * + * Based on the original TorManager implementation. + */ +class ArtiTorManager private constructor() { + enum class TorState { + OFF, + STARTING, + BOOTSTRAPPING, + RUNNING, + STOPPING, + ERROR + } + + data class TorStatus( + val mode: TorMode = TorMode.OFF, + val running: Boolean = false, + val bootstrapPercent: Int = 0, + val lastLogLine: String = "", + val state: TorState = TorState.OFF + ) + + companion object { + private const val TAG = "ArtiTorManager" + private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT + private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS + private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS + private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS + private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS + + @Volatile + private var INSTANCE: ArtiTorManager? = null + + fun getInstance(): ArtiTorManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: ArtiTorManager().also { INSTANCE = it } + } + } + } + + private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Volatile + private var initialized = false + @Volatile + private var socksAddr: InetSocketAddress? = null + @Volatile + private var artiProxy: ArtiProxy? = null + @Volatile + private var lastMode: TorMode = TorMode.OFF + private val applyMutex = Mutex() + @Volatile + private var desiredMode: TorMode = TorMode.OFF + @Volatile + private var currentSocksPort: Int = DEFAULT_SOCKS_PORT + @Volatile + private var lastLogTime = AtomicLong(0L) + @Volatile + private var retryAttempts = 0 + @Volatile + private var bindRetryAttempts = 0 + private var inactivityJob: Job? = null + private var retryJob: Job? = null + private var currentApplication: Application? = null + + private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } + + @Volatile + private var lifecycleState: LifecycleState = LifecycleState.STOPPED + + private val _statusFlow = MutableStateFlow( + TorStatus( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + lastLogLine = "", + state = TorState.OFF + ) + ) + + val statusFlow: StateFlow = _statusFlow.asStateFlow() + + private val stateChangeDeferred = AtomicReference?>(null) + + fun isProxyEnabled(): Boolean { + val s = _statusFlow.value + return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && + socksAddr != null && s.state == TorState.RUNNING + } + + fun init(application: Application) { + if (initialized) return + synchronized(this) { + if (initialized) return + initialized = true + currentApplication = application + TorPreferenceManager.init(application) + + val logListener = ArtiLogListener { logLine -> + val text = logLine ?: return@ArtiLogListener + val s = text + lastLogTime.set(System.currentTimeMillis()) + _statusFlow.update { it.copy(lastLogLine = s) } + handleArtiLogLine(s) + if (_statusFlow.value.bootstrapPercent < 100) { + armBootstrapInactivityWatchdog() + } + } + + artiProxy = ArtiProxy.Builder(application) + .setSocksPort(currentSocksPort) + .setDnsPort(currentSocksPort + 1) + .setLogListener(logListener) + .build() + + val savedMode = TorPreferenceManager.get(application) + if (savedMode == TorMode.ON) { + if (currentSocksPort < DEFAULT_SOCKS_PORT) { + currentSocksPort = DEFAULT_SOCKS_PORT + } + desiredMode = savedMode + socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) + try { + OkHttpProvider.reset() + } catch (_: Throwable) { + } // Only reset OkHttp during init + } + appScope.launch { + applyMode(application, savedMode) + } + + appScope.launch { + TorPreferenceManager.modeFlow.collect { mode -> + applyMode(application, mode) + } + } + } + } + + fun currentSocksAddress(): InetSocketAddress? = socksAddr + + /** + * Wait until the currently selected HTTP route can be used. + * + * When Tor mode is enabled, [socksAddr] is intentionally published before + * bootstrap completes so clients fail closed instead of leaking traffic + * directly. Callers that initiate one-shot HTTP work should wait here rather + * than repeatedly connecting to a SOCKS port that is not listening yet. + */ + suspend fun awaitSelectedRoute(timeoutMs: Long): Boolean { + if (currentSocksAddress() == null || isProxyEnabled()) { + return true + } + + return withTimeoutOrNull(timeoutMs) { + statusFlow.first { + currentSocksAddress() == null || isProxyEnabled() + } + true + } ?: false + } + + suspend fun applyMode(application: Application, mode: TorMode) { + applyMutex.withLock { + try { + desiredMode = mode + lastMode = mode + val s = _statusFlow.value + if (mode == s.mode && mode != TorMode.OFF && + (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING) + ) { + return + } + when (mode) { + TorMode.OFF -> { + Log.i(TAG, "applyMode: OFF -> stopping tor") + lifecycleState = LifecycleState.STOPPING + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + state = TorState.STOPPING + ) + stopArti() + waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS) + socksAddr = null + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + state = TorState.OFF + ) + currentSocksPort = DEFAULT_SOCKS_PORT + bindRetryAttempts = 0 + lifecycleState = LifecycleState.STOPPED + resetNetworkConnections() + } + + TorMode.ON -> { + Log.i(TAG, "applyMode: ON -> starting arti") + if (currentSocksPort < DEFAULT_SOCKS_PORT) { + currentSocksPort = DEFAULT_SOCKS_PORT + } + bindRetryAttempts = 0 + lifecycleState = LifecycleState.STARTING + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.ON, + running = false, + bootstrapPercent = 0, + state = TorState.STARTING + ) + socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) + resetNetworkConnections() + startArti(application, useDelay = false) + appScope.launch { + waitUntilBootstrapped() + if (_statusFlow.value.running && desiredMode == TorMode.ON) { + socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) + Log.i(TAG, "Tor ON: proxy set to ${socksAddr}") + resetNetworkConnections() + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to apply Arti mode: ${e.message}") + } + } + } + + private suspend fun startArti(application: Application, useDelay: Boolean = false) { + try { + stopArtiAndWait() + if (useDelay) { + delay(RESTART_DELAY_MS) + } + + val proxy = artiProxy ?: run { + Log.e(TAG, "ArtiProxy not initialized! This should not happen.") + _statusFlow.update { it.copy(state = TorState.ERROR) } + return + } + + proxy.start() + lastLogTime.set(System.currentTimeMillis()) + + _statusFlow.update { + it.copy( + running = true, + bootstrapPercent = 0, + state = TorState.STARTING + ) + } + lifecycleState = LifecycleState.RUNNING + startInactivityMonitoring() + + } catch (e: Exception) { + Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}") + _statusFlow.update { it.copy(state = TorState.ERROR) } + + val isBindError = isBindError(e) + if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) { + bindRetryAttempts++ + currentSocksPort++ + Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort") + socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) + resetNetworkConnections() + startArti(application, useDelay = false) + } else if (isBindError) { + Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up") + lifecycleState = LifecycleState.STOPPED + _statusFlow.update { + it.copy( + running = false, + bootstrapPercent = 0, + state = TorState.ERROR + ) + } + } else { + scheduleRetry(application) + } + } + } + + private fun isBindError(exception: Exception): Boolean { + val message = exception.message?.lowercase() ?: "" + return message.contains("bind") || + message.contains("address already in use") || + message.contains("port") && message.contains("use") || + message.contains("permission denied") && message.contains("port") || + message.contains("could not bind") + } + + /** + * Reset network connections after Tor state changes. + * Rebuilds OkHttp clients and reconnects Nostr relays. + */ + private fun resetNetworkConnections() { + try { + OkHttpProvider.reset() + } catch (_: Throwable) { + } + try { + com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() + } catch (_: Throwable) { + } + } + + private fun stopArtiInternal() { + try { + val proxy = artiProxy + if (proxy != null) { + try { + proxy.stop() + } catch (_: Throwable) { + } + } + stopInactivityMonitoring() + stopRetryMonitoring() + } catch (e: Exception) { + Log.w(TAG, "Error stopping Arti: ${e.message}") + } + } + + private fun stopArti() { + stopArtiInternal() + socksAddr = null + _statusFlow.value = _statusFlow.value.copy( + running = false, + bootstrapPercent = 0, + state = TorState.STOPPING + ) + } + + private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) { + stopArtiInternal() + waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs) + delay(200) + } + + private suspend fun restartArti(application: Application) { + Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") + stopArtiAndWait() + delay(RESTART_DELAY_MS) + startArti(application, useDelay = false) + } + + private fun startInactivityMonitoring() { + armBootstrapInactivityWatchdog() + } + + /** + * One-shot startup watchdog. Arti log activity re-arms it and successful bootstrap cancels it, + * so a healthy background Tor process has no permanent five-second polling coroutine. + */ + private fun armBootstrapInactivityWatchdog() { + inactivityJob?.cancel() + if (lifecycleState != LifecycleState.RUNNING || _statusFlow.value.bootstrapPercent >= 100) { + inactivityJob = null + return + } + inactivityJob = appScope.launch { + delay(INACTIVITY_TIMEOUT_MS) + val timeSinceLastActivity = System.currentTimeMillis() - lastLogTime.get() + if ( + timeSinceLastActivity >= INACTIVITY_TIMEOUT_MS && + _statusFlow.value.mode == TorMode.ON && + _statusFlow.value.bootstrapPercent < 100 && + lifecycleState == LifecycleState.RUNNING + ) { + Log.w(TAG, "Bootstrap inactivity detected (${timeSinceLastActivity}ms), restarting Arti") + currentApplication?.let { restartArti(it) } + } + } + } + + private fun stopInactivityMonitoring() { + inactivityJob?.cancel() + inactivityJob = null + } + + private fun scheduleRetry(application: Application) { + retryJob?.cancel() + if (retryAttempts < MAX_RETRY_ATTEMPTS) { + retryAttempts++ + val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) + Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms") + retryJob = appScope.launch { + delay(delayMs) + val currentMode = _statusFlow.value.mode + if (currentMode == TorMode.ON) { + restartArti(application) + } + } + } else { + Log.e(TAG, "Max retry attempts reached, giving up on Arti connection") + } + } + + private fun stopRetryMonitoring() { + retryJob?.cancel() + retryJob = null + } + + private suspend fun waitUntilBootstrapped() { + val current = _statusFlow.value + if (!current.running) return + if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return + while (true) { + val s = statusFlow.first { + (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || + !it.running || + it.state == TorState.ERROR + } + if (!s.running || s.state == TorState.ERROR) return + if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return + } + } + + private fun handleArtiLogLine(s: String) { + val currentState = _statusFlow.value.state + val currentLifecycle = lifecycleState + + when { + s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> { + if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) { + return + } + _statusFlow.update { it.copy(state = TorState.STARTING) } + completeWaitersIf(TorState.STARTING) + } + + s.contains("AMEx: state changed to Starting", ignoreCase = true) -> { + if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) { + return + } + _statusFlow.update { it.copy(state = TorState.STARTING) } + completeWaitersIf(TorState.STARTING) + } + + s.contains( + "Sufficiently bootstrapped; system SOCKS now functional", + ignoreCase = true + ) -> { + if (currentLifecycle != LifecycleState.RUNNING) { + return + } + _statusFlow.update { + it.copy( + bootstrapPercent = 75, + state = TorState.BOOTSTRAPPING + ) + } + retryAttempts = 0 + bindRetryAttempts = 0 + startInactivityMonitoring() + } + + s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> { + if (currentLifecycle != LifecycleState.RUNNING) { + return + } + _statusFlow.update { + it.copy( + state = TorState.RUNNING, + bootstrapPercent = 100, + running = true + ) + } + stopInactivityMonitoring() + completeWaitersIf(TorState.RUNNING) + } + + s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> { + if (currentLifecycle != LifecycleState.STOPPING) { + return + } + _statusFlow.update { + it.copy( + state = TorState.STOPPING, + running = false + ) + } + } + + s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> { + if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) { + return + } + _statusFlow.update { + it.copy( + state = TorState.OFF, + running = false, + bootstrapPercent = 0 + ) + } + completeWaitersIf(TorState.OFF) + } + + s.contains("Another process has the lock on our state files", ignoreCase = true) -> { + _statusFlow.update { it.copy(state = TorState.ERROR) } + } + } + } + + private fun completeWaitersIf(state: TorState) { + stateChangeDeferred.getAndSet(null)?.let { def -> + def.complete(state) + } + } + + private suspend fun waitForStateTransition(target: TorState, timeoutMs: Long): TorState? { + val def = CompletableDeferred() + stateChangeDeferred.getAndSet(def)?.cancel() + return withTimeoutOrNull(timeoutMs) { + val cur = _statusFlow.value.state + if (cur == target) return@withTimeoutOrNull cur + def.await() + } + } + + fun isTorAvailable(): Boolean = true +} diff --git a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt index 60431476..f9ad16e2 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -10,44 +10,72 @@ import java.util.concurrent.atomic.AtomicReference * Centralized OkHttp provider to ensure all network traffic honors Tor settings. */ object OkHttpProvider { - private val httpClientRef = AtomicReference(null) - private val wsClientRef = AtomicReference(null) - - fun reset() { - httpClientRef.set(null) - wsClientRef.set(null) + enum class Route { + DIRECT, + TOR } - fun httpClient(): OkHttpClient { + data class RoutedClient( + val client: OkHttpClient, + val route: Route + ) + + private val httpClientRef = AtomicReference(null) + private val wsClientRef = AtomicReference(null) + private val clientLock = Any() + + fun reset() { + synchronized(clientLock) { + httpClientRef.set(null) + wsClientRef.set(null) + } + } + + fun httpClient(): OkHttpClient = routedHttpClient().client + + /** + * Returns the client and the route it was actually built with as one snapshot. + * + * The selected Tor mode can change while an existing client is still cached. Consumers that + * key cooldowns by network identity must use this value rather than re-reading the preference. + */ + fun routedHttpClient(): RoutedClient { httpClientRef.get()?.let { return it } - val client = baseBuilderForCurrentProxy() - .callTimeout(15, TimeUnit.SECONDS) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - httpClientRef.set(client) - return client + return synchronized(clientLock) { + httpClientRef.get() ?: run { + val (builder, route) = baseBuilderForCurrentProxy() + val client = builder + .callTimeout(15, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + RoutedClient(client, route).also(httpClientRef::set) + } + } } fun webSocketClient(): OkHttpClient { wsClientRef.get()?.let { return it } - val client = baseBuilderForCurrentProxy() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .build() - wsClientRef.set(client) - return client + return synchronized(clientLock) { + wsClientRef.get() ?: baseBuilderForCurrentProxy().first + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + .also(wsClientRef::set) + } } - private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { + private fun baseBuilderForCurrentProxy(): Pair { val builder = OkHttpClient.Builder() - val socks: InetSocketAddress? = TorManager.currentSocksAddress() - if (socks != null && TorManager.isProxyEnabled()) { + val torProvider = ArtiTorManager.getInstance() + val socks: InetSocketAddress? = torProvider.currentSocksAddress() + // If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON, + // even before bootstrap, to prevent any direct connections from occurring. + if (socks != null) { val proxy = Proxy(Proxy.Type.SOCKS, socks) builder.proxy(proxy) } - return builder + return builder to if (socks == null) Route.DIRECT else Route.TOR } } - diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/TorManager.kt deleted file mode 100644 index 7cfd6a5d..00000000 --- a/app/src/main/java/com/bitchat/android/net/TorManager.kt +++ /dev/null @@ -1,327 +0,0 @@ -package com.bitchat.android.net - -import android.app.Application -import android.util.Log -import info.guardianproject.arti.ArtiLogListener -import info.guardianproject.arti.ArtiProxy -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.launch -import kotlinx.coroutines.delay -import kotlinx.coroutines.Job - -import java.net.InetSocketAddress -import java.util.concurrent.atomic.AtomicReference -import java.util.concurrent.atomic.AtomicLong - -/** - * Manages embedded Tor lifecycle & provides SOCKS proxy address. - * Uses Arti (Tor in Rust) for improved security and reliability. - */ -object TorManager { - private const val TAG = "TorManager" - private const val DEFAULT_SOCKS_PORT = 9060 - private const val RESTART_DELAY_MS = 2000L // 2 seconds between stop/start - private const val INACTIVITY_TIMEOUT_MS = 5000L // 5 seconds of no activity before restart - private const val MAX_RETRY_ATTEMPTS = 5 - - private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - @Volatile private var initialized = false - @Volatile private var socksAddr: InetSocketAddress? = null - private val artiProxyRef = AtomicReference(null) - @Volatile private var lastMode: TorMode = TorMode.OFF - private val applyMutex = Mutex() - @Volatile private var desiredMode: TorMode = TorMode.OFF - @Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT - @Volatile private var lastLogTime = AtomicLong(0L) - @Volatile private var retryAttempts = 0 - @Volatile private var bindRetryAttempts = 0 - private var inactivityJob: Job? = null - private var retryJob: Job? = null - private var currentApplication: Application? = null - - private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } - @Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED - - data class TorStatus( - val mode: TorMode = TorMode.OFF, - val running: Boolean = false, - val bootstrapPercent: Int = 0, - val lastLogLine: String = "" - ) - - private val _status = MutableStateFlow(TorStatus()) - val statusFlow: StateFlow = _status.asStateFlow() - - fun isProxyEnabled(): Boolean { - val s = _status.value - return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null - } - - fun init(application: Application) { - if (initialized) return - synchronized(this) { - if (initialized) return - initialized = true - currentApplication = application - TorPreferenceManager.init(application) - - // Apply saved mode at startup - appScope.launch { - applyMode(application, TorPreferenceManager.get(application)) - } - - // Observe changes - appScope.launch { - TorPreferenceManager.modeFlow.collect { mode -> - applyMode(application, mode) - } - } - } - } - - fun currentSocksAddress(): InetSocketAddress? = socksAddr - - suspend fun applyMode(application: Application, mode: TorMode) { - applyMutex.withLock { - try { - desiredMode = mode - lastMode = mode - val s = _status.value - if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) { - Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip") - return - } - when (mode) { - TorMode.OFF -> { - Log.i(TAG, "applyMode: OFF -> stopping tor") - lifecycleState = LifecycleState.STOPPING - stopArti() - socksAddr = null - _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0) - currentSocksPort = DEFAULT_SOCKS_PORT - bindRetryAttempts = 0 - lifecycleState = LifecycleState.STOPPED - // Rebuild clients WITHOUT proxy and reconnect relays - try { - OkHttpProvider.reset() - com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() - } catch (_: Throwable) { } - } - TorMode.ON -> { - Log.i(TAG, "applyMode: ON -> starting arti") - // Reset port to default unless we're already using a higher port - if (currentSocksPort < DEFAULT_SOCKS_PORT) { - currentSocksPort = DEFAULT_SOCKS_PORT - } - bindRetryAttempts = 0 - lifecycleState = LifecycleState.STARTING - startArti(application, useDelay = false) - _status.value = _status.value.copy(mode = TorMode.ON) - // Defer enabling proxy until bootstrap completes - appScope.launch { - waitUntilBootstrapped() - if (_status.value.running && desiredMode == TorMode.ON) { - socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - Log.i(TAG, "Tor ON: proxy set to ${socksAddr}") - OkHttpProvider.reset() - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } - } - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to apply Arti mode: ${e.message}") - } - } - } - - private suspend fun startArti(application: Application, useDelay: Boolean = false) { - try { - stopArtiInternal() - - Log.i(TAG, "Starting Arti on port $currentSocksPort…") - if (useDelay) { - delay(RESTART_DELAY_MS) - } - - val logListener = ArtiLogListener { logLine -> - val text = logLine ?: return@ArtiLogListener - val s = text.toString() - Log.i(TAG, "arti: $s") - lastLogTime.set(System.currentTimeMillis()) - _status.value = _status.value.copy(lastLogLine = s) - if ( - s.contains("Sufficiently bootstrapped", ignoreCase = true) || - s.contains("AMEx: state changed to Running", ignoreCase = true) - ) { - _status.value = _status.value.copy(bootstrapPercent = 100) - retryAttempts = 0 // Reset retry attempts on successful bootstrap - bindRetryAttempts = 0 // Reset bind retry attempts on successful bootstrap - startInactivityMonitoring() - } - } - - val proxy = ArtiProxy.Builder(application) - .setSocksPort(currentSocksPort) - .setDnsPort(currentSocksPort + 1) - .setLogListener(logListener) - .build() - - artiProxyRef.set(proxy) - proxy.start() - lastLogTime.set(System.currentTimeMillis()) - - _status.value = _status.value.copy(running = true, bootstrapPercent = 0) - lifecycleState = LifecycleState.RUNNING - startInactivityMonitoring() - - } catch (e: Exception) { - Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}") - - // Check if this is a bind error - val isBindError = isBindError(e) - if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) { - bindRetryAttempts++ - currentSocksPort++ - Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort") - // Immediate retry with incremented port, no exponential backoff for bind errors - startArti(application, useDelay = false) - } else if (isBindError) { - Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up") - lifecycleState = LifecycleState.STOPPED - _status.value = _status.value.copy(running = false, bootstrapPercent = 0) - } else { - // For non-bind errors, use the existing retry mechanism - scheduleRetry(application) - } - } - } - - /** - * Checks if the exception indicates a port binding failure - */ - private fun isBindError(exception: Exception): Boolean { - val message = exception.message?.lowercase() ?: "" - return message.contains("bind") || - message.contains("address already in use") || - message.contains("port") && message.contains("use") || - message.contains("permission denied") && message.contains("port") || - message.contains("could not bind") - } - - private fun stopArtiInternal() { - try { - val proxy = artiProxyRef.getAndSet(null) - if (proxy != null) { - Log.i(TAG, "Stopping Arti…") - try { proxy.stop() } catch (_: Throwable) {} - } - stopInactivityMonitoring() - stopRetryMonitoring() - } catch (e: Exception) { - Log.w(TAG, "Error stopping Arti: ${e.message}") - } - } - - private fun stopArti() { - stopArtiInternal() - socksAddr = null - _status.value = _status.value.copy(running = false, bootstrapPercent = 0) - } - - private suspend fun restartArti(application: Application) { - Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") - stopArtiInternal() - delay(RESTART_DELAY_MS) - startArti(application, useDelay = false) // Already delayed above - } - - private fun startInactivityMonitoring() { - inactivityJob?.cancel() - inactivityJob = appScope.launch { - while (true) { - delay(INACTIVITY_TIMEOUT_MS) - val currentTime = System.currentTimeMillis() - val lastActivity = lastLogTime.get() - val timeSinceLastActivity = currentTime - lastActivity - - if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { - val currentMode = _status.value.mode - if (currentMode == TorMode.ON) { - val bootstrapPercent = _status.value.bootstrapPercent - if (bootstrapPercent < 100) { - Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") - currentApplication?.let { app -> - appScope.launch { - restartArti(app) - } - } - break - } - } - } - } - } - } - - private fun stopInactivityMonitoring() { - inactivityJob?.cancel() - inactivityJob = null - } - - private fun scheduleRetry(application: Application) { - retryJob?.cancel() - if (retryAttempts < MAX_RETRY_ATTEMPTS) { - retryAttempts++ - val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s - Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms") - retryJob = appScope.launch { - delay(delayMs) - val currentMode = _status.value.mode - if (currentMode == TorMode.ON) { - Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)") - restartArti(application) - } - } - } else { - Log.e(TAG, "Max retry attempts reached, giving up on Arti connection") - } - } - - private fun stopRetryMonitoring() { - retryJob?.cancel() - retryJob = null - } - - // Removed Tor resource installation: not needed for Arti - - /** - * Build an execution command that works on Android 10+ where app data dirs are mounted noexec. - * We invoke the platform dynamic linker and pass the PIE binary path as its first arg. - */ - // Removed exec command builder: not needed for Arti - - private suspend fun waitUntilBootstrapped() { - val current = _status.value - if (!current.running) return - if (current.bootstrapPercent >= 100) return - // Suspend until we observe 100% at least once - while (true) { - val s = statusFlow.first { it.bootstrapPercent >= 100 || !it.running } - if (!s.running) return - if (s.bootstrapPercent >= 100) return - } - } - - // Visible for instrumentation tests to validate installation - fun installResourcesForTest(application: Application): Boolean { return true } -} diff --git a/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt b/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt index 661cf7c4..5e02a60b 100644 --- a/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt @@ -8,13 +8,13 @@ object TorPreferenceManager { private const val PREFS_NAME = "bitchat_settings" private const val KEY_TOR_MODE = "tor_mode" - private val _modeFlow = MutableStateFlow(TorMode.OFF) + private val _modeFlow = MutableStateFlow(TorMode.ON) val modeFlow: StateFlow = _modeFlow fun init(context: Context) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name) - val mode = runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF) + val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name) + val mode = runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON) _modeFlow.value = mode } @@ -26,8 +26,8 @@ object TorPreferenceManager { fun get(context: Context): TorMode { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name) - return runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF) + val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name) + return runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON) } } diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index a3d469be..909ca82b 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -24,20 +24,20 @@ class NoiseEncryptionService(private val context: Context) { private const val TAG = "NoiseEncryptionService" // Session limits for performance and security - private const val REKEY_TIME_LIMIT = 3600000L // 1 hour (same as iOS) - private const val REKEY_MESSAGE_LIMIT = 1000L // 1k messages (matches iOS) (same as iOS) + private const val REKEY_TIME_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_TIME_LIMIT_MS // 1 hour (same as iOS) + private const val REKEY_MESSAGE_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_MESSAGE_LIMIT_ENCRYPTION // 1k messages (matches iOS) (same as iOS) } // Static identity key (persistent across app restarts) - loaded from secure storage - private val staticIdentityPrivateKey: ByteArray - private val staticIdentityPublicKey: ByteArray + private var staticIdentityPrivateKey: ByteArray + private var staticIdentityPublicKey: ByteArray // Ed25519 signing key (persistent across app restarts) - loaded from secure storage - private val signingPrivateKey: ByteArray - private val signingPublicKey: ByteArray + private var signingPrivateKey: ByteArray + private var signingPublicKey: ByteArray // Session management - private val sessionManager: NoiseSessionManager + private lateinit var sessionManager: NoiseSessionManager // Channel encryption for password-protected channels private val channelEncryption = NoiseChannelEncryption() @@ -56,12 +56,39 @@ class NoiseEncryptionService(private val context: Context) { // Initialize identity state manager for persistent storage identityStateManager = SecureIdentityStateManager(context) + // Load or create keys - temporary placeholders + staticIdentityPrivateKey = ByteArray(32) + staticIdentityPublicKey = ByteArray(32) + signingPrivateKey = ByteArray(32) + signingPublicKey = ByteArray(32) + + loadOrGenerateKeys() + + // Initialize session manager + initializeSessionManager() + } + + private fun initializeSessionManager() { + // Create new session manager with current keys + val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16) + sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID) + + // Set up session callbacks + sessionManager.onSessionEstablished = { peerID, remoteStaticKey -> + handleSessionEstablished(peerID, remoteStaticKey) + } + + // Ensure any other callbacks are wired if needed + // sessionManager.onSessionFailed could be wired if we exposed it + } + + private fun loadOrGenerateKeys() { // Load or create static identity key (persistent across sessions) val loadedKeyPair = identityStateManager.loadStaticKey() if (loadedKeyPair != null) { staticIdentityPrivateKey = loadedKeyPair.first staticIdentityPublicKey = loadedKeyPair.second - Log.d(TAG, "Loaded existing static identity key: ${calculateFingerprint(staticIdentityPublicKey)}") + Log.d(TAG, "Identity loaded: ${calculateFingerprint(staticIdentityPublicKey).take(16)}") } else { // Generate new identity key pair val keyPair = generateKeyPair() @@ -70,7 +97,6 @@ class NoiseEncryptionService(private val context: Context) { // Save to secure storage identityStateManager.saveStaticKey(staticIdentityPrivateKey, staticIdentityPublicKey) - Log.d(TAG, "Generated and saved new static identity key") } // Load or create Ed25519 signing key (persistent across sessions) @@ -78,7 +104,6 @@ class NoiseEncryptionService(private val context: Context) { if (loadedSigningKeyPair != null) { signingPrivateKey = loadedSigningKeyPair.first signingPublicKey = loadedSigningKeyPair.second - Log.d(TAG, "Loaded existing Ed25519 signing key") } else { // Generate new Ed25519 signing key pair val signingKeyPair = generateEd25519KeyPair() @@ -87,18 +112,9 @@ class NoiseEncryptionService(private val context: Context) { // Save to secure storage identityStateManager.saveSigningKey(signingPrivateKey, signingPublicKey) - Log.d(TAG, "Generated and saved new Ed25519 signing key") - } - - // Initialize session manager - sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey) - - // Set up session callbacks - sessionManager.onSessionEstablished = { peerID, remoteStaticKey -> - handleSessionEstablished(peerID, remoteStaticKey) } } - + // MARK: - Public Interface /** @@ -130,12 +146,36 @@ class NoiseEncryptionService(private val context: Context) { fun getPeerPublicKeyData(peerID: String): ByteArray? { return sessionManager.getRemoteStaticKey(peerID) } + + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? = + sessionManager.getAuthenticatedSession(peerID) + + fun withAuthenticatedSession( + peerID: String, + expectedSession: AuthenticatedNoiseSession, + action: () -> Boolean + ): Boolean = sessionManager.withAuthenticatedSession(peerID, expectedSession, action) /** * Clear persistent identity (for panic mode) */ fun clearPersistentIdentity() { + Log.w(TAG, "Panic: clearing persistent identity and rotating in-memory keys") + + // 1. Clear storage identityStateManager.clearIdentityData() + + // 2. Clear all sessions immediately + if (::sessionManager.isInitialized) { + sessionManager.shutdown() + } + + // 3. Regenerate keys immediately (in-memory rotation) + loadOrGenerateKeys() + + // 4. Re-initialize SessionManager with new keys + initializeSessionManager() + } // MARK: - Handshake Management @@ -144,9 +184,9 @@ class NoiseEncryptionService(private val context: Context) { * Initiate a Noise handshake with a peer * Returns the first handshake message to send */ - fun initiateHandshake(peerID: String): ByteArray? { + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { return try { - sessionManager.initiateHandshake(peerID) + sessionManager.initiateHandshake(peerID, replaceEstablished) } catch (e: Exception) { Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}") null @@ -159,12 +199,25 @@ class NoiseEncryptionService(private val context: Context) { */ fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? { return try { - sessionManager.processHandshakeMessage(peerID, data) + processHandshakeMessageWithResult(data, peerID).response } catch (e: Exception) { Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}") null } } + + /** + * Typed handshake result for security-sensitive callers that must distinguish a null response + * from a newly authenticated session or a rejected handshake. Identity mismatch exceptions are + * intentionally propagated to the caller. + */ + @Throws(Exception::class) + fun processHandshakeMessageWithResult( + data: ByteArray, + peerID: String + ): NoiseHandshakeProcessingResult { + return sessionManager.processHandshakeMessageWithResult(peerID, data) + } /** * Check if we have an established session with a peer @@ -199,6 +252,13 @@ class NoiseEncryptionService(private val context: Context) { null } } + + @Throws(Exception::class) + fun encryptForSession( + data: ByteArray, + peerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray = sessionManager.encryptForSession(data, peerID, expectedSession) /** * Decrypt data from a specific peer using established Noise session @@ -216,6 +276,14 @@ class NoiseEncryptionService(private val context: Context) { null } } + + fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult? = + try { + sessionManager.decryptWithSession(encryptedData, peerID) + } catch (e: Exception) { + Log.e(TAG, "Failed generation-bound decryption from $peerID: ${e.message}") + null + } // MARK: - Peer Management @@ -305,7 +373,7 @@ class NoiseEncryptionService(private val context: Context) { * Initiate rekey for a session (replaces old session with new handshake) */ fun initiateRekey(peerID: String): ByteArray? { - Log.d(TAG, "Initiating rekey for session with $peerID") + Log.d(TAG, "Rekeying session with $peerID") // Remove old session sessionManager.removeSession(peerID) @@ -347,11 +415,24 @@ class NoiseEncryptionService(private val context: Context) { // Store fingerprint mapping via centralized manager // This is the ONLY place where fingerprints are stored - after successful Noise handshake fingerprintManager.storeFingerprintForPeer(peerID, remoteStaticKey) + + // Preserve the canonical peerID -> npub index, but only after Noise proves possession of + // the static key. Announcement-time indexing was unsafe because a peer can copy another + // party's public Noise key without possessing its private key. + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .findNostrPubkey(remoteStaticKey) + ?.let { npub -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .updateNostrPublicKeyForPeerID(peerID, npub) + } + } catch (_: Exception) { + // Favorites may not be initialized in isolated/background crypto tests. + } // Calculate fingerprint for logging and callback val fingerprint = calculateFingerprint(remoteStaticKey) - Log.d(TAG, "Session established with $peerID, fingerprint: ${fingerprint.take(16)}...") // Notify about authentication onPeerAuthenticated?.invoke(peerID, fingerprint) @@ -478,7 +559,9 @@ class NoiseEncryptionService(private val context: Context) { * Clean shutdown */ fun shutdown() { - sessionManager.shutdown() + if (::sessionManager.isInitialized) { + sessionManager.shutdown() + } channelEncryption.clear() // No need to clear fingerprints here - they are managed centrally } diff --git a/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt b/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt new file mode 100644 index 00000000..1a9b4da0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.noise + +import java.security.MessageDigest + +/** + * Canonical binding between an authenticated Noise static key and its mesh wire identity. + * + * Mesh peer IDs are the first eight bytes of SHA-256(staticPublicKey), encoded as 16 lowercase + * hexadecimal characters. A self-signed announcement or completed Noise XX handshake is not + * sufficient on its own: the claimed wire ID must also match this derivation. + */ +object NoisePeerIdentity { + const val STATIC_PUBLIC_KEY_SIZE = 32 + const val WIRE_PEER_ID_LENGTH = 16 + + private val wirePeerIDPattern = Regex("^[0-9a-f]{$WIRE_PEER_ID_LENGTH}$") + + fun derivePeerID(staticPublicKey: ByteArray): String? { + if (staticPublicKey.size != STATIC_PUBLIC_KEY_SIZE) return null + return MessageDigest.getInstance("SHA-256") + .digest(staticPublicKey) + .take(8) + .joinToString("") { "%02x".format(it) } + } + + fun matchesClaimedPeerID(claimedPeerID: String, staticPublicKey: ByteArray): Boolean { + if (!wirePeerIDPattern.matches(claimedPeerID)) return false + return derivePeerID(staticPublicKey) == claimedPeerID + } +} diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index b9760a14..1314d9b7 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -2,7 +2,6 @@ package com.bitchat.android.noise import android.util.Log import com.bitchat.android.noise.southernstorm.protocol.* -import com.bitchat.android.util.toHexString import java.security.SecureRandom @@ -25,8 +24,8 @@ class NoiseSession( private const val PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_SHA256" // Rekey thresholds (same as iOS) - private const val REKEY_TIME_LIMIT = 3600000L // 1 hour - private const val REKEY_MESSAGE_LIMIT = 10000L // 10k messages + private const val REKEY_TIME_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_TIME_LIMIT_MS // 1 hour + private const val REKEY_MESSAGE_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_MESSAGE_LIMIT_SESSION // 10k messages // XX Pattern Message Sizes (exactly matching iOS implementation) private const val XX_MESSAGE_1_SIZE = 32 // -> e (ephemeral key only) @@ -34,13 +33,13 @@ class NoiseSession( private const val XX_MESSAGE_3_SIZE = 48 // -> s, se (encrypted static key) // Maximum payload size for safety - private const val MAX_PAYLOAD_SIZE = 256 + private const val MAX_PAYLOAD_SIZE = com.bitchat.android.util.AppConstants.Noise.MAX_PAYLOAD_SIZE_BYTES // Constants for replay protection (matching iOS implementation) private const val NONCE_SIZE_BYTES = 4 private const val REPLAY_WINDOW_SIZE = 1024 private const val REPLAY_WINDOW_BYTES = REPLAY_WINDOW_SIZE / 8 // 128 bytes - private const val HIGH_NONCE_WARNING_THRESHOLD = 1_000_000_000L + private const val HIGH_NONCE_WARNING_THRESHOLD = com.bitchat.android.util.AppConstants.Noise.HIGH_NONCE_WARNING_THRESHOLD // MARK: - Sliding Window Replay Protection @@ -123,7 +122,6 @@ class NoiseSession( } // Extract ciphertext (remaining bytes) val ciphertext = combinedPayload.copyOfRange(NONCE_SIZE_BYTES, combinedPayload.size) - Log.d(TAG, "Extracted nonce: $extractedNonce, ciphertext size: ${ciphertext.size}") return Pair(extractedNonce, ciphertext) } catch (e: Exception) { @@ -153,6 +151,9 @@ class NoiseSession( // Session state private var state: NoiseSessionState = NoiseSessionState.Uninitialized private val creationTime = System.currentTimeMillis() + private var handshakeStartMs: Long? = null + private var lastHandshakeActivityMs: Long? = null + private var handshakeMessage1: ByteArray? = null // Session counters private var currentPattern = 0; @@ -195,12 +196,19 @@ class NoiseSession( fun isEstablished(): Boolean = state is NoiseSessionState.Established fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking fun getCreationTime(): Long = creationTime + fun isInitiatorRole(): Boolean = isInitiator + fun getHandshakeStartMs(): Long? = handshakeStartMs + fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs + + internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone() + + internal fun setLastHandshakeActivityForTest(timestampMs: Long) { + lastHandshakeActivityMs = timestampMs + } init { try { - // Validate static keys validateStaticKeys() - Log.d(TAG, "Created ${if (isInitiator) "initiator" else "responder"} session for $peerID") } catch (e: Exception) { state = NoiseSessionState.Failed(e) Log.e(TAG, "Failed to initialize Noise session: ${e.message}") @@ -225,8 +233,6 @@ class NoiseSession( if (localStaticPublicKey.all { it == 0.toByte() }) { throw IllegalArgumentException("Local static public key cannot be all zeros") } - - Log.d(TAG, "Static keys validated successfully - private: ${localStaticPrivateKey.size} bytes, public: ${localStaticPublicKey.size} bytes") } /** @@ -235,60 +241,24 @@ class NoiseSession( */ private fun initializeNoiseHandshake(role: Int) { try { - Log.d(TAG, "Creating HandshakeState with role: ${if (role == HandshakeState.INITIATOR) "INITIATOR" else "RESPONDER"}") - - // LOGGING: Track Android handshake initialization (matching iOS) - Log.d(TAG, "=== ANDROID NOISE SESSION - BEFORE HANDSHAKE INIT ===") - Log.d(TAG, "Creating NoiseHandshakeState for peer: $peerID") - Log.d(TAG, "Role: ${if (role == HandshakeState.INITIATOR) "INITIATOR" else "RESPONDER"}") - handshakeState = HandshakeState(PROTOCOL_NAME, role) - Log.d(TAG, "HandshakeState created successfully") - - Log.d(TAG, "=== ANDROID NOISE SESSION - AFTER HANDSHAKE INIT ===") - Log.d(TAG, "NoiseHandshakeState created and mixPreMessageKeys() completed") - + if (handshakeState?.needsLocalKeyPair() == true) { - Log.d(TAG, "Local static key pair is required for XX pattern") - val localKeyPair = handshakeState?.getLocalKeyPair() if (localKeyPair != null) { - // FIXED: Use the provided persistent identity keys with our local fork - // Our local fork properly supports setting pre-existing keys - Log.d(TAG, "Setting persistent static identity keys...") - localKeyPair.setPrivateKey(localStaticPrivateKey, 0) - + if (!localKeyPair.hasPrivateKey() || !localKeyPair.hasPublicKey()) { throw IllegalStateException("Failed to set static identity keys - local fork issue") } - - Log.d(TAG, "✓ Successfully set persistent static identity keys") - Log.d(TAG, "Algorithm: ${localKeyPair.dhName}") - Log.d(TAG, "Private key length: ${localKeyPair.privateKeyLength}") - Log.d(TAG, "Public key length: ${localKeyPair.publicKeyLength}") - - // Verify the keys were set correctly - val verifyPrivate = ByteArray(32) - val verifyPublic = ByteArray(32) - localKeyPair.getPrivateKey(verifyPrivate, 0) - localKeyPair.getPublicKey(verifyPublic, 0) - - Log.d(TAG, "Persistent identity public key: ${localStaticPublicKey.joinToString("") { "%02x".format(it) }}") - Log.d(TAG, "Set public key: ${verifyPublic.joinToString("") { "%02x".format(it) }}") - } else { throw IllegalStateException("HandshakeState returned null for local key pair") } - - } else { - Log.d(TAG, "Local static key pair not needed for this handshake pattern/role") } handshakeState?.start() - Log.d(TAG, "Handshake state started successfully with persistent identity keys") } catch (e: Exception) { - Log.e(TAG, "Exception during handshake initialization: ${e.message}", e) + Log.e(TAG, "Handshake init failed for $peerID: ${e.message}", e) throw e } } @@ -303,8 +273,6 @@ class NoiseSession( */ @Synchronized fun startHandshake(): ByteArray { - Log.d(TAG, "Starting noise XX handshake with $peerID as INITIATOR") - if (!isInitiator) { throw IllegalStateException("Only initiator can start handshake") } @@ -317,23 +285,27 @@ class NoiseSession( // Initialize handshake as initiator initializeNoiseHandshake(HandshakeState.INITIATOR) state = NoiseSessionState.Handshaking + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } + lastHandshakeActivityMs = System.currentTimeMillis() val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE) val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0) currentPattern++ val firstMessage = messageBuffer.copyOf(messageLength) + handshakeMessage1 = firstMessage // Validate message size matches XX pattern expectations if (firstMessage.size != XX_MESSAGE_1_SIZE) { - Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE") + Log.w(TAG, "XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE") } - - Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern") + return firstMessage } catch (e: Exception) { state = NoiseSessionState.Failed(e) - Log.e(TAG, "Failed to start handshake: ${e.message}") + Log.e(TAG, "Failed to start handshake with $peerID: ${e.message}") throw e } } @@ -344,19 +316,20 @@ class NoiseSession( */ @Synchronized fun processHandshakeMessage(message: ByteArray): ByteArray? { - Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)") - try { // Initialize as responder if receiving first message if (state == NoiseSessionState.Uninitialized && !isInitiator) { initializeNoiseHandshake(HandshakeState.RESPONDER) state = NoiseSessionState.Handshaking - Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID") + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } } if (state != NoiseSessionState.Handshaking) { throw IllegalStateException("Invalid state for handshake: $state") } + lastHandshakeActivityMs = System.currentTimeMillis() val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") @@ -366,12 +339,10 @@ class NoiseSession( // Read the incoming message - the Noise library will handle validation val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0) currentPattern++ - Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern") - + // Check what action the handshake state wants us to take next val action = handshakeStateLocal.getAction() - Log.d(TAG, "Handshake action after processing message: $action") - + return when (action) { HandshakeState.WRITE_MESSAGE -> { // Noise library says we need to send a response @@ -379,33 +350,22 @@ class NoiseSession( val responseLength = handshakeStateLocal.writeMessage(responseBuffer, 0, null, 0, 0) currentPattern++ val response = responseBuffer.copyOf(responseLength) - - Log.d(TAG, "Generated handshake response: ${response.size} bytes, action still: ${handshakeStateLocal.getAction()} currentPattern: $currentPattern") + completeHandshake() response } - + HandshakeState.SPLIT -> { // Handshake complete, split into transport keys completeHandshake() - Log.d(TAG, "SPLIT ✅ XX handshake completed with $peerID") null } - + HandshakeState.FAILED -> { throw Exception("Handshake failed - Noise library reported FAILED state") } - - HandshakeState.READ_MESSAGE -> { - // Noise library expects us to read another message - Log.d(TAG, "Handshake waiting for next message from $peerID") - null - } - - else -> { - Log.d(TAG, "Handshake action: $action - no immediate action needed") - null - } + + else -> null } } catch (e: Exception) { @@ -424,30 +384,36 @@ class NoiseSession( return } - Log.d(TAG, "Completing XX handshake with $peerID") - try { - // Split handshake state into transport ciphers - val cipherPair = handshakeState?.split() - - sendCipher = cipherPair?.getSender() - receiveCipher = cipherPair?.getReceiver() - - // Extract remote static key if available - if (handshakeState?.hasRemotePublicKey() == true) { - val remoteDH = handshakeState?.getRemotePublicKey() - if (remoteDH != null) { - remoteStaticPublicKey = ByteArray(32) - remoteDH.getPublicKey(remoteStaticPublicKey!!, 0) - Log.d(TAG, "Remote static public key: ${remoteStaticPublicKey!!.joinToString("") { "%02x".format(it) }}") - } + val activeHandshake = handshakeState ?: throw NoiseSessionError.HandshakeFailed + + // Authenticate the remote static key's claimed mesh identity before split creates + // transport ciphers or the session can become observable as Established. + if (!activeHandshake.hasRemotePublicKey()) throw NoiseSessionError.HandshakeFailed + val remoteDH = activeHandshake.getRemotePublicKey() + ?: throw NoiseSessionError.HandshakeFailed + val authenticatedRemoteKey = ByteArray(NoisePeerIdentity.STATIC_PUBLIC_KEY_SIZE) + remoteDH.getPublicKey(authenticatedRemoteKey, 0) + val derivedPeerID = NoisePeerIdentity.derivePeerID(authenticatedRemoteKey) + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteKey)) { + authenticatedRemoteKey.fill(0) + throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID) } + remoteStaticPublicKey = authenticatedRemoteKey + + // Only a bound remote identity may derive transport ciphers. + val cipherPair = activeHandshake.split() + sendCipher = cipherPair.getSender() + receiveCipher = cipherPair.getReceiver() // Extract handshake hash for channel binding - handshakeHash = handshakeState?.getHandshakeHash() + // getHandshakeHash() exposes the handshake state's backing array. Clone it before + // destroy() zeroizes that state, or every completed session appears to have the same + // all-zero channel-binding token. + handshakeHash = activeHandshake.getHandshakeHash().clone() // Clean up handshake state - handshakeState?.destroy() + activeHandshake.destroy() handshakeState = null messagesSent = 0 @@ -459,8 +425,7 @@ class NoiseSession( replayWindow = ByteArray(REPLAY_WINDOW_BYTES) state = NoiseSessionState.Established - Log.d(TAG, "Handshake completed with $peerID as isInitiator: $isInitiator - transport keys derived") - Log.d(TAG, "✅ XX handshake completed with $peerID") + Log.i(TAG, "Handshake established with $peerID (${if (isInitiator) "initiator" else "responder"})") } catch (e: Exception) { state = NoiseSessionState.Failed(e) Log.e(TAG, "Failed to complete handshake: ${e.message}") @@ -523,20 +488,13 @@ class NoiseSession( // Log high nonce values that might indicate issues if (currentNonce > HIGH_NONCE_WARNING_THRESHOLD) { - Log.w(TAG, "High nonce value detected: $currentNonce - consider rekeying") + Log.w(TAG, "High send nonce $currentNonce for $peerID - rekey recommended") } - - Log.d(TAG, "✅ ANDROID ENCRYPT: ${data.size} → ${combinedPayload.size} bytes (nonce: $currentNonce, ciphertextLength+TAG: ${ciphertextLength}) for $peerID (msg #$messagesSent, role: ${if (isInitiator) "INITIATOR" else "RESPONDER"})") + return combinedPayload - + } catch (e: Exception) { - Log.e(TAG, "Real encryption failed - exception: ${e.message}") - - // ENHANCED: Log cipher state for debugging - if (sendCipher != null) { - Log.e(TAG, "Send cipher state: ${sendCipher!!.javaClass.simpleName}") - } - + Log.e(TAG, "Encryption failed for $peerID: ${e.message}") throw SessionError.EncryptionFailed } } @@ -572,7 +530,12 @@ class NoiseSession( } val (extractedNonce, ciphertext) = nonceAndCiphertext - + + if (ciphertext.size < receiveCipher!!.macLength) { + Log.w(TAG, "Ciphertext too short: ${ciphertext.size} < ${receiveCipher!!.macLength}") + throw SessionError.DecryptionFailed + } + // Validate nonce with sliding window replay protection if (!isValidNonce(extractedNonce, highestReceivedNonce, replayWindow)) { Log.w(TAG, "Replay attack detected: nonce $extractedNonce rejected for $peerID") @@ -592,23 +555,13 @@ class NoiseSession( // Log high nonce values that might indicate issues if (extractedNonce > HIGH_NONCE_WARNING_THRESHOLD) { - Log.w(TAG, "High nonce value detected: $extractedNonce - consider rekeying") + Log.w(TAG, "High receive nonce $extractedNonce for $peerID - rekey recommended") } - val result = plaintext.copyOf(plaintextLength) - Log.d(TAG, "✅ ANDROID DECRYPT: ${combinedPayload.size} → ${result.size} bytes from $peerID (nonce: $extractedNonce, highest: $highestReceivedNonce, role: ${if (isInitiator) "INITIATOR" else "RESPONDER"})") - return result - + return plaintext.copyOf(plaintextLength) + } catch (e: Exception) { - Log.e(TAG, "Decryption failed - exception: ${e.message}") - - // ENHANCED: Log cipher state and session details for debugging - if (receiveCipher != null) { - Log.e(TAG, "Receive cipher state: ${receiveCipher!!.javaClass.simpleName}") - } - Log.e(TAG, "Session state: $state, highest received nonce: $highestReceivedNonce") - Log.e(TAG, "Input data size: ${combinedPayload.size} bytes") - + Log.w(TAG, "Decrypt failed for $peerID: ${e.message} (state=$state, highestNonce=$highestReceivedNonce)") throw SessionError.DecryptionFailed } } @@ -709,9 +662,7 @@ class NoiseSession( if (state !is NoiseSessionState.Failed) { state = NoiseSessionState.Failed(Exception("Session destroyed")) } - - Log.d(TAG, "Session destroyed for $peerID") - + } catch (e: Exception) { Log.w(TAG, "Error during session cleanup: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index 2d9e06d8..dd39eb91 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -2,20 +2,80 @@ package com.bitchat.android.noise import android.util.Log import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +data class NoiseHandshakeProcessingResult( + val response: ByteArray?, + val establishedNow: Boolean, + /** The bound remote static key only when this exact call completed authentication. */ + val authenticatedRemoteStaticKey: ByteArray? = null, + /** Handshake hash identifying that exact authenticated Noise generation. */ + val authenticatedSessionToken: ByteArray? = null +) + +/** Atomic snapshot of the live authenticated Noise generation. */ +class AuthenticatedNoiseSession( + remoteStaticKey: ByteArray, + sessionToken: ByteArray +) { + private val remoteStaticKeyBytes = remoteStaticKey.copyOf() + private val sessionTokenBytes = sessionToken.copyOf() + + val remoteStaticKey: ByteArray get() = remoteStaticKeyBytes.copyOf() + val sessionToken: ByteArray get() = sessionTokenBytes.copyOf() + + override fun equals(other: Any?): Boolean = + this === other || + (other is AuthenticatedNoiseSession && + remoteStaticKeyBytes.contentEquals(other.remoteStaticKeyBytes) && + sessionTokenBytes.contentEquals(other.sessionTokenBytes)) + + override fun hashCode(): Int = + 31 * remoteStaticKeyBytes.contentHashCode() + sessionTokenBytes.contentHashCode() +} + +/** Plaintext and generation binding captured atomically from the session that decrypted it. */ +data class NoiseDecryptionResult( + val plaintext: ByteArray, + val authenticatedSession: AuthenticatedNoiseSession +) /** * SIMPLIFIED Noise session manager - focuses on core functionality only */ class NoiseSessionManager( private val localStaticPrivateKey: ByteArray, - private val localStaticPublicKey: ByteArray + private val localStaticPublicKey: ByteArray, + private val localPeerID: String ) { companion object { private const val TAG = "NoiseSessionManager" + private const val HANDSHAKE_TIMEOUT_MS = 10_000L + private const val HANDSHAKE_SWEEP_INTERVAL_MS = 2_000L + private const val HANDSHAKE_MESSAGE_1_SIZE = 32 + private const val SESSION_TOKEN_SIZE = 32 } - + private val sessions = ConcurrentHashMap() + // An inbound replacement handshake must prove its authenticated static-key binding before it + // can evict a working transport session. Keep responder candidates outside the active map. + private val responderCandidates = ConcurrentHashMap() + + private val sweepScheduler = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "NoiseHandshakeSweeper").apply { isDaemon = true } + } + + init { + sweepScheduler.scheduleWithFixedDelay({ + try { + cleanupStaleHandshakes(System.currentTimeMillis()) + } catch (e: Exception) { + Log.w(TAG, "Handshake sweep failed: ${e.message}") + } + }, HANDSHAKE_SWEEP_INTERVAL_MS, HANDSHAKE_SWEEP_INTERVAL_MS, TimeUnit.MILLISECONDS) + } // Callbacks var onSessionEstablished: ((String, ByteArray) -> Unit)? = null @@ -26,9 +86,10 @@ class NoiseSessionManager( /** * Add new session for a peer */ + @Synchronized fun addSession(peerID: String, session: NoiseSession) { - sessions[peerID] = session - Log.d(TAG, "Added new session for $peerID") + val previous = sessions.put(peerID, session) + if (previous != null && previous !== session) previous.destroy() } /** @@ -42,20 +103,48 @@ class NoiseSessionManager( /** * Remove session for a peer */ + @Synchronized fun removeSession(peerID: String) { - sessions[peerID]?.destroy() - sessions.remove(peerID) - Log.d(TAG, "Removed session for $peerID") + sessions.remove(peerID)?.destroy() + responderCandidates.remove(peerID)?.destroy() } /** * SIMPLIFIED: Initiate handshake - no tie breaker, just start */ - fun initiateHandshake(peerID: String): ByteArray { - Log.d(TAG, "initiateHandshake($peerID)") - - // Remove any existing session first - removeSession(peerID) + @Synchronized + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { + val now = System.currentTimeMillis() + val existing = getSession(peerID) + if (existing != null) { + when { + existing.isEstablished() -> { + if (!replaceEstablished) { + return null + } + val candidate = createSession(peerID, isInitiator = true) + responderCandidates.remove(peerID)?.destroy() + responderCandidates[peerID] = candidate + return try { + candidate.startHandshake() + } catch (e: Exception) { + responderCandidates.remove(peerID, candidate) + candidate.destroy() + throw e + } + } + existing.isHandshaking() -> { + if (!isHandshakeStale(existing, now)) { + return null + } + Log.d(TAG, "Restarting stale handshake with $peerID") + removeSession(peerID) + } + else -> { + removeSession(peerID) + } + } + } // Create new session as initiator val session = NoiseSession( @@ -64,15 +153,12 @@ class NoiseSessionManager( localStaticPrivateKey = localStaticPrivateKey, localStaticPublicKey = localStaticPublicKey ) - Log.d(TAG, "Storing new INITIATOR session for $peerID") addSession(peerID, session) try { - val handshakeData = session.startHandshake() - Log.d(TAG, "Started handshake with $peerID as INITIATOR") - return handshakeData + return session.startHandshake() } catch (e: Exception) { - sessions.remove(peerID) + if (sessions.remove(peerID, session)) session.destroy() throw e } } @@ -81,48 +167,175 @@ class NoiseSessionManager( * Handle incoming handshake message */ fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? { - Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)") - + return processHandshakeMessageWithResult(peerID, message).response + } + + @Synchronized + fun processHandshakeMessageWithResult( + peerID: String, + message: ByteArray + ): NoiseHandshakeProcessingResult { + var activeSession: NoiseSession? = null + var isReplacementCandidate = false + var establishedRemoteKey: ByteArray? = null + var establishedSessionToken: ByteArray? = null + var response: ByteArray? = null + try { - var session = getSession(peerID) - - // If no session exists, create one as responder - if (session == null) { - Log.d(TAG, "Creating new RESPONDER session for $peerID") - session = NoiseSession( - peerID = peerID, - isInitiator = false, - localStaticPrivateKey = localStaticPrivateKey, - localStaticPublicKey = localStaticPublicKey - ) - addSession(peerID, session) - } - - // Process handshake message - val response = session.processHandshakeMessage(message) - - // Check if session is established - if (session.isEstablished()) { - Log.d(TAG, "✅ Session ESTABLISHED with $peerID") - val remoteStaticKey = session.getRemoteStaticPublicKey() - if (remoteStaticKey != null) { - onSessionEstablished?.invoke(peerID, remoteStaticKey) + val existingCandidate = responderCandidates[peerID] + if (existingCandidate != null) { + activeSession = if (message.size == HANDSHAKE_MESSAGE_1_SIZE) { + if (existingCandidate.isInitiatorRole()) { + val shouldYield = localPeerID > peerID + if (!shouldYield) { + return NoiseHandshakeProcessingResult( + response = null, + establishedNow = false + ) + } + Log.d(TAG, "Replacement collision with $peerID; yielding to responder") + } + responderCandidates.remove(peerID, existingCandidate) + existingCandidate.destroy() + createSession(peerID, isInitiator = false).also { + responderCandidates[peerID] = it + } + } else { + existingCandidate + } + isReplacementCandidate = true + } else { + var session = getSession(peerID) + + // Collision handling: both sides initiated and we received message 1. + if (session != null && + session.isHandshaking() && + session.isInitiatorRole() && + message.size == HANDSHAKE_MESSAGE_1_SIZE + ) { + val shouldYield = localPeerID > peerID + if (shouldYield) { + Log.d(TAG, "Handshake collision with $peerID; yielding to responder") + if (sessions.remove(peerID, session)) session.destroy() + session = null + } else { + return NoiseHandshakeProcessingResult(response = null, establishedNow = false) + } + } + + activeSession = when { + session == null -> { + createSession(peerID, isInitiator = false).also { sessions[peerID] = it } + } + session.isEstablished() -> { + isReplacementCandidate = true + createSession(peerID, isInitiator = false).also { + responderCandidates[peerID] = it + } + } + session.isHandshaking() && + !session.isInitiatorRole() && + message.size == HANDSHAKE_MESSAGE_1_SIZE -> { + // A restarted responder handshake can replace an incomplete session because + // there is no working transport state to preserve. + if (sessions.remove(peerID, session)) session.destroy() + createSession(peerID, isInitiator = false).also { sessions[peerID] = it } + } + else -> session } } - - return response - + + val session = activeSession ?: throw NoiseSessionError.InvalidState + + response = session.processHandshakeMessage(message) + + if (session.isEstablished()) { + val remoteStaticKey = session.getRemoteStaticPublicKey() + ?: throw NoiseSessionError.HandshakeFailed + val derivedPeerID = NoisePeerIdentity.derivePeerID(remoteStaticKey) + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStaticKey)) { + throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID) + } + + val sessionToken = session.getHandshakeHash() + ?.takeIf { it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() } } + ?: throw NoiseSessionError.HandshakeFailed + + if (isReplacementCandidate) { + responderCandidates.remove(peerID, session) + val previous = sessions.put(peerID, session) + if (previous != null && previous !== session) previous.destroy() + } + + establishedRemoteKey = remoteStaticKey + establishedSessionToken = sessionToken + } } catch (e: Exception) { + val session = activeSession + if (session != null) { + if (isReplacementCandidate) { + responderCandidates.remove(peerID, session) + } else { + sessions.remove(peerID, session) + } + session.destroy() + } Log.e(TAG, "Handshake failed with $peerID: ${e.message}") - sessions.remove(peerID) - onSessionFailed?.invoke(peerID, e) + runCatching { onSessionFailed?.invoke(peerID, e) } throw e } + + establishedRemoteKey?.let { onSessionEstablished?.invoke(peerID, it) } + return NoiseHandshakeProcessingResult( + response = response, + establishedNow = establishedRemoteKey != null, + authenticatedRemoteStaticKey = establishedRemoteKey?.clone(), + authenticatedSessionToken = establishedSessionToken?.clone() + ) + } + + private fun createSession(peerID: String, isInitiator: Boolean): NoiseSession = NoiseSession( + peerID = peerID, + isInitiator = isInitiator, + localStaticPrivateKey = localStaticPrivateKey, + localStaticPublicKey = localStaticPublicKey + ) + + private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean { + val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs() + if (lastActivity == null) return false + return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS + } + + /** + * Actively expire handshakes that stopped progressing (lost response, abandoned + * responder candidates). Established sessions are never touched here. + */ + @Synchronized + fun cleanupStaleHandshakes(nowMs: Long) { + sessions.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale handshake with $peerID") + if (sessions.remove(peerID, session)) { + session.destroy() + runCatching { onSessionFailed?.invoke(peerID, NoiseSessionError.HandshakeTimeout) } + } + } + } + responderCandidates.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale responder candidate for $peerID") + if (responderCandidates.remove(peerID, session)) { + session.destroy() + } + } + } } /** * SIMPLIFIED: Encrypt data */ + @Synchronized fun encrypt(data: ByteArray, peerID: String): ByteArray { val session = getSession(peerID) ?: throw IllegalStateException("No session found for $peerID") if (!session.isEstablished()) { @@ -130,11 +343,29 @@ class NoiseSessionManager( } return session.encrypt(data) } + + /** Encrypt only if the exact generation that authorized the operation is still active. */ + @Synchronized + fun encryptForSession( + data: ByteArray, + peerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray { + val session = getSession(peerID) ?: throw NoiseSessionError.SessionNotFound + if (!session.isEstablished()) throw NoiseSessionError.SessionNotEstablished + val current = authenticatedSession(session) ?: throw NoiseSessionError.SessionNotEstablished + if (current != expectedSession) throw NoiseSessionError.SessionGenerationChanged + return session.encrypt(data) + } /** * SIMPLIFIED: Decrypt data */ - fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray { + fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray = + decryptWithSession(encryptedData, peerID).plaintext + + @Synchronized + fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult { val session = getSession(peerID) if (session == null) { Log.e(TAG, "No session found for $peerID when trying to decrypt") @@ -144,16 +375,17 @@ class NoiseSessionManager( Log.e(TAG, "Session not established with $peerID when trying to decrypt") throw IllegalStateException("Session not established with $peerID") } - return session.decrypt(encryptedData) + val plaintext = session.decrypt(encryptedData) + val authenticatedSession = authenticatedSession(session) + ?: throw IllegalStateException("Established session for $peerID has no channel binding") + return NoiseDecryptionResult(plaintext, authenticatedSession) } /** * Check if session is established with peer */ fun hasEstablishedSession(peerID: String): Boolean { - val hasSession = getSession(peerID)?.isEstablished() ?: false - Log.d(TAG, "hasEstablishedSession($peerID): $hasSession") - return hasSession + return getSession(peerID)?.isEstablished() ?: false } /** @@ -166,15 +398,42 @@ class NoiseSessionManager( /** * Get remote static public key for a peer (if session established) */ - fun getRemoteStaticKey(peerID: String): ByteArray? { - return getSession(peerID)?.getRemoteStaticPublicKey() + fun getRemoteStaticKey(peerID: String): ByteArray? = + getAuthenticatedSession(peerID)?.remoteStaticKey + + @Synchronized + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? { + val session = getSession(peerID) ?: return null + if (!session.isEstablished()) return null + return authenticatedSession(session) + } + + /** Execute a state transition while preventing replacement/removal of the expected session. */ + @Synchronized + fun withAuthenticatedSession( + peerID: String, + expectedSession: AuthenticatedNoiseSession, + action: () -> Boolean + ): Boolean { + val session = getSession(peerID) ?: return false + if (!session.isEstablished()) return false + if (authenticatedSession(session) != expectedSession) return false + return action() } /** * Get handshake hash for channel binding (if session established) */ fun getHandshakeHash(peerID: String): ByteArray? { - return getSession(peerID)?.getHandshakeHash() + return getAuthenticatedSession(peerID)?.sessionToken + } + + private fun authenticatedSession(session: NoiseSession): AuthenticatedNoiseSession? { + val remoteStaticKey = session.getRemoteStaticPublicKey()?.takeIf { it.size == 32 } ?: return null + val sessionToken = session.getHandshakeHash()?.takeIf { + it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() } + } ?: return null + return AuthenticatedNoiseSession(remoteStaticKey, sessionToken) } /** @@ -194,6 +453,7 @@ class NoiseSessionManager( fun getDebugInfo(): String = buildString { appendLine("=== Noise Session Manager Debug ===") appendLine("Active sessions: ${sessions.size}") + appendLine("Responder candidates: ${responderCandidates.size}") appendLine("") if (sessions.isNotEmpty()) { @@ -207,9 +467,13 @@ class NoiseSessionManager( /** * Shutdown manager and clean up all sessions */ + @Synchronized fun shutdown() { + sweepScheduler.shutdownNow() sessions.values.forEach { it.destroy() } + responderCandidates.values.forEach { it.destroy() } sessions.clear() + responderCandidates.clear() Log.d(TAG, "Noise session manager shut down") } } @@ -222,5 +486,10 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce object SessionNotEstablished : NoiseSessionError("Session not established") object InvalidState : NoiseSessionError("Session in invalid state") object HandshakeFailed : NoiseSessionError("Handshake failed") + object HandshakeTimeout : NoiseSessionError("Handshake timed out") object AlreadyEstablished : NoiseSessionError("Session already established") + object SessionGenerationChanged : NoiseSessionError("Noise session generation changed") + class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError( + "Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID" + ) } diff --git a/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/HandshakeState.java b/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/HandshakeState.java index 91fec78a..3780da88 100644 --- a/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/HandshakeState.java +++ b/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/HandshakeState.java @@ -22,8 +22,6 @@ package com.bitchat.android.noise.southernstorm.protocol; -import android.util.Log; - import java.security.NoSuchAlgorithmException; import java.util.Arrays; @@ -35,8 +33,6 @@ import javax.crypto.ShortBufferException; */ public class HandshakeState implements Destroyable { - private static final String TAG = "AndroidHandshake"; - private SymmetricState symmetric; private boolean isInitiator; private DHState localKeyPair; @@ -467,17 +463,6 @@ public class HandshakeState implements Destroyable { // Empty value for when the prologue is not supplied. private static final byte[] emptyPrologue = new byte [0]; - /** - * Converts a byte array to hex string for logging (matching iOS hex format) - */ - private static String bytesToHex(byte[] bytes) { - StringBuilder sb = new StringBuilder(); - for (byte b : bytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } - /** * Starts the handshake running. * @@ -524,27 +509,18 @@ public class HandshakeState implements Destroyable { throw new IllegalStateException("Pre-shared key required"); } - // Log the symmetric state BEFORE any mixing operations (matching iOS) - Log.d(TAG, "=== ANDROID HANDSHAKE START - INITIAL STATE ==="); - Log.d(TAG, "Protocol: " + symmetric.getProtocolName()); - Log.d(TAG, "Role: " + (isInitiator ? "INITIATOR" : "RESPONDER")); - Log.d(TAG, "Initial symmetric hash: " + bytesToHex(symmetric.getHandshakeHash())); - // Hash the prologue value. - Log.d(TAG, "Mixing empty prologue"); if (prologue != null) symmetric.mixHash(prologue, 0, prologue.length); else symmetric.mixHash(emptyPrologue, 0, 0); - Log.d(TAG, "Hash after empty prologue: " + bytesToHex(symmetric.getHandshakeHash())); - + // Hash the pre-shared key into the chaining key and handshake hash. if (preSharedKey != null) symmetric.mixPreSharedKey(preSharedKey); - + // Mix the pre-supplied public keys into the handshake hash. if (isInitiator) { - Log.d(TAG, "XX pattern - no pre-message keys to mix"); if ((requirements & LOCAL_PREMSG) != 0) symmetric.mixPublicKey(localKeyPair); if ((requirements & FALLBACK_PREMSG) != 0) { @@ -557,7 +533,6 @@ public class HandshakeState implements Destroyable { if ((requirements & REMOTE_PREMSG) != 0) symmetric.mixPublicKey(remotePublicKey); } else { - Log.d(TAG, "XX pattern - no pre-message keys to mix"); if ((requirements & REMOTE_PREMSG) != 0) symmetric.mixPublicKey(remotePublicKey); if ((requirements & FALLBACK_PREMSG) != 0) { @@ -571,11 +546,6 @@ public class HandshakeState implements Destroyable { symmetric.mixPublicKey(localKeyPair); } - // Log final state after all initialization (matching iOS) - Log.d(TAG, "=== ANDROID HANDSHAKE START - FINAL STATE ==="); - Log.d(TAG, "Final symmetric hash after mixPreMessageKeys(): " + bytesToHex(symmetric.getHandshakeHash())); - Log.d(TAG, "==========================================="); - // The handshake has officially started - set the first action. if (isInitiator) action = WRITE_MESSAGE; diff --git a/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/SymmetricState.java b/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/SymmetricState.java index 0e70f9f3..4568d07d 100644 --- a/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/SymmetricState.java +++ b/app/src/main/java/com/bitchat/android/noise/southernstorm/protocol/SymmetricState.java @@ -22,8 +22,6 @@ package com.bitchat.android.noise.southernstorm.protocol; -import android.util.Log; - import java.io.UnsupportedEncodingException; import java.security.DigestException; import java.security.MessageDigest; @@ -37,9 +35,7 @@ import javax.crypto.ShortBufferException; * Symmetric state for helping manage a Noise handshake. */ class SymmetricState implements Destroyable { - - private static final String TAG = "AndroidSymmetric"; - + private String name; private CipherState cipher; private MessageDigest hash; @@ -47,17 +43,6 @@ class SymmetricState implements Destroyable { private byte[] h; private byte[] prev_h; - /** - * Converts a byte array to hex string for logging (matching iOS hex format) - */ - private static String bytesToHex(byte[] bytes) { - StringBuilder sb = new StringBuilder(); - for (byte b : bytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } - /** * Constructs a new symmetric state object. * @@ -94,14 +79,6 @@ class SymmetricState implements Destroyable { } System.arraycopy(h, 0, ck, 0, hashLength); - - // LOGGING: Initial symmetric state after protocol name initialization (matching iOS) - Log.d(TAG, "=== ANDROID SYMMETRIC STATE INITIALIZED ==="); - Log.d(TAG, "Protocol: " + protocolName); - Log.d(TAG, "Initial hash (h): " + bytesToHex(h)); - Log.d(TAG, "Initial chaining key (ck): " + bytesToHex(ck)); - Log.d(TAG, "Hash length: " + h.length); - Log.d(TAG, "========================================="); } /** @@ -134,14 +111,6 @@ class SymmetricState implements Destroyable { */ public void mixKey(byte[] data, int offset, int length) { - // LOGGING: Before mixKey operation (matching iOS) - byte[] inputData = new byte[length]; - System.arraycopy(data, offset, inputData, 0, length); - Log.d(TAG, "*** Android mixKey() BEFORE ***"); - Log.d(TAG, "Input data (" + length + " bytes): " + bytesToHex(inputData)); - Log.d(TAG, "Current CK: " + bytesToHex(ck)); - Log.d(TAG, "Current Hash: " + bytesToHex(h)); - int keyLength = cipher.getKeyLength(); byte[] tempKey = new byte [keyLength]; try { @@ -150,12 +119,6 @@ class SymmetricState implements Destroyable { } finally { Noise.destroy(tempKey); } - - // LOGGING: After mixKey operation (matching iOS) - Log.d(TAG, "*** Android mixKey() AFTER ***"); - Log.d(TAG, "New CK: " + bytesToHex(ck)); - Log.d(TAG, "Hash unchanged: " + bytesToHex(h)); - Log.d(TAG, "Cipher now has key: " + (cipher.getMACLength() > 0)); } /** @@ -167,18 +130,7 @@ class SymmetricState implements Destroyable { */ public void mixHash(byte[] data, int offset, int length) { - // LOGGING: Before mixHash operation (matching iOS) - byte[] inputData = new byte[length]; - System.arraycopy(data, offset, inputData, 0, length); - Log.d(TAG, "*** Android mixHash() BEFORE ***"); - Log.d(TAG, "Input data (" + length + " bytes): " + bytesToHex(inputData)); - Log.d(TAG, "Current Hash: " + bytesToHex(h)); - hashTwo(h, 0, h.length, data, offset, length, h, 0, h.length); - - // LOGGING: After mixHash operation (matching iOS) - Log.d(TAG, "*** Android mixHash() AFTER ***"); - Log.d(TAG, "New Hash: " + bytesToHex(h)); } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt new file mode 100644 index 00000000..9ead2485 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt @@ -0,0 +1,50 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.content.SharedPreferences +import java.util.concurrent.ConcurrentHashMap + +/** + * GeohashAliasRegistry + * - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_ -> pubkeyHex) + * - Persisted to SharedPreferences to survive app restarts. + */ +object GeohashAliasRegistry { + private val map: MutableMap = ConcurrentHashMap() + private const val PREFS_NAME = "geohash_alias_registry" + private var prefs: SharedPreferences? = null + + fun initialize(context: Context) { + if (prefs == null) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + loadFromPrefs() + } + } + + private fun loadFromPrefs() { + prefs?.let { p -> + val allEntries = p.all + for ((key, value) in allEntries) { + if (key is String && value is String) { + map[key] = value + } + } + } + } + + fun put(alias: String, pubkeyHex: String) { + map[alias] = pubkeyHex + prefs?.edit()?.putString(alias, pubkeyHex)?.apply() + } + + fun get(alias: String): String? = map[alias] + + fun contains(alias: String): Boolean = map.containsKey(alias) + + fun snapshot(): Map = HashMap(map) + + fun clear() { + map.clear() + prefs?.edit()?.clear()?.apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt new file mode 100644 index 00000000..68c1b29e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt @@ -0,0 +1,51 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.content.SharedPreferences +import java.util.concurrent.ConcurrentHashMap + +/** + * GeohashConversationRegistry + * - Global, thread-safe registry of conversationKey (e.g., "nostr_") -> source geohash + * - Enables routing geohash DMs from anywhere by providing the correct geohash identity + * - Persisted to SharedPreferences to survive app restarts. + */ +object GeohashConversationRegistry { + private val map = ConcurrentHashMap() + private const val PREFS_NAME = "geohash_conversation_registry" + private var prefs: SharedPreferences? = null + + fun initialize(context: Context) { + if (prefs == null) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + loadFromPrefs() + } + } + + private fun loadFromPrefs() { + prefs?.let { p -> + val allEntries = p.all + for ((key, value) in allEntries) { + if (key is String && value is String) { + map[key] = value + } + } + } + } + + fun set(convKey: String, geohash: String) { + if (geohash.isNotEmpty()) { + map[convKey] = geohash + prefs?.edit()?.putString(convKey, geohash)?.apply() + } + } + + fun get(convKey: String): String? = map[convKey] + + fun snapshot(): Map = map.toMap() + + fun clear() { + map.clear() + prefs?.edit()?.clear()?.apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt new file mode 100644 index 00000000..a43194cb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -0,0 +1,120 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Date + +/** + * GeohashMessageHandler + * - Processes kind=20000 Nostr events for geohash channels + * - Updates repository for participants + nicknames + * - Emits messages to MessageManager + */ +class GeohashMessageHandler( + private val application: Application, + private val repo: GeohashRepository, + private val scope: CoroutineScope, + private val dataManager: com.bitchat.android.ui.DataManager, + private val addChannelMessage: (String, BitchatMessage) -> Unit, + private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default +) { + companion object { private const val TAG = "GeohashMessageHandler" } + + // Simple event deduplication + private val processedIds = ArrayDeque() + private val seen = HashSet() + private val max = 2000 + + private fun dedupe(id: String): Boolean { + if (seen.contains(id)) return true + seen.add(id) + processedIds.addLast(id) + if (processedIds.size > max) { + val old = processedIds.removeFirst() + seen.remove(old) + } + return false + } + + fun onEvent(event: NostrEvent, subscribedGeohash: String) { + scope.launch { + try { + if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch + val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) + if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch + val hasValidSignature = withContext(signatureVerificationDispatcher) { + event.isValidSignature() + } + if (!hasValidSignature) { + Log.w(TAG, "Rejecting geohash event ${event.id.take(8)}... with invalid signature") + return@launch + } + if (dedupe(event.id)) return@launch + + // PoW validation (if enabled) - apply to chat messages primarily + if (event.kind == NostrKind.EPHEMERAL_EVENT) { + val pow = PoWPreferenceManager.getCurrentSettings() + if (pow.enabled && pow.difficulty > 0) { + if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch + } + } + + // Normalize pubkey to lowercase for consistent blocking and storage + val pubkey = event.pubkey.lowercase() + + // Blocked users check (use injected DataManager which has loaded state) + if (dataManager.isGeohashUserBlocked(pubkey)) return@launch + + // Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events + if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) { + repo.updateParticipant(subscribedGeohash, pubkey, Date(event.createdAt * 1000L)) + } + + event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(pubkey, it[1]) } + event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(pubkey) } + // Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr + try { + com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${pubkey.take(16)}", pubkey) + } catch (_: Exception) { } + + // Stop here for presence events - they don't produce chat messages + if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch + + // Skip our own events for message emission + val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application) + if (my.publicKeyHex.equals(pubkey, true)) return@launch + + val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && + event.content.trim().isEmpty() + if (isTeleportPresence) return@launch + + val senderName = repo.displayNameForNostrPubkeyUI(pubkey) + val hasNonce = try { NostrProofOfWork.hasNonce(event) } catch (_: Exception) { false } + val msg = BitchatMessage( + id = event.id, + sender = senderName, + content = event.content, + timestamp = Date(event.createdAt * 1000L), + isRelay = false, + originalSender = repo.displayNameForNostrPubkey(pubkey), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + mentions = null, + channel = "#$subscribedGeohash", + powDifficulty = try { + if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null + } catch (_: Exception) { null } + ) + addChannelMessage("geo:$subscribedGeohash", msg) + } catch (e: Exception) { + Log.e(TAG, "onEvent error: ${e.message}") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt new file mode 100644 index 00000000..50c72cd4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -0,0 +1,273 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.GeoPerson +import java.util.Date + +/** + * GeohashRepository + * - Owns geohash participant tracking and nickname caching + * - Maintains lightweight state for geohash-related UI + */ +class GeohashRepository( + private val application: Application, + private val state: ChatState, + private val dataManager: com.bitchat.android.ui.DataManager +) { + companion object { private const val TAG = "GeohashRepository" } + + // geohash -> (participant pubkeyHex -> lastSeen) + private val geohashParticipants: MutableMap> = mutableMapOf() + + + // pubkeyHex(lowercase) -> nickname (without #hash) + private val geoNicknames: MutableMap = mutableMapOf() + + // conversation key (e.g., "nostr_") -> source geohash it belongs to + private val conversationGeohash: MutableMap = mutableMapOf() + + @Synchronized + fun setConversationGeohash(convKey: String, geohash: String) { + if (geohash.isNotEmpty()) { + conversationGeohash[convKey] = geohash + } + } + + @Synchronized + fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey] + + @Synchronized + fun findPubkeyByNickname(targetNickname: String): String? { + return geoNicknames.entries.firstOrNull { (_, nickname) -> + val base = nickname.split("#").firstOrNull() ?: nickname + base == targetNickname + }?.key + } + + @Synchronized + fun findPubkeyByShortId(shortId: String): String? { + // First check cached nicknames (fastest) + var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) } + if (found != null) return found + + // If not found in nicknames (e.g. anon user), check all known participants across all geohashes + for (participants in geohashParticipants.values) { + found = participants.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) } + if (found != null) return found + } + + return null + } + + // peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases + private val nostrKeyMapping: MutableMap = mutableMapOf() + + // Current geohash in view + private var currentGeohash: String? = null + + fun setCurrentGeohash(geo: String?) { currentGeohash = geo } + fun getCurrentGeohash(): String? = currentGeohash + + @Synchronized + fun clearAll() { + geohashParticipants.clear() + geoNicknames.clear() + nostrKeyMapping.clear() + state.setGeohashPeople(emptyList()) + state.setTeleportedGeo(emptySet()) + state.setGeohashParticipantCounts(emptyMap()) + currentGeohash = null + } + + @Synchronized + fun cacheNickname(pubkeyHex: String, nickname: String) { + val lower = pubkeyHex.lowercase() + val previous = geoNicknames[lower] + geoNicknames[lower] = nickname + if (previous != nickname && currentGeohash != null) { + refreshGeohashPeople() + } + } + + @Synchronized + fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()] + + @Synchronized + fun markTeleported(pubkeyHex: String) { + val set = state.getTeleportedGeoValue().toMutableSet() + val key = pubkeyHex.lowercase() + if (!set.contains(key)) { + set.add(key) + // Background safe update + state.postTeleportedGeo(set) + } + } + + @Synchronized + fun isPersonTeleported(pubkeyHex: String): Boolean { + return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) + } + + @Synchronized + fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) { + val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } + // Cap to now: prevents future-timestamped events (clock skew / malicious created_at) + // from pinning lastSeen and blocking subsequent normal heartbeats. + // Also keeps max: relays send events newest-first, so subsequent older events for + // the same user must not overwrite a fresher lastSeen. + val now = Date() + val effective = if (lastSeen.after(now)) now else lastSeen + val existing = participants[participantId] + if (existing == null || effective.after(existing)) { + participants[participantId] = effective + } + if (currentGeohash == geohash) refreshGeohashPeople() + updateReactiveParticipantCounts() + } + + @Synchronized + fun geohashParticipantCount(geohash: String): Int { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[geohash] ?: return 0 + // prune expired + val it = participants.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value.before(cutoff)) it.remove() + } + // exclude blocked users + return participants.keys.count { !dataManager.isGeohashUserBlocked(it) } + } + + @Synchronized + fun refreshGeohashPeople() { + val geohash = currentGeohash + if (geohash == null) { + // Use postValue for thread safety - this can be called from background threads + state.setGeohashPeople(emptyList()) + return + } + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[geohash] ?: mutableMapOf() + // prune expired + val it = participants.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value.before(cutoff)) it.remove() + } + geohashParticipants[geohash] = participants + // exclude blocked users from people list + val people = participants.filterKeys { !dataManager.isGeohashUserBlocked(it) } + .map { (pubkeyHex, lastSeen) -> + // Use our actual nickname for self; otherwise use cached nickname or anon + val base = try { + val myHex = currentGeohash?.let { NostrIdentityBridge.deriveIdentity(it, application).publicKeyHex } + if (myHex != null && myHex.equals(pubkeyHex, true)) { + state.getNicknameValue() ?: "anon" + } else { + getCachedNickname(pubkeyHex) ?: "anon" + } + } catch (_: Exception) { getCachedNickname(pubkeyHex) ?: "anon" } + GeoPerson( + id = pubkeyHex.lowercase(), + displayName = base, // UI can add #hash if necessary + lastSeen = lastSeen + ) + }.sortedByDescending { it.lastSeen } + // Use postValue for thread safety - this can be called from background threads + state.setGeohashPeople(people) + } + + @Synchronized + fun updateReactiveParticipantCounts() { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val counts = mutableMapOf() + for ((gh, participants) in geohashParticipants) { + val active = participants.filterKeys { !dataManager.isGeohashUserBlocked(it) } + .values.count { !it.before(cutoff) } + counts[gh] = active + } + // Use postValue for thread safety - this can be called from background threads + state.setGeohashParticipantCounts(counts) + } + + @Synchronized + fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { + nostrKeyMapping[tempKeyOrPeer] = pubkeyHex + } + + @Synchronized + fun getNostrKeyMapping(): Map = nostrKeyMapping.toMap() + + @Synchronized + fun displayNameForNostrPubkey(pubkeyHex: String): String { + val suffix = pubkeyHex.takeLast(4) + val lower = pubkeyHex.lowercase() + // Self nickname if matches current identity of current geohash + val current = currentGeohash + if (current != null) { + try { + val my = NostrIdentityBridge.deriveIdentity(current, application) + if (my.publicKeyHex.equals(lower, true)) { + return "${state.getNicknameValue()}#$suffix" + } + } catch (_: Exception) {} + } + val nick = geoNicknames[lower] ?: "anon" + return "$nick#$suffix" + } + + @Synchronized + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { + val lower = pubkeyHex.lowercase() + val suffix = pubkeyHex.takeLast(4) + val current = currentGeohash + val base: String = try { + if (current != null) { + val my = NostrIdentityBridge.deriveIdentity(current, application) + if (my.publicKeyHex.equals(lower, true)) { + state.getNicknameValue() ?: "anon" + } else geoNicknames[lower] ?: "anon" + } else geoNicknames[lower] ?: "anon" + } catch (_: Exception) { geoNicknames[lower] ?: "anon" } + if (current == null) return base + return try { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[current] ?: emptyMap() + var count = 0 + for ((k, t) in participants) { + if (dataManager.isGeohashUserBlocked(k)) continue + if (t.before(cutoff)) continue + val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon") + if (name.equals(base, true)) { count++; if (count > 1) break } + } + if (!participants.containsKey(lower)) count += 1 + if (count > 1) "$base#$suffix" else base + } catch (_: Exception) { base } + } + + /** + * Get display name for any geohash (not just current one) for header titles + */ + @Synchronized + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { + val lower = pubkeyHex.lowercase() + val suffix = pubkeyHex.takeLast(4) + val base = geoNicknames[lower] ?: "anon" + return try { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[sourceGeohash] ?: emptyMap() + var count = 0 + for ((k, t) in participants) { + if (dataManager.isGeohashUserBlocked(k)) continue + if (t.before(cutoff)) continue + val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon") + if (name.equals(base, true)) { count++; if (count > 1) break } + } + if (!participants.containsKey(lower)) count += 1 + if (count > 1) "$base#$suffix" else base + } catch (_: Exception) { base } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt new file mode 100644 index 00000000..d725f3e1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Log + +/** + * Initializer for LocationNotesManager with all dependencies + * Extracts initialization logic from MainActivity for better separation of concerns + */ +object LocationNotesInitializer { + + private const val TAG = "LocationNotesInitializer" + + /** + * Initialize LocationNotesManager with all required dependencies + * + * @param context Application context + * @return true if initialization succeeded, false otherwise + */ + fun initialize(context: Context): Boolean { + return try { + LocationNotesManager.getInstance().initialize( + relayManager = { NostrRelayManager.getInstance(context) }, + subscribe = { filter, id, handler -> + // CRITICAL FIX: Extract geohash properly from filter using getGeohash() method + val geohashFromFilter = filter.getGeohash() ?: run { + Log.e(TAG, "❌ Cannot extract geohash from filter for location notes") + return@initialize id // Return subscription ID even on error + } + + val token = com.bitchat.android.geohash.LiveLocationPrivacyGate + .captureToken() ?: return@initialize id + NostrRelayManager.getInstance(context).subscribeForGeohash( + geohash = geohashFromFilter, + filter = filter, + id = id, + handler = handler, + includeDefaults = true, + nRelays = 5, + liveLocationToken = token + ) + }, + unsubscribe = { id -> + NostrRelayManager.getInstance(context).unsubscribe(id) + }, + sendEvent = { event, relayUrls, token -> + if (relayUrls != null) { + NostrRelayManager.getInstance(context).sendEvent( + event, + relayUrls, + liveLocationToken = token + ) + } else { + NostrRelayManager.getInstance(context).sendEvent( + event, + liveLocationToken = token + ) + } + }, + deriveIdentity = { geohash -> + NostrIdentityBridge.deriveIdentity(geohash, context) + } + ) + Log.d(TAG, "✅ Location Notes Manager initialized") + true + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to initialize Location Notes Manager: ${e.message}", e) + false + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt new file mode 100644 index 00000000..0edb1fca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -0,0 +1,540 @@ +package com.bitchat.android.nostr + +import android.util.Log +import androidx.annotation.MainThread +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID + +/** + * Manages location notes (kind=1 text notes with geohash tags) + * iOS-compatible implementation with StateFlow for Android UI binding + */ +@MainThread +class LocationNotesManager private constructor() { + + companion object { + private const val TAG = "LocationNotesManager" + private const val MAX_NOTES_IN_MEMORY = 500 + + @Volatile + private var INSTANCE: LocationNotesManager? = null + + fun getInstance(): LocationNotesManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: LocationNotesManager().also { INSTANCE = it } + } + } + } + + /** + * Note data class matching iOS implementation + */ + data class Note( + val id: String, + val pubkey: String, + val content: String, + val createdAt: Int, + val nickname: String? + ) { + /** + * Display name for the note - matches iOS exactly + * Format: "nickname#abcd" or "anon#abcd" where abcd is last 4 chars of pubkey + */ + val displayName: String + get() { + val suffix = pubkey.takeLast(4) + val nick = nickname?.trim() + return if (!nick.isNullOrEmpty()) { + "$nick#$suffix" + } else { + "anon#$suffix" + } + } + } + + /** + * Manager state enum + */ + enum class State { + IDLE, + LOADING, + READY, + NO_RELAYS + } + + // Published state (StateFlow for Android) + private val _notes = MutableStateFlow>(emptyList()) + val notes: StateFlow> = _notes.asStateFlow() + + private val _geohash = MutableStateFlow(null) + val geohash: StateFlow = _geohash.asStateFlow() + + private val _initialLoadComplete = MutableStateFlow(false) + val initialLoadComplete: StateFlow = _initialLoadComplete.asStateFlow() + + private val _state = MutableStateFlow(State.IDLE) + val state: StateFlow = _state.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + // Private state + private var subscriptionIDs: MutableMap = mutableMapOf() + private val noteIDs = mutableSetOf() // For deduplication + private var subscribedGeohashes: Set = emptySet() + + // Dependencies (injected via setters for flexibility) + private var relayLookup: (() -> NostrRelayManager)? = null + private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null + private var unsubscribeFunc: ((String) -> Unit)? = null + private var sendEventFunc: ((NostrEvent, List?, Long) -> Unit)? = null + private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null + + // Coroutine scope for background operations + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var liveLocationToken: Long? = null + private var subscribeRetryJob: Job? = null + private var initialLoadJob: Job? = null + + init { + LiveLocationPrivacyGate.addRevocationListener(::stop) + } + + /** + * Initialize dependencies + */ + fun initialize( + relayManager: () -> NostrRelayManager, + subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String, + unsubscribe: (String) -> Unit, + sendEvent: (NostrEvent, List?, Long) -> Unit, + deriveIdentity: (String) -> NostrIdentity + ) { + this.relayLookup = relayManager + this.subscribeFunc = subscribe + this.unsubscribeFunc = unsubscribe + this.sendEventFunc = sendEvent + this.deriveIdentityFunc = deriveIdentity + } + + /** + * Set geohash and start subscription + * iOS: Validates building-level precision (8 characters) + */ + fun setGeohash(newGeohash: String) { + val token = LiveLocationPrivacyGate.captureToken() ?: run { + stop() + return + } + val normalized = newGeohash.lowercase() + + if (_geohash.value == normalized && + liveLocationToken?.let(LiveLocationPrivacyGate::accepts) == true + ) { + return + } + + // Validate geohash (building-level precision: 8 chars) - matches iOS + if (!isValidBuildingGeohash(normalized)) { + Log.w(TAG, "LocationNotesManager rejected an invalid building geohash") + return + } + + // Cancel existing subscription + cancel() + if (!LiveLocationPrivacyGate.accepts(token)) return + liveLocationToken = token + + // Set loading state before clearing to prevent empty state flicker (iOS pattern) + _state.value = State.LOADING + _initialLoadComplete.value = false + _errorMessage.value = null + + // Clear notes + _notes.value = emptyList() + noteIDs.clear() + _geohash.value = normalized + + // Compute target geohashes: center + neighbors (±1) + val neighbors = try { + com.bitchat.android.geohash.Geohash.neighborsSamePrecision(normalized) + } catch (_: Exception) { emptySet() } + subscribedGeohashes = (neighbors + normalized).toSet() + + // Start new subscriptions for all cells + subscribeAll(token) + } + + /** + * Validate building-level geohash (precision 8) - matches iOS Geohash.isValidBuildingGeohash + */ + private fun isValidBuildingGeohash(geohash: String): Boolean { + if (geohash.length != 8) return false + val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz" + return geohash.all { it in base32Chars } + } + + /** + * Refresh notes for current geohash + */ + fun refresh() { + val token = LiveLocationPrivacyGate.captureToken() ?: run { + stop() + return + } + val currentGeohash = _geohash.value + if (currentGeohash == null) { + Log.w(TAG, "Cannot refresh - no geohash set") + return + } + + // Cancel and restart subscriptions for current ±1 set + cancel() + if (!LiveLocationPrivacyGate.accepts(token)) return + liveLocationToken = token + _notes.value = emptyList() + noteIDs.clear() + _initialLoadComplete.value = false + // Rebuild subscribedGeohashes and resubscribe + val neighbors = try { + com.bitchat.android.geohash.Geohash.neighborsSamePrecision(currentGeohash) + } catch (_: Exception) { emptySet() } + subscribedGeohashes = (neighbors + currentGeohash).toSet() + subscribeAll(token) + } + + /** + * Send a new location note + */ + fun send(content: String, nickname: String?) { + val token = LiveLocationPrivacyGate.captureToken() ?: run { + stop() + return + } + val currentGeohash = _geohash.value + if (currentGeohash == null) { + Log.w(TAG, "Cannot send note - no geohash set") + _errorMessage.value = "No location set" + return + } + + val trimmed = content.trim() + if (trimmed.isEmpty()) { + return + } + + // CRITICAL FIX: Get geo-specific relays for sending (matching iOS pattern) + // iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + var relays: List = emptyList() + try { + LiveLocationPrivacyGate.runIfAllowed(token) { + relays = RelayDirectory.closestRelaysForGeohash(currentGeohash, 5) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to look up location-note relays") + } + if (!LiveLocationPrivacyGate.accepts(token)) { + stop() + return + } + + // Check if we have relays (iOS pattern: guard !relays.isEmpty()) + if (relays.isEmpty()) { + Log.w(TAG, "Location-note send blocked because no relays are available") + _state.value = State.NO_RELAYS + _errorMessage.value = "No relays available" + return + } + + val deriveIdentity = deriveIdentityFunc + if (deriveIdentity == null) { + Log.e(TAG, "Cannot send note - deriveIdentity not initialized") + _errorMessage.value = "Not initialized" + return + } + + scope.launch { + try { + var identity: NostrIdentity? = null + val identityPrepared = withContext(Dispatchers.IO) { + LiveLocationPrivacyGate.runIfAllowed(token) { + identity = deriveIdentity(currentGeohash) + } + } + val preparedIdentity = identity + if (!identityPrepared || preparedIdentity == null || + !LiveLocationPrivacyGate.accepts(token) + ) return@launch + + val preparedEvent = withContext(Dispatchers.IO) { + NostrProtocol.createGeohashTextNote( + content = trimmed, + geohash = currentGeohash, + senderIdentity = preparedIdentity, + nickname = nickname + ) + } + if (!LiveLocationPrivacyGate.accepts(token)) return@launch + + // Optimistic local echo - add note immediately to UI + val localNote = Note( + id = preparedEvent.id, + pubkey = preparedEvent.pubkey, + content = trimmed, + createdAt = preparedEvent.createdAt, + nickname = nickname + ) + + if (!noteIDs.contains(preparedEvent.id)) { + noteIDs.add(preparedEvent.id) + val currentNotes = _notes.value ?: emptyList() + _notes.value = (currentNotes + localNote).sortedByDescending { it.createdAt } + + // Trim if exceeds max + if (noteIDs.size > MAX_NOTES_IN_MEMORY) { + trimOldestNotes() + } + } + + // CRITICAL FIX: Send to geo-specific relays (matching iOS pattern) + // iOS: dependencies.sendEvent(event, relays) + val sent = withContext(Dispatchers.IO) { + LiveLocationPrivacyGate.runIfAllowed(token) { + sendEventFunc?.invoke(preparedEvent, relays, token) + } + } + if (!sent) return@launch + + // Clear any error messages on successful send + _errorMessage.value = null + _state.value = State.READY + + } catch (e: Exception) { + Log.e(TAG, "Failed to send note: ${e.message}") + _errorMessage.value = "Failed to send: ${e.message}" + } + } + } + + /** + * Subscribe to location notes for current geohash + */ + private fun subscribeAll(token: Long) { + subscribeRetryJob?.cancel() + subscribeRetryJob = null + initialLoadJob?.cancel() + initialLoadJob = null + + if (!LiveLocationPrivacyGate.accepts(token)) { + stop() + return + } + val currentGeohash = _geohash.value + if (currentGeohash == null) { + Log.w(TAG, "Cannot subscribe - no geohash set") + _state.value = State.IDLE + return + } + + val subscribe = subscribeFunc + if (subscribe == null) { + Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly") + _state.value = State.LOADING + // Retry a few times in case initialization is racing the sheet open + subscribeRetryJob = scope.launch { + var attempts = 0 + while (attempts < 10 && + subscribeFunc == null && + LiveLocationPrivacyGate.accepts(token) + ) { + delay(300) + attempts++ + } + val subNow = subscribeFunc + if (subNow != null && LiveLocationPrivacyGate.accepts(token)) { + // Try again now that dependencies are ready + subscribeAll(token) + } else { + // Give UI a chance to show empty state rather than spinner forever + if (!_initialLoadComplete.value) { + _initialLoadComplete.value = true + _state.value = State.READY + } + } + } + return + } + + _state.value = State.LOADING + + // Subscribe for each geohash in the ±1 set + subscribedGeohashes.forEach { gh -> + if (!LiveLocationPrivacyGate.accepts(token)) return + val filter = NostrFilter.geohashNotes( + geohash = gh, + since = null, + limit = 200 + ) + val subId = "location-notes-${UUID.randomUUID()}" + try { + var id: String? = null + LiveLocationPrivacyGate.runIfAllowed(token) { + id = subscribe(filter, subId) { event -> handleEvent(event) } + } + id?.let { subscriptionIDs[gh] = it } + } catch (e: Exception) { + Log.e(TAG, "Failed to subscribe to location notes") + } + } + + // Mark initial load complete after brief delay to allow relay responses + initialLoadJob = scope.launch { + delay(2000) // Wait 2 seconds for initial batch + if (_geohash.value == currentGeohash && + LiveLocationPrivacyGate.accepts(token) && + !_initialLoadComplete.value + ) { + _initialLoadComplete.value = true + _state.value = State.READY + } + } + } + + /** + * Handle incoming event from subscription + */ + private fun handleEvent(event: NostrEvent) { + val token = liveLocationToken + if (token == null || !LiveLocationPrivacyGate.accepts(token)) return + + // Validate event + if (event.kind != NostrKind.TEXT_NOTE) { + Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}") + return + } + + if (!event.isValidSignature()) { + Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature") + return + } + + // Check for geohash tag + val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" } + if (geohashTag == null) { + Log.v(TAG, "Ignoring event without geohash tag: ${event.id.take(16)}...") + return + } + + // Check if matches current geohash + val eventGeohash = geohashTag[1] + if (!subscribedGeohashes.contains(eventGeohash)) { + return + } + + // Deduplicate + if (noteIDs.contains(event.id)) { + return + } + + // Extract nickname from tags + val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" } + val nickname = nicknameTag?.get(1) + + // Create note + val note = Note( + id = event.id, + pubkey = event.pubkey, + content = event.content, + createdAt = event.createdAt, + nickname = nickname + ) + + // Add to collection + noteIDs.add(event.id) + val currentNotes = _notes.value ?: emptyList() + _notes.value = (currentNotes + note).sortedByDescending { it.createdAt } + + // Trim if exceeds max + if (noteIDs.size > MAX_NOTES_IN_MEMORY) { + trimOldestNotes() + } + + // Update state + if (!_initialLoadComplete.value!!) { + _initialLoadComplete.value = true + } + _state.value = State.READY + } + + /** + * Trim oldest notes to stay within memory limit + */ + private fun trimOldestNotes() { + val currentNotes = _notes.value ?: return + if (currentNotes.size <= MAX_NOTES_IN_MEMORY) return + + val trimmed = currentNotes.sortedByDescending { it.createdAt }.take(MAX_NOTES_IN_MEMORY) + _notes.value = trimmed + + // Update note IDs set + noteIDs.clear() + noteIDs.addAll(trimmed.map { it.id }) + + Log.d(TAG, "Trimmed notes to $MAX_NOTES_IN_MEMORY (removed ${currentNotes.size - trimmed.size})") + } + + /** + * Clear error message - matches iOS clearError() + */ + fun clearError() { + _errorMessage.value = null + } + + /** + * Cancel subscription and clear state + */ + fun cancel() { + subscribeRetryJob?.cancel() + subscribeRetryJob = null + initialLoadJob?.cancel() + initialLoadJob = null + + if (subscriptionIDs.isNotEmpty()) { + subscriptionIDs.values.forEach { subId -> + try { + unsubscribeFunc?.invoke(subId) + } catch (_: Exception) { } + } + subscriptionIDs.clear() + } + subscribedGeohashes = emptySet() + _state.value = State.IDLE + } + + /** + * End the nearby-notes session and discard location-correlated UI state. + * Unlike [cancel], this also clears the target so a later activation can + * safely subscribe to the same building geohash again. + */ + fun stop() { + cancel() + liveLocationToken = null + _geohash.value = null + _notes.value = emptyList() + noteIDs.clear() + _initialLoadComplete.value = false + _errorMessage.value = null + } + + /** + * Cleanup resources + */ + fun cleanup() { + stop() + scope.cancel() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt new file mode 100644 index 00000000..a5f1a8ec --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt @@ -0,0 +1,130 @@ +package com.bitchat.android.nostr + +import androidx.annotation.MainThread +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Session-scoped consent gate for nearby location notes. + * + * Merely rendering the mesh timeline must not open a building-precision Nostr + * subscription. A subscription is eligible only after an explicit reveal and + * while the app is foregrounded and at least one nearby-notes surface is active. + */ +@MainThread +class NearbyNotesController internal constructor( + private val subscribe: (String) -> Unit, + private val unsubscribe: () -> Unit, +) { + private val _revealed = MutableStateFlow(false) + val revealed: StateFlow = _revealed.asStateFlow() + + private var activeHolders = 0 + private var locationEnabled = false + private var locationAuthorized = false + private var appForeground = false + private var buildingGeohash: String? = null + private var subscribedGeohash: String? = null + + /** + * Unlocks nearby notes for this process session. Deactivation deliberately + * does not reset consent, matching the iOS privacy model. + */ + fun reveal() { + if (_revealed.value) return + _revealed.value = true + reconcileSubscription() + } + + /** Holds the subscription while a nearby-notes surface is visible. */ + fun activate() { + activeHolders += 1 + reconcileSubscription() + } + + /** Releases a matching [activate] hold and unsubscribes after the last one. */ + fun deactivate() { + activeHolders = (activeHolders - 1).coerceAtLeast(0) + reconcileSubscription() + } + + /** Closes the live subscription whenever the process leaves the foreground. */ + fun updateAppForeground(isForeground: Boolean) { + appForeground = isForeground + reconcileSubscription() + } + + /** + * Updates the privacy-sensitive inputs independently of view activation. + * Permission revocation, location disable, or loss of the building cell + * immediately closes any live subscription. + */ + fun updateAvailability( + locationEnabled: Boolean, + locationAuthorized: Boolean, + buildingGeohash: String?, + ) { + this.locationEnabled = locationEnabled + this.locationAuthorized = locationAuthorized + this.buildingGeohash = buildingGeohash + ?.trim() + ?.lowercase() + ?.takeIf { it.isNotEmpty() } + reconcileSubscription() + } + + fun offersRevealHint(): Boolean = + !_revealed.value && + locationEnabled && + locationAuthorized && + buildingGeohash != null + + private fun reconcileSubscription() { + val target = buildingGeohash.takeIf { + activeHolders > 0 && + appForeground && + _revealed.value && + locationEnabled && + locationAuthorized + } + + if (subscribedGeohash != null && subscribedGeohash != target) { + unsubscribe() + subscribedGeohash = null + } + + if (target != null && subscribedGeohash == null) { + subscribe(target) + subscribedGeohash = target + } + } + + companion object { + val shared: NearbyNotesController by lazy { + val manager = LocationNotesManager.getInstance() + NearbyNotesController( + subscribe = manager::setGeohash, + unsubscribe = manager::stop, + ) + } + } +} + +/** + * Building precision is location-notes precision and remains private before a + * reveal. Explicit bookmarks remain eligible because saving one is itself an + * intentional location act. + */ +internal fun geohashesForSampling( + availableChannels: List, + bookmarks: Collection, + notesRevealed: Boolean, +): List = buildSet { + availableChannels + .filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING } + .mapTo(this) { it.geohash } + addAll(bookmarks) +}.toList() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt new file mode 100644 index 00000000..442840eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt @@ -0,0 +1,115 @@ +package com.bitchat.android.nostr + +import android.app.Application +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Process-owned Nostr event processing. + * + * Relay subscriptions must remain useful when no Activity exists, so their handlers cannot be + * borrowed from a ViewModel. This processor owns only application-scoped collaborators and writes + * messages to [AppStateStore], which the next UI instance hydrates from. + */ +internal class NostrBackgroundEventProcessor( + application: Application, + parentScope: CoroutineScope +) { + private val scope = CoroutineScope( + parentScope.coroutineContext + Dispatchers.IO.limitedParallelism(1) + ) + private val state = ChatState(scope) + private val dataManager = DataManager(application.applicationContext).apply { + state.setNickname(loadNickname()) + loadBlockedUsers() + loadGeohashBlockedUsers() + } + private val messageManager = MessageManager(state) + private val geohashRepository = GeohashRepository(application, state, dataManager) + private val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String): Boolean = false + override fun initiateHandshake(peerID: String) = Unit + override fun getMyPeerID(): String = "" + }, + trackUnreadMessages = false + ) + private val geohashMessageHandler = GeohashMessageHandler( + application = application, + repo = geohashRepository, + scope = scope, + dataManager = dataManager, + addChannelMessage = AppStateStore::addChannelMessage + ) + private val directMessageHandler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = ::updateDeliveryStatus, + scope = scope, + repo = geohashRepository, + dataManager = dataManager + ) + + init { + // Keep the headless state aligned with messages sent or received through other transports. + // This preserves duplicate detection and focused-conversation behavior without retaining UI. + scope.launch { + AppStateStore.privateMessages.collect(state::setPrivateChats) + } + scope.launch { + AppStateStore.nickname.collect(state::setNickname) + } + scope.launch { + AppStateStore.selectedPrivateChatPeer.collect(state::setSelectedPrivateChatPeer) + } + } + + fun onAccountDm(event: NostrEvent, identity: NostrIdentity) { + refreshBlockLists() + directMessageHandler.onGiftWrap(event, "", identity) + } + + fun onGeohashMessage(event: NostrEvent, geohash: String) { + refreshBlockLists() + geohashMessageHandler.onEvent(event, geohash) + } + + fun onGeohashDm(event: NostrEvent, geohash: String, identity: NostrIdentity) { + refreshBlockLists() + directMessageHandler.onGiftWrap(event, geohash, identity) + } + + fun conversationGeohash(conversationKey: String): String? = + geohashRepository.getConversationGeohash(conversationKey) + ?: GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String = + geohashRepository.displayNameForNostrPubkeyUI(pubkeyHex) + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = + geohashRepository.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + + private fun updateDeliveryStatus(messageId: String, status: DeliveryStatus) { + messageManager.updateMessageDeliveryStatus(messageId, status) + // The headless state may not yet contain a just-sent UI message. Update the process store + // unconditionally so a delivery/read receipt can never be lost during Activity handoff. + AppStateStore.updatePrivateMessageStatus(messageId, status) + } + + private fun refreshBlockLists() { + dataManager.loadBlockedUsers() + dataManager.loadGeohashBlockedUsers() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt new file mode 100644 index 00000000..a895f612 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -0,0 +1,272 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.mesh.PowerManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.security.SecureRandom +import kotlin.random.asKotlinRandom + +/** + * Process-owned Nostr connectivity and low-volume background subscriptions. + * + * Stable subscriptions dispatch directly to a process-owned event processor. The UI hydrates from + * the process state store, so relay events remain useful without retaining a cleared ViewModel. + */ +object NostrBackgroundRuntime { + private const val TAG = "NostrBackground" + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val random = SecureRandom().asKotlinRandom() + private val lock = Any() + + @Volatile private var initialized = false + @Volatile private var activeGeohash: String? = null + @Volatile private var activeGeohashLiveToken: Long? = null + @Volatile private var conversationGeohash: String? = null + private lateinit var application: Application + private lateinit var subscriptions: NostrSubscriptionManager + private lateinit var locationChannels: LocationChannelManager + private lateinit var eventProcessor: NostrBackgroundEventProcessor + + fun initialize(app: Application) { + synchronized(lock) { + if (initialized) return + application = app + locationChannels = LocationChannelManager.getInstance(app) + eventProcessor = NostrBackgroundEventProcessor(app, scope) + subscriptions = NostrSubscriptionManager( + app, + owner = NostrRelayManager.OWNER_BACKGROUND + ) + // Publish readiness only after every process-owned dependency is available. + initialized = true + } + + subscriptions.connect() + subscribeAccountDm() + observeSelectedChannel() + startPresenceScheduler() + } + + fun resetSubscriptions() { + if (!initialized) return + subscriptions.unsubscribeAllOwned() + scope.launch { + // Let CLOSE frames be queued before replacing the deterministic IDs. + delay(100) + subscribeAccountDm() + activeGeohash?.let { geohash -> + subscribeSelectedGeohash(geohash, activeGeohashLiveToken) + } + } + } + + fun ensureConversationDm(geohash: String) { + if (!initialized || geohash == conversationGeohash) return + val selectedLiveToken = activeGeohashLiveToken + val selectedChannelSubscriptionIsUsable = + geohash == activeGeohash && + (selectedLiveToken == null || + LiveLocationPrivacyGate.accepts(selectedLiveToken)) + if (selectedChannelSubscriptionIsUsable) return + conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") } + conversationGeohash = geohash + subscribeGeohashDm( + geohash = geohash, + subscriptionId = "geo-dm-conversation-$geohash", + liveLocationToken = null + ) + } + + private fun subscribeAccountDm() { + val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = "chat-messages", + handler = { event -> + eventProcessor.onAccountDm(event, identity) + } + ) + } + + private fun observeSelectedChannel() { + scope.launch { + locationChannels.selectedChannel.collectLatest { channel -> + val locationChannel = channel as? ChannelID.Location + val next = locationChannel?.channel?.geohash + val nextToken = locationChannel?.let { + locationChannels.liveLocationTokenForSelectedChannel(it.channel) + } + val previous = activeGeohash + if (previous == next && activeGeohashLiveToken == nextToken) { + return@collectLatest + } + + previous?.let { + subscriptions.unsubscribe("geohash-$it") + subscriptions.unsubscribe("geo-dm-$it") + } + activeGeohash = next + activeGeohashLiveToken = nextToken + if (conversationGeohash == next) { + subscriptions.unsubscribe("geo-dm-conversation-$next") + conversationGeohash = null + } + next?.let { geohash -> + val isLiveDerived = + locationChannels.isSelectedChannelLiveDerived(locationChannel.channel) + if (!isLiveDerived || nextToken != null) { + subscribeSelectedGeohash(geohash, nextToken) + } + } + } + } + } + + private fun subscribeSelectedGeohash( + geohash: String, + liveLocationToken: Long? + ) { + subscriptions.subscribeGeohashMessages( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3_600_000L, + limit = 200, + id = "geohash-$geohash", + handler = { event -> eventProcessor.onGeohashMessage(event, geohash) }, + liveLocationToken = liveLocationToken + ) + subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken) + } + + private fun subscribeGeohashDm( + geohash: String, + subscriptionId: String, + liveLocationToken: Long? + ) { + scope.launch { + val subscribe = { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = subscriptionId, + handler = { event -> + eventProcessor.onGeohashDm(event, geohash, identity) + }, + liveLocationToken = liveLocationToken + ) + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + + if (liveLocationToken == null) { + subscribe() + } else { + LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, subscribe) + } + } + } + + private fun startPresenceScheduler() { + val powerManager = PowerManager.getInstance(application) + scope.launch { + val nostrSchedule = powerManager.profile + .map { it.nostr } + .distinctUntilChanged() + combine( + locationChannels.availableChannels, + LiveLocationPrivacyGate.enabled, + nostrSchedule + ) { channels, liveLocationEnabled, schedule -> + val targets = GeohashNostrPrivacyPolicy.livePresenceTargets( + availableChannels = channels, + liveLocationEnabled = liveLocationEnabled + ) + targets to schedule + }.collectLatest { (targets, schedule) -> + if (targets.isEmpty()) return@collectLatest + while (true) { + val waitMs = if (schedule.presenceHeartbeatMaxMs > schedule.presenceHeartbeatMinMs) { + random.nextLong( + schedule.presenceHeartbeatMinMs, + schedule.presenceHeartbeatMaxMs + 1 + ) + } else { + schedule.presenceHeartbeatMinMs + } + delay(waitMs) + + // Send every target in one wake window; do not spread the batch over seconds. + targets.forEach { geohash -> + try { + val token = LiveLocationPrivacyGate.captureToken() + ?: return@forEach + if (geohash !in GeohashNostrPrivacyPolicy.livePresenceTargets( + availableChannels = locationChannels.availableChannels.value, + liveLocationEnabled = true + ) + ) return@forEach + + var identity: NostrIdentity? = null + LiveLocationPrivacyGate.runIfAllowed(token) { + identity = NostrIdentityBridge.deriveIdentity(geohash, application) + } + val preparedIdentity = identity ?: return@forEach + if (!LiveLocationPrivacyGate.accepts(token)) return@forEach + val event = NostrProtocol.createGeohashPresenceEvent( + geohash, + preparedIdentity + ) + LiveLocationPrivacyGate.runIfAllowed(token) { + NostrRelayManager.getInstance(application).sendEventToGeohash( + event = event, + geohash = geohash, + includeDefaults = false, + nRelays = 5, + liveLocationToken = token + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}") + } + } + } + } + } + } + + fun conversationGeohash(conversationKey: String): String? = + if (initialized) eventProcessor.conversationGeohash(conversationKey) + else GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String? = + if (initialized) eventProcessor.displayNameForNostrPubkey(pubkeyHex) else null + + fun displayNameForGeohashConversation( + pubkeyHex: String, + sourceGeohash: String + ): String? = if (initialized) { + eventProcessor.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + } else { + null + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index b95f15d7..0257c756 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -2,9 +2,10 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * High-level Nostr client that manages identity, connections, and messaging @@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) { private var currentIdentity: NostrIdentity? = null // Client state - private val _isInitialized = MutableLiveData() - val isInitialized: LiveData = _isInitialized + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() - private val _currentNpub = MutableLiveData() - val currentNpub: LiveData = _currentNpub + private val _currentNpub = MutableStateFlow(null) + val currentNpub: StateFlow = _currentNpub.asStateFlow() // Message processing private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) { currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (currentIdentity != null) { - _currentNpub.postValue(currentIdentity!!.npub) + _currentNpub.value = currentIdentity!!.npub Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}") // Connect to relays relayManager.connect() - _isInitialized.postValue(true) + _isInitialized.value = true Log.i(TAG, "✅ Nostr client initialized successfully") } else { Log.e(TAG, "❌ Failed to load/create Nostr identity") - _isInitialized.postValue(false) + _isInitialized.value = false } } catch (e: Exception) { Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}") - _isInitialized.postValue(false) + _isInitialized.value = false } } } @@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) { fun shutdown() { Log.d(TAG, "Shutting down Nostr client") relayManager.disconnect() - _isInitialized.postValue(false) + _isInitialized.value = false } /** @@ -169,7 +170,7 @@ class NostrClient private constructor(private val context: Context) { // Derive geohash-specific identity val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context) - // Create ephemeral event + // Create ephemeral event (with PoW if enabled) val event = NostrProtocol.createEphemeralGeohashEvent( content = content, geohash = geohash, @@ -179,7 +180,7 @@ class NostrClient private constructor(private val context: Context) { relayManager.sendEvent(event) - Log.i(TAG, "📤 Sent geohash message to #$geohash") + Log.i(TAG, "📤 Sent geohash message") onSuccess?.invoke() } catch (e: Exception) { @@ -208,7 +209,7 @@ class NostrClient private constructor(private val context: Context) { } }) - Log.i(TAG, "🌍 Subscribed to geohash channel: #$geohash") + Log.i(TAG, "🌍 Subscribed to geohash channel") } /** @@ -216,7 +217,7 @@ class NostrClient private constructor(private val context: Context) { */ fun unsubscribeFromGeohash(geohash: String) { relayManager.unsubscribe("geohash-$geohash") - Log.i(TAG, "Unsubscribed from geohash channel: #$geohash") + Log.i(TAG, "Unsubscribed from geohash channel") } /** @@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) { /** * Get relay connection status */ - val relayConnectionStatus: LiveData = relayManager.isConnected + val relayConnectionStatus: StateFlow = relayManager.isConnected /** * Get relay information */ - val relayInfo: LiveData> = relayManager.relays + val relayInfo: StateFlow> = relayManager.relays // MARK: - Private Methods @@ -281,6 +282,21 @@ class NostrClient private constructor(private val context: Context) { handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit ) { try { + if (!event.isValidSignature()) { + Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... with invalid signature") + return + } + + // Check Proof of Work validation for incoming geohash events + val powSettings = PoWPreferenceManager.getCurrentSettings() + if (powSettings.enabled && powSettings.difficulty > 0) { + if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) { + Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})") + return + } + Log.v(TAG, "✅ PoW validation passed for geohash event ${event.id.take(8)}...") + } + // Extract nickname from tags val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt index b25499dd..1bbdc972 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrCrypto.kt @@ -21,7 +21,9 @@ import java.math.BigInteger * Includes secp256k1 operations, ECDH, and NIP-44 encryption */ object NostrCrypto { - + + internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 79_200 + private val secureRandom = SecureRandom() // NIP-44 v2 only @@ -317,9 +319,9 @@ object NostrCrypto { } /** - * Random timestamp up to maxPastSeconds in the past (default 2 days) + * Random timestamp in the past, defaulting to 22 hours to leave 2 hours of slack inside iOS's 24-hour lookback. */ - fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int { + fun randomizeTimestampUpToPast(maxPastSeconds: Int = NIP17_DEFAULT_MAX_PAST_SECONDS): Int { val now = (System.currentTimeMillis() / 1000).toInt() val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0 return now - offset diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt new file mode 100644 index 00000000..4125d5ca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -0,0 +1,322 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.favorites.FavoriteControlMessage +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.services.SeenMessageStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.PrivateChatManager +import com.bitchat.android.ui.PrivateMessageOrigin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Date + +class NostrDirectMessageHandler( + private val application: Application, + private val state: ChatState, + private val privateChatManager: PrivateChatManager, + private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit, + private val scope: CoroutineScope, + private val repo: GeohashRepository, + private val dataManager: com.bitchat.android.ui.DataManager, + private val seenStoreProvider: () -> SeenMessageStore = { + SeenMessageStore.getInstance(application) + } +) { + companion object { private const val TAG = "NostrDirectMessageHandler" } + + private val seenStore by lazy(seenStoreProvider) + + // Simple event deduplication + private val processedIds = ArrayDeque() + private val seen = HashSet() + private val max = 2000 + + private fun dedupe(id: String): Boolean { + if (seen.contains(id)) return true + seen.add(id) + processedIds.addLast(id) + if (processedIds.size > max) { + val old = processedIds.removeFirst() + seen.remove(old) + } + return false + } + + fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { + scope.launch { + try { + if (dedupe(giftWrap.id)) return@launch + + val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt + if (messageAge > 173700) return@launch // 48 hours + 15 mins + + val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) + if (decryptResult == null) { + Log.w(TAG, "Failed to decrypt Nostr message") + return@launch + } + + val (content, rawSenderPubkey, rumorTimestamp) = decryptResult + val senderPubkey = rawSenderPubkey.lowercase() + + // If sender is blocked for geohash contexts, drop any events from this pubkey + // Applies to both geohash DMs (geohash != "") and account DMs (geohash == "") + if (dataManager.isGeohashUserBlocked(senderPubkey)) return@launch + if (!content.startsWith("bitchat1:")) return@launch + + val base64Content = content.removePrefix("bitchat1:") + val packetData = base64URLDecode(base64Content) ?: return@launch + val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch + + if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch + + val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch + val messageTimestamp = Date(rumorTimestamp * 1000L) + val convKey = "nostr_${senderPubkey.take(16)}" + repo.putNostrKeyMapping(convKey, senderPubkey) + com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) + if (geohash.isNotEmpty()) { + // Remember which geohash this conversation belongs to so we can subscribe on-demand + repo.setConversationGeohash(convKey, geohash) + GeohashConversationRegistry.set(convKey, geohash) + } + + // Ensure sender appears in geohash people list even if they haven't posted publicly yet + if (geohash.isNotEmpty()) { + // Cache a best-effort nickname and mark as participant + val cached = repo.getCachedNickname(senderPubkey) + if (cached == null) { + val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#") + repo.cacheNickname(senderPubkey, base) + } + repo.updateParticipant(geohash, senderPubkey, messageTimestamp) + } + + val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey) + val conversationID = ContactDirectory.canonicalConversationId(convKey) + + processNoisePayload(noisePayload, conversationID, senderNickname, messageTimestamp, senderPubkey, identity) + + } catch (e: Exception) { + Log.e(TAG, "onGiftWrap error: ${e.message}") + } + } + } + + private suspend fun processNoisePayload( + payload: NoisePayload, + conversationID: String, + senderNickname: String, + timestamp: Date, + senderPubkey: String, + recipientIdentity: NostrIdentity + ) { + when (payload.type) { + NoisePayloadType.PRIVATE_MESSAGE -> { + val pm = PrivateMessagePacket.decode(payload.data) ?: return + val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList() + if (existingMessages.any { it.id == pm.messageID }) return + + val favoriteControl = FavoriteControlMessage.parse(pm.content) + if (favoriteControl != null) { + val admitted = handleFavoriteControl( + favoriteControl, + conversationID, + senderNickname, + timestamp, + senderPubkey + ) + if (!admitted) return + if (!seenStore.hasDelivered(pm.messageID)) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) + seenStore.markDelivered(pm.messageID) + } + return + } + + val message = BitchatMessage( + id = pm.messageID, + sender = senderNickname, + content = pm.content, + timestamp = timestamp, + isRelay = false, + isPrivate = true, + recipientNickname = state.getNicknameValue(), + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey, + deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) + ) + + val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID + val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID) + + val admitted = withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessageDurably( + message = message, + suppressUnread = suppressUnread, + origin = PrivateMessageOrigin.NOSTR + ) + } + if (!admitted) return + + if (!seenStore.hasDelivered(pm.messageID)) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) + seenStore.markDelivered(pm.messageID) + } + + if (isViewing && !suppressUnread) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity) + seenStore.markReadLocally(pm.messageID) + seenStore.markReadReceiptSent(pm.messageID) + } + } + NoisePayloadType.DELIVERED -> { + val messageId = String(payload.data, Charsets.UTF_8) + withContext(Dispatchers.Main) { + updateDeliveryStatus( + messageId, + DeliveryStatus.Delivered(conversationID, Date()) + ) + } + } + NoisePayloadType.READ_RECEIPT -> { + val messageId = String(payload.data, Charsets.UTF_8) + withContext(Dispatchers.Main) { + updateDeliveryStatus( + messageId, + DeliveryStatus.Read(conversationID, Date()) + ) + } + } + NoisePayloadType.FILE_TRANSFER -> { + // Properly handle encrypted file transfer + val file = BitchatFilePacket.decode(payload.data) + if (file != null) { + val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() + val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file) + val message = BitchatMessage( + id = uniqueMsgId, + sender = senderNickname, + content = savedPath, + type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), + timestamp = timestamp, + isRelay = false, + isPrivate = true, + recipientNickname = state.getNicknameValue(), + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey + ) + Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") + val admitted = withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessageDurably( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + } + if (!admitted) { + com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths( + application, + listOf(savedPath) + ) + } + } else { + Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID") + } + } + NoisePayloadType.VERIFY_CHALLENGE, + NoisePayloadType.VERIFY_RESPONSE, + NoisePayloadType.VOICE_FRAME, + NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. + } + } + + private suspend fun handleFavoriteControl( + control: FavoriteControlMessage, + conversationID: String, + senderNickname: String, + timestamp: Date, + senderPubkey: String + ): Boolean { + return try { + val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey) + val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) } + ?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey) + + if (noiseKey == null) { + Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...") + return false + } + + FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite) + senderNpub?.let { FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it) } + val targetConversationID = ContactDirectory.canonicalConversationId(conversationID) + + val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + val displayName = relationship + ?.peerNickname + ?.takeUnless { it.equals("Unknown", ignoreCase = true) } + ?: senderNickname + val guidance = if (control.isFavorite) { + if (relationship?.isFavorite == true) { + " - mutual! You can continue DMs via Nostr when out of mesh." + } else { + " - favorite back to continue DMs later." + } + } else { + ". DMs over Nostr will pause unless you both favorite again." + } + val action = if (control.isFavorite) "favorited" else "unfavorited" + val systemMessage = BitchatMessage( + sender = "system", + content = "$displayName $action you$guidance", + timestamp = timestamp, + isRelay = false, + isPrivate = true, + senderPeerID = targetConversationID + ) + + withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessageDurably( + message = systemMessage, + suppressUnread = true, + origin = PrivateMessageOrigin.NOSTR + ) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}") + false + } + } + + private fun base64URLDecode(input: String): ByteArray? { + return try { + val padded = input.replace("-", "+") + .replace("_", "/") + .let { str -> + val padding = (4 - str.length % 4) % 4 + str + "=".repeat(padding) + } + android.util.Base64.decode(padded, android.util.Base64.DEFAULT) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode base64url: ${e.message}") + null + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt index 968bec16..755d9d34 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt @@ -2,15 +2,16 @@ package com.bitchat.android.nostr import android.util.Base64 import android.util.Log +import com.bitchat.android.mesh.MeshPacketUtils import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import com.bitchat.android.services.ContactIdentityResolver import java.util.* /** * BitChat-over-Nostr Adapter - * Direct port from iOS implementation for 100% compatibility */ object NostrEmbeddedBitChat { @@ -46,7 +47,7 @@ object NostrEmbeddedBitChat { timestamp = System.currentTimeMillis().toULong(), payload = payload, signature = null, - ttl = 7u + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS ) val data = packet.toBinaryData() ?: return null @@ -86,7 +87,7 @@ object NostrEmbeddedBitChat { timestamp = System.currentTimeMillis().toULong(), payload = payload, signature = null, - ttl = 7u + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS ) val data = packet.toBinaryData() ?: return null @@ -123,7 +124,7 @@ object NostrEmbeddedBitChat { timestamp = System.currentTimeMillis().toULong(), payload = payload, signature = null, - ttl = 7u + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS ) val data = packet.toBinaryData() ?: return null @@ -158,7 +159,7 @@ object NostrEmbeddedBitChat { timestamp = System.currentTimeMillis().toULong(), payload = payload, signature = null, - ttl = 7u + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS ) val data = packet.toBinaryData() ?: return null @@ -173,26 +174,12 @@ object NostrEmbeddedBitChat { * Normalize recipient peer ID (matches iOS implementation) */ private fun normalizeRecipientPeerID(recipientPeerID: String): String { - try { - val maybeData = hexStringToByteArray(recipientPeerID) - return when (maybeData.size) { - 32 -> { - // Treat as Noise static public key; derive peerID from fingerprint - // For now, return first 8 bytes as hex (simplified) - maybeData.take(8).joinToString("") { "%02x".format(it) } - } - 8 -> { - // Already an 8-byte peer ID - recipientPeerID - } - else -> { - // Fallback: return as-is (expecting 16 hex chars) - recipientPeerID - } - } - } catch (e: Exception) { - // Fallback: return as-is - return recipientPeerID + val clean = recipientPeerID.trim().lowercase() + return when { + ContactIdentityResolver.isNoiseKeyHex(clean) -> + ContactIdentityResolver.peerIdForNoiseKeyHex(clean) ?: clean + ContactIdentityResolver.isMeshPeerId(clean) -> clean + else -> recipientPeerID } } @@ -210,25 +197,6 @@ object NostrEmbeddedBitChat { /** * Convert hex string to byte array */ - private fun hexStringToByteArray(hexString: String): ByteArray { - if (hexString.length % 2 != 0) { - return ByteArray(8) // Return 8-byte array filled with zeros - } - - val result = ByteArray(8) { 0 } // Exactly 8 bytes like iOS - var tempID = hexString - var index = 0 - - while (tempID.length >= 2 && index < 8) { - val hexByte = tempID.substring(0, 2) - val byte = hexByte.toIntOrNull(16)?.toByte() - if (byte != null) { - result[index] = byte - } - tempID = tempID.substring(2) - index++ - } - - return result - } + private fun hexStringToByteArray(hexString: String): ByteArray = + MeshPacketUtils.hexStringToByteArray(hexString) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 785b41f3..92752b17 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -215,6 +215,7 @@ object NostrKind { const val SEAL = 13 // NIP-17 sealed event const val GIFT_WRAP = 1059 // NIP-17 gift wrap const val EPHEMERAL_EVENT = 20000 // For geohash channels + const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt index ca54462d..138ffb0a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt @@ -22,7 +22,7 @@ class NostrEventDeduplicator( ) { companion object { private const val TAG = "NostrDeduplicator" - private const val DEFAULT_CAPACITY = 10000 + private const val DEFAULT_CAPACITY = com.bitchat.android.util.AppConstants.Nostr.DEFAULT_DEDUP_CAPACITY @Volatile private var INSTANCE: NostrEventDeduplicator? = null @@ -79,9 +79,9 @@ class NostrEventDeduplicator( * @return true if the event is a duplicate (already seen), false if it's new */ fun isDuplicate(eventId: String): Boolean { - totalChecks++ - synchronized(lruLock) { + totalChecks++ + val existingNode = nodeMap[eventId] if (existingNode != null) { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index 0525d184..df67822f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -32,9 +32,22 @@ data class NostrFilter( } /** - * Create filter for geohash-scoped ephemeral events (kind 20000) + * Create filter for geohash-scoped ephemeral events (kind 20000 and 20001) */ - fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + + /** + * Create filter for geohash-scoped chat messages only (kind 20000). + * Low-volume; kept subscribed in the background so messages keep arriving. + */ + fun geohashMessages(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter { return NostrFilter( kinds = listOf(NostrKind.EPHEMERAL_EVENT), since = since?.let { (it / 1000).toInt() }, @@ -42,6 +55,20 @@ data class NostrFilter( limit = limit ) } + + /** + * Create filter for geohash-scoped presence heartbeats only (kind 20001). + * High-volume firehose (every participant rebroadcasts ~every 60s); only used + * to refresh the participant list, so it is paused while backgrounded. + */ + fun geohashPresence(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.GEOHASH_PRESENCE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } /** * Create filter for text notes from specific authors @@ -55,6 +82,18 @@ data class NostrFilter( ) } + /** + * Create filter for geohash-scoped text notes (kind=1 with g tag) + */ + fun geohashNotes(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.TEXT_NOTE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + /** * Create filter for specific event IDs */ @@ -193,4 +232,12 @@ data class NostrFilter( return "NostrFilter(${parts.joinToString(", ")})" } + + /** + * Get geohash value from g tag filter (if present) + * Returns the first geohash in the filter or null if none + */ + fun getGeohash(): String? { + return tagFilters?.get("g")?.firstOrNull() + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt deleted file mode 100644 index 687f79bc..00000000 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ /dev/null @@ -1,1799 +0,0 @@ - -package com.bitchat.android.nostr - -import android.app.Application -import android.util.Log -import androidx.lifecycle.viewModelScope -import com.bitchat.android.mesh.BluetoothMeshService -import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MessageManager -import com.bitchat.android.ui.MeshDelegateHandler -import com.bitchat.android.ui.PrivateChatManager -import com.bitchat.android.ui.GeoPerson -import com.bitchat.android.ui.colorForPeerSeed -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.* - -/** - * Service responsible for all Nostr and Geohash business logic extracted from ChatViewModel - * Maintains 100% iOS compatibility and exact same functionality - */ -class NostrGeohashService( - private val application: Application, - private val state: ChatState, - private val messageManager: MessageManager, - private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, - private val coroutineScope: CoroutineScope, - private val dataManager: com.bitchat.android.ui.DataManager, - private val notificationManager: com.bitchat.android.ui.NotificationManager -) { - - companion object { - private const val TAG = "NostrGeohashService" - - @Volatile - private var INSTANCE: NostrGeohashService? = null - - fun getInstance(application: Application): NostrGeohashService { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: throw IllegalStateException("NostrGeohashService not initialized. Call initialize() first.") - } - } - - fun initialize( - application: Application, - state: ChatState, - messageManager: MessageManager, - privateChatManager: PrivateChatManager, - meshDelegateHandler: MeshDelegateHandler, - coroutineScope: CoroutineScope, - dataManager: com.bitchat.android.ui.DataManager, - notificationManager: com.bitchat.android.ui.NotificationManager - ): NostrGeohashService { - return synchronized(this) { - INSTANCE ?: NostrGeohashService( - application, - state, - messageManager, - privateChatManager, - meshDelegateHandler, - coroutineScope, - dataManager, - notificationManager - ).also { INSTANCE = it } - } - } - } - - // MARK: - Nostr Message Integration Properties - - private val processedNostrEvents = mutableSetOf() - private val processedNostrEventOrder = mutableListOf() - private val maxProcessedNostrEvents = 2000 - // removed unused processedNostrAcks - private val nostrKeyMapping = mutableMapOf() // senderPeerID -> nostrPubkey - - // MARK: - Geohash Participant Tracking Properties - - private val geohashParticipants = mutableMapOf>() // geohash -> participantId -> lastSeen - private var geohashSamplingJob: Job? = null - private var geoParticipantsTimer: Job? = null - - // MARK: - Geohash Message History Properties - - private val geohashMessageHistory = mutableMapOf>() // geohash -> messages - private val maxGeohashMessages = 1000 // Maximum messages per geohash - - // MARK: - Location Channel Management Properties - - private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null - private var currentGeohashSubscriptionId: String? = null - private var currentGeohashDmSubscriptionId: String? = null - private var currentGeohash: String? = null - private var geoNicknames: MutableMap = mutableMapOf() // pubkeyHex(lowercased) -> nickname - - // MARK: - Initialization - - /** - * Initialize Nostr relay subscriptions for gift wraps and geohash events - */ - fun initializeNostrIntegration() { - coroutineScope.launch { - val nostrRelayManager = NostrRelayManager.getInstance(application) - - // Connect to relays - nostrRelayManager.connect() - - // Get current Nostr identity - val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (currentIdentity == null) { - Log.w(TAG, "No Nostr identity available for subscriptions") - return@launch - } - - // Subscribe to gift wraps (NIP-17 private messages) - val dmFilter = NostrFilter.giftWrapsFor( - pubkey = currentIdentity.publicKeyHex, - since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) - ) - - nostrRelayManager.subscribe( - filter = dmFilter, - id = "chat-messages", - handler = { event -> - handleNostrMessage(event) - } - ) - - Log.i(TAG, "✅ Nostr integration initialized with gift wrap subscription") - } - } - - /** - * Panic/reset flow for Nostr + geohash systems. - * - Disconnect and clear all Nostr relay subscriptions and caches - * - Delete device-wide Nostr keys and per-geohash seed cache - * - Clear geohash participants, nicknames, message history - * - Recreate identity and reconnect; reinitialize subscriptions from scratch - */ - fun panicResetNostrAndGeohash() { - coroutineScope.launch { - try { - val relayManager = NostrRelayManager.getInstance(application) - - // 1) Disconnect from all relays - relayManager.disconnect() - - // 2) Clear all subscription tracking and caches - relayManager.clearAllSubscriptions() - - // 3) Clear Nostr identity (npub/private) and geohash identity cache/seed - try { - NostrIdentityBridge.clearAllAssociations(application) - } catch (e: Exception) { - Log.e(TAG, "Failed to clear Nostr associations: ${e.message}") - } - - // 4) Clear local geohash state (participants, nicknames, message history) - try { - geohashParticipants.clear() - geoNicknames.clear() - geohashMessageHistory.clear() - processedNostrEvents.clear() - processedNostrEventOrder.clear() - currentGeohashSubscriptionId = null - currentGeohashDmSubscriptionId = null - currentGeohash = null - state.setGeohashPeople(emptyList()) - state.setTeleportedGeo(emptySet()) - state.setGeohashParticipantCounts(emptyMap()) - // Stop any timers/jobs - geohashSamplingJob?.cancel() - geohashSamplingJob = null - geoParticipantsTimer?.cancel() - geoParticipantsTimer = null - } catch (e: Exception) { - Log.e(TAG, "Failed to clear geohash state: ${e.message}") - } - - // 5) Recreate identity and reconnect, then re-subscribe - try { - // Touch identity to recreate - val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (identity == null) { - Log.w(TAG, "No identity after reset; skipping subscriptions") - return@launch - } - - // Reconnect to relays and reinitialize subscriptions - relayManager.connect() - initializeNostrIntegration() - // Re-establish location channel state observers and subscriptions - initializeLocationChannelState() - } catch (e: Exception) { - Log.e(TAG, "Failed to reinitialize Nostr after reset: ${e.message}") - } - } catch (e: Exception) { - Log.e(TAG, "panicResetNostrAndGeohash error: ${e.message}") - } - } - } - - /** - * Initialize location channel state - */ - fun initializeLocationChannelState() { - try { - // Initialize location channel manager safely - locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - - // Observe location channel manager state and trigger channel switching - locationChannelManager?.selectedChannel?.observeForever { channel -> - state.setSelectedLocationChannel(channel) - // CRITICAL FIX: Switch to the channel when selection changes - switchLocationChannel(channel) - } - - locationChannelManager?.teleported?.observeForever { teleported -> - state.setIsTeleported(teleported) - } - - Log.d(TAG, "✅ Location channel state initialized successfully") - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to initialize location channel state: ${e.message}") - // Set default values in case of failure - state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) - state.setIsTeleported(false) - } - } - - // MARK: - Message Sending - - /** - * Send message to geohash channel via Nostr ephemeral event - */ - fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { - coroutineScope.launch { - try { - val identity = NostrIdentityBridge.deriveIdentity( - forGeohash = channel.geohash, - context = application - ) - - val teleported = state.isTeleported.value ?: false - - val event = NostrProtocol.createEphemeralGeohashEvent( - content = content, - geohash = channel.geohash, - senderIdentity = identity, - nickname = nickname, - teleported = teleported - ) - - val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.sendEventToGeohash( - event = event, - geohash = channel.geohash, - includeDefaults = false, - nRelays = 5 - ) - - Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}") - - // Add local echo message - val localMessage = BitchatMessage( - sender = nickname ?: myPeerID, - content = content, - timestamp = Date(), - isRelay = false, - senderPeerID = "geohash:${channel.geohash}", - channel = "#${channel.geohash}" - ) - - // Store our own message in geohash history - storeGeohashMessage(channel.geohash, localMessage) - messageManager.addMessage(localMessage) - - } catch (e: Exception) { - Log.e(TAG, "Failed to send geohash message: ${e.message}") - } - } - } - - // MARK: - Nostr Message Handling - - /** - * Handle incoming Nostr message (gift wrap) - */ - private fun handleNostrMessage(giftWrap: NostrEvent) { - // Offload processing to avoid blocking UI - coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { - // Simple deduplication - if (processedNostrEvents.contains(giftWrap.id)) return@launch - processedNostrEvents.add(giftWrap.id) - - // Manage deduplication cache size - processedNostrEventOrder.add(giftWrap.id) - if (processedNostrEventOrder.size > maxProcessedNostrEvents) { - val oldestId = processedNostrEventOrder.removeAt(0) - processedNostrEvents.remove(oldestId) - } - - // Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) { // 48 hours + 15 minutes - return@launch - } - - Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...") - - // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID - - val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (currentIdentity == null) { - Log.w(TAG, "No Nostr identity available for decryption") - return@launch - } - - try { - val decryptResult = NostrProtocol.decryptPrivateMessage( - giftWrap = giftWrap, - recipientIdentity = currentIdentity - ) - - if (decryptResult == null) { - Log.w(TAG, "Failed to decrypt Nostr message") - return@launch - } - - val (content, senderPubkey, rumorTimestamp) = decryptResult - - // Expect embedded BitChat packet content - if (!content.startsWith("bitchat1:")) { - Log.d(TAG, "Ignoring non-embedded Nostr DM content") - return@launch - } - - val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) - if (packetData == null) { - Log.e(TAG, "Failed to decode base64url BitChat packet") - return@launch - } - - val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) - if (packet == null) { - Log.e(TAG, "Failed to parse embedded BitChat packet from Nostr DM") - return@launch - } - - // Only process noiseEncrypted envelope for private messages/receipts - if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) { - Log.w(TAG, "Unsupported embedded packet type: ${packet.type}") - return@launch - } - - // Validate recipient if present - packet.recipientID?.let { rid -> - val ridHex = rid.joinToString("") { "%02x".format(it) } - // Note: myPeerID needs to be passed in as parameter - // if (ridHex != myPeerID) return - } - - // Parse plaintext typed payload (NoisePayload) - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) - if (noisePayload == null) { - Log.e(TAG, "Failed to parse embedded NoisePayload") - return@launch - } - - // Map sender by Nostr pubkey to Noise key when possible - val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey) - val messageTimestamp = Date(rumorTimestamp * 1000L) - - // If we know the Noise key, try to resolve a currently connected mesh peer ID for it - val targetPeerID: String = if (senderNoiseKey != null) { - val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) - if (meshPeerId != null) { - // Also unify existing noise-hex/nostr-temp chats into this mesh peer - val noiseHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - val tempKey = "nostr_${senderPubkey.take(16)}" - unifyChatsIntoPeer(meshPeerId, listOf(noiseHex, tempKey)) - - // If currently viewing the temporary or noise-hex chat, auto-switch to mesh peer - val selected = state.getSelectedPrivateChatPeerValue() - if (selected == noiseHex || selected == tempKey) { - state.setSelectedPrivateChatPeer(meshPeerId) - } - meshPeerId - } else { - senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - } - } else { - "nostr_${senderPubkey.take(16)}" - } - - // Prefer nickname from mesh when connected; else fallback to favorites - val senderNickname: String = if (senderNoiseKey != null) { - val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) - if (meshPeerId != null) { - // Use live mesh nickname if available - meshDelegateHandler.getPeerInfo(meshPeerId)?.nickname - ?: getFavoriteNickname(senderNoiseKey) - ?: "Unknown" - } else { - getFavoriteNickname(senderNoiseKey) ?: "Unknown" - } - } else { - "Unknown" - } - - // Store Nostr key mapping - nostrKeyMapping[targetPeerID] = senderPubkey - - // Process payload and update UI/state - processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp) - - // If this was a private message, send a delivery ACK back over Nostr - if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) - pm?.let { pmsg -> - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - if (!seen.hasDelivered(pmsg.messageID)) { - val nostrTransport = NostrTransport.getInstance(application) - if (senderNoiseKey != null) { - val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex) - } else { - val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (identity != null) { - nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity) - } - } - seen.markDelivered(pmsg.messageID) - } - } - } - - } catch (e: Exception) { - Log.e(TAG, "Error processing Nostr message: ${e.message}") - } - } - } - - /** - * Process NoisePayload from Nostr message - */ - private suspend fun processNoisePayload( - noisePayload: com.bitchat.android.model.NoisePayload, - targetPeerID: String, - senderNickname: String, - messageTimestamp: Date - ) { - when (noisePayload.type) { - com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) - if (pm == null) { - Log.e(TAG, "Failed to decode PrivateMessagePacket") - return - } - - val messageId = pm.messageID - val messageContent = pm.content - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - val suppressUnread = seen.hasRead(messageId) - - // Handle favorite/unfavorite notifications - if (messageContent.startsWith("[FAVORITED]") || messageContent.startsWith("[UNFAVORITED]")) { - handleFavoriteNotification(messageContent, targetPeerID, senderNickname) - return - } - - // Check for duplicate message - val existingChats = state.getPrivateChatsValue() - var messageExists = false - for ((_, messages) in existingChats) { - if (messages.any { it.id == messageId }) { - messageExists = true - break - } - } - if (messageExists) return - - // Check if viewing this chat - val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == targetPeerID - - // Create BitchatMessage - val message = BitchatMessage( - id = messageId, - sender = senderNickname, - content = messageContent, - timestamp = messageTimestamp, - isRelay = false, - isPrivate = true, - recipientNickname = state.getNicknameValue(), - senderPeerID = targetPeerID, - deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered( - to = state.getNicknameValue() ?: "Unknown", - at = Date() - ) - ) - - // Add to private chats on Main - withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage(message, suppressUnread) - } - - // Send read receipt if viewing (only once across restarts) - if (isViewingThisChat && !seen.hasRead(messageId)) { - try { - val rr = com.bitchat.android.model.ReadReceipt(originalMessageID = messageId) - NostrTransport.getInstance(application).sendReadReceipt(rr, targetPeerID) - seen.markRead(messageId) - } catch (_: Exception) { } - } - - Log.i(TAG, "📥 Processed Nostr private message from $senderNickname") - } - - com.bitchat.android.model.NoisePayloadType.DELIVERED -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - // Use the existing delegate to handle delivery acknowledgment on Main - withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID) - } - Log.d(TAG, "📥 Processed Nostr delivery ACK for message $messageId") - } - - com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - // Use the existing delegate to handle read receipt on Main - withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID) - } - Log.d(TAG, "📥 Processed Nostr read receipt for message $messageId") - } - } - } - - /** - * Find Noise key for Nostr pubkey from favorites - */ - private fun findNoiseKeyForNostrPubkey(nostrPubkey: String): ByteArray? { - return com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(nostrPubkey) - } - - /** - * Get favorite nickname for Noise key - */ - private fun getFavoriteNickname(noiseKey: ByteArray): String? { - return com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)?.peerNickname - } - - /** - * Resolve a currently-connected mesh peer ID for a given Noise public key - * by matching against the mesh service's peer info noisePublicKey values. - */ - private fun resolveMeshPeerIdForNoiseKey(noiseKey: ByteArray): String? { - return try { - val peers: List = state.getConnectedPeersValue() - peers.firstOrNull { peerId: String -> - val info = meshDelegateHandler.getPeerInfo(peerId) - info?.noisePublicKey?.contentEquals(noiseKey) == true - } - } catch (_: Exception) { null } - } - - /** - * Merge any chats stored under the given keys into the target peer's chat entry - * so messages received while offline appear in the same chat when the peer connects. - */ - private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List) { - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge) - } - - /** - * Handle favorite/unfavorite notification - */ - private fun handleFavoriteNotification(content: String, fromPeerID: String, senderNickname: String) { - val isFavorite = content.startsWith("[FAVORITED]") - val action = if (isFavorite) "favorited" else "unfavorited" - - // Try to extract npub after colon, if present - val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") } - - // Resolve noise key if possible and persist relationship + npub mapping - try { - var noiseKey: ByteArray? = null - // If fromPeerID looks like hex (noise key), decode - val hexRegex = Regex("^[0-9a-fA-F]+$") - if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { - // Expect 64 hex chars for full Curve25519 key; accept others best-effort - val bytes = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - // Use only if length is plausible - if (bytes.isNotEmpty()) noiseKey = bytes - } else { - // fromPeerID likely a temporary key like "nostr_..."; map to Nostr pubkey - val senderPubkey = nostrKeyMapping[fromPeerID] - if (senderPubkey != null) { - noiseKey = findNoiseKeyForNostrPubkey(senderPubkey) - } - } - - if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) - if (npub != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) - } - } - } catch (_: Exception) { - // Best-effort - } - - // Determine guidance text based on mutual status (iOS-style) - val guidance = try { - val rel = run { - // Try to resolve via noise key directly or mapping - var key: ByteArray? = null - val hexRegex = Regex("^[0-9a-fA-F]+$") - if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { - key = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } else { - val mappedNostr = nostrKeyMapping[fromPeerID] - key = mappedNostr?.let { findNoiseKeyForNostrPubkey(it) } - } - key?.let { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) } - } - if (isFavorite) { - if (rel?.isFavorite == true) { - " — mutual! You can continue DMs via Nostr when out of mesh." - } else { - " — favorite back to continue DMs later." - } - } else { - ". DMs over Nostr will pause unless you both favorite again." - } - } catch (_: Exception) { "" } - - // Show system message - val systemMessage = BitchatMessage( - sender = "system", - content = "$senderNickname $action you$guidance", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) - - Log.i(TAG, "📥 Processed favorite notification: $senderNickname $action you") - } - - /** - * Base64URL decode (without padding) - */ - private fun base64URLDecode(input: String): ByteArray? { - return try { - val padded = input.replace("-", "+") - .replace("_", "/") - .let { str -> - val padding = (4 - str.length % 4) % 4 - str + "=".repeat(padding) - } - android.util.Base64.decode(padded, android.util.Base64.DEFAULT) - } catch (e: Exception) { - Log.e(TAG, "Failed to decode base64url: ${e.message}") - null - } - } - - // MARK: - Geohash Message History - - /** - * Store a message in geohash history - */ - private fun storeGeohashMessage(geohash: String, message: BitchatMessage) { - val messages = geohashMessageHistory.getOrPut(geohash) { mutableListOf() } - messages.add(message) - - // Limit message history to prevent memory issues - if (messages.size > maxGeohashMessages) { - messages.removeAt(0) // Remove oldest message - } - - Log.v(TAG, "📦 Stored message in geohash $geohash history (${messages.size} total) - sender: ${message.sender}, content: '${message.content.take(30)}...'") - } - - /** - * Load stored messages for a geohash channel - */ - fun loadGeohashMessages(geohash: String) { - val storedMessages = geohashMessageHistory[geohash] - if (storedMessages == null) { - Log.d(TAG, "📥 No stored messages found for geohash $geohash") - return - } - - Log.d(TAG, "📥 Loading ${storedMessages.size} stored messages for geohash $geohash") - - // Add all stored messages to the current message timeline - storedMessages.forEach { message -> - Log.v(TAG, "📥 Loading stored message: ${message.sender} - '${message.content.take(30)}...'") - messageManager.addMessage(message) - } - } - - /** - * Clear geohash message history - */ - fun clearGeohashMessageHistory() { - geohashMessageHistory.clear() - } - - // MARK: - Geohash Participant Tracking - - /** - * Get participant count for a specific geohash (5-minute activity window) - */ - fun geohashParticipantCount(geohash: String): Int { - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) // 5 minutes ago - val participants = geohashParticipants[geohash] ?: return 0 - - // Remove expired participants - val iterator = participants.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (entry.value.before(cutoff)) { - iterator.remove() - } - } - - return participants.size - } - - /** - * Begin sampling multiple geohashes for participant activity - */ - fun beginGeohashSampling(geohashes: List) { - // Cancel existing sampling - geohashSamplingJob?.cancel() - - if (geohashes.isEmpty()) return - - Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes") - - geohashSamplingJob = coroutineScope.launch { - val nostrRelayManager = NostrRelayManager.getInstance(application) - - // Subscribe to each geohash for ephemeral events (kind 20000) using geohash-specific relays - geohashes.forEach { geohash -> - val filter = NostrFilter.geohashEphemeral( - geohash = geohash, - since = System.currentTimeMillis() - 86400000L, // Last 24 hours - limit = 200 - ) - - nostrRelayManager.subscribeForGeohash( - geohash = geohash, - filter = filter, - id = "geohash-$geohash", - handler = { event -> - handleUnifiedGeohashEvent(event, geohash) - }, - includeDefaults = false, - nRelays = 5 - ) - - Log.d(TAG, "Subscribed to geohash events for: $geohash") - } - } - } - - /** - * End geohash sampling - */ - fun endGeohashSampling() { - Log.d(TAG, "🌍 Ending geohash sampling") - geohashSamplingJob?.cancel() - geohashSamplingJob = null - } - - /** - * Update participant activity for a geohash - */ - private fun updateGeohashParticipant(geohash: String, participantId: String, lastSeen: Date) { - val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } - participants[participantId] = lastSeen - - // Update geohash people list if this is the current geohash - if (currentGeohash == geohash) { - refreshGeohashPeople() - } - - // CRITICAL FIX: Force UI recomposition by updating reactive participant counts for location channel selector - // This ensures that the location channels sheet shows live participant counts for ALL geohashes - updateReactiveParticipantCounts() - } - - /** - * Update reactive participant counts for real-time location channel selector (CRITICAL FIX) - */ - private fun updateReactiveParticipantCounts() { - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) // 5 minutes ago - val counts = mutableMapOf() - - // Calculate current participant counts for all geohashes with recent activity - for ((geohash, participants) in geohashParticipants) { - // CRITICAL BUG FIX: Count active participants WITHOUT mutating original data - // Don't remove from original structure - just count active ones - val activeCount = participants.values.count { lastSeen -> - !lastSeen.before(cutoff) - } - - // Store the current count - counts[geohash] = activeCount - } - - // CRITICAL: Update reactive state to trigger UI recomposition - state.setGeohashParticipantCounts(counts) - - Log.v(TAG, "🔄 Updated reactive participant counts: ${counts.size} geohashes with activity") - } - - /** - * Record geohash participant by pubkey hex (iOS-compatible) - */ - private fun recordGeoParticipant(pubkeyHex: String) { - currentGeohash?.let { geohash -> - updateGeohashParticipant(geohash, pubkeyHex, Date()) - } - } - - /** - * Refresh geohash people list from current participants (iOS-compatible) - */ - private fun refreshGeohashPeople() { - val geohash = currentGeohash - if (geohash == null) { - state.setGeohashPeople(emptyList()) - return - } - - // Use 5-minute activity window (matches iOS exactly) - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) - val participants = geohashParticipants[geohash] ?: mutableMapOf() - - // Remove expired participants - val iterator = participants.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (entry.value.before(cutoff)) { - iterator.remove() - } - } - geohashParticipants[geohash] = participants - - // Build GeoPerson list - val people = participants.map { (pubkeyHex, lastSeen) -> - val displayName = displayNameForNostrPubkey(pubkeyHex) - //Log.v(TAG, "🏷️ Participant ${pubkeyHex.take(8)} -> displayName: $displayName") - GeoPerson( - id = pubkeyHex.lowercase(), - displayName = displayName, - lastSeen = lastSeen - ) - }.sortedByDescending { it.lastSeen } // Most recent first - - state.setGeohashPeople(people) - //Log.d(TAG, "🌍 Refreshed geohash people: ${people.size} participants in $geohash") - - } - - /** - * Start participant refresh timer for geohash channels (iOS-compatible) - */ - private fun startGeoParticipantsTimer() { - // Cancel existing timer - geoParticipantsTimer?.cancel() - - // Start 30-second refresh timer (matches iOS) - geoParticipantsTimer = coroutineScope.launch { - while (currentGeohash != null) { - delay(30000) // 30 seconds - refreshGeohashPeople() - } - } - } - - /** - * Stop participant refresh timer - */ - private fun stopGeoParticipantsTimer() { - geoParticipantsTimer?.cancel() - geoParticipantsTimer = null - } - - /** - * Check if a geohash person is teleported (iOS-compatible) - */ - fun isPersonTeleported(pubkeyHex: String): Boolean { - return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) - } - - /** - * Start geohash DM with pubkey hex (iOS-compatible) - */ - fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) { - val convKey = "nostr_${pubkeyHex.take(16)}" - nostrKeyMapping[convKey] = pubkeyHex - onStartPrivateChat(convKey) - Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey") - } - - /** - * Get the Nostr key mapping for geohash DMs - */ - fun getNostrKeyMapping(): Map { - return nostrKeyMapping.toMap() - } - - /** - * Send read receipt for geohash DM - */ - private fun sendGeohashReadReceipt(messageID: String, recipientPubkey: String, geohash: String) { - coroutineScope.launch { - try { - // Derive geohash-specific identity for sending - val senderIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - - // Send via Nostr transport - val nostrTransport = NostrTransport.getInstance(application) - // Set sender peer ID (get from mesh service or use a placeholder) - nostrTransport.senderPeerID = "geohash:$geohash" - nostrTransport.sendReadReceiptGeohash( - messageID = messageID, - toRecipientHex = recipientPubkey, - fromIdentity = senderIdentity - ) - - Log.d(TAG, "📤 Sent geohash read receipt for $messageID to ${recipientPubkey.take(8)}...") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") - } - } - } - - // MARK: - Location Channel Management - - fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { - locationChannelManager?.select(channel) ?: run { - Log.w(TAG, "Cannot select location channel - LocationChannelManager not initialized") - } - } - - /** - * Switch to location channel and set up proper Nostr subscriptions (iOS-compatible) - * Optimized for non-blocking UI with immediate feedback - */ - private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { - // STEP 1: Immediate UI updates (synchronous, no blocking) - try { - // Clear all displayed messages and load stored messages for the new channel - messageManager.clearMessages() - Log.d(TAG, "🗑️ Cleared all messages for channel switch") - - when (channel) { - is com.bitchat.android.geohash.ChannelID.Mesh -> { - Log.d(TAG, "📡 Switched to mesh channel") - // Immediate UI state updates - currentGeohash = null - // Update notification manager with current geohash - notificationManager.setCurrentGeohash(null) - // Clear mesh mention notifications since user is now viewing mesh chat - notificationManager.clearMeshMentionNotifications() - // Note: Don't clear geoNicknames - keep cached for when we return to location channels - stopGeoParticipantsTimer() - state.setGeohashPeople(emptyList()) - state.setTeleportedGeo(emptySet()) - } - - is com.bitchat.android.geohash.ChannelID.Location -> { - Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") - currentGeohash = channel.channel.geohash - // Update notification manager with current geohash - notificationManager.setCurrentGeohash(channel.channel.geohash) - // Clear notifications for this geohash since user is now viewing it - notificationManager.clearNotificationsForGeohash(channel.channel.geohash) - // Note: Don't clear geoNicknames - they contain cached nicknames for all geohashes - - // Load stored messages for this geohash immediately - loadGeohashMessages(channel.channel.geohash) - - // Immediate self-registration for instant UI feedback - try { - val identity = NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = application - ) - recordGeoParticipant(identity.publicKeyHex) - - // Mark teleported state immediately - val teleported = state.isTeleported.value ?: false - if (teleported) { - val currentTeleported = state.getTeleportedGeoValue().toMutableSet() - currentTeleported.add(identity.publicKeyHex.lowercase()) - state.setTeleportedGeo(currentTeleported) - } - - Log.d(TAG, "📍 Immediate self-registration completed for geohash UI") - } catch (e: Exception) { - Log.w(TAG, "Failed immediate identity setup: ${e.message}") - } - - // Start participant refresh timer immediately - startGeoParticipantsTimer() - - // Force immediate refresh to show any cached nicknames - refreshGeohashPeople() - } - - null -> { - Log.d(TAG, "📡 No channel selected") - currentGeohash = null - // Note: Don't clear geoNicknames - keep cached nicknames for when we return - stopGeoParticipantsTimer() - state.setGeohashPeople(emptyList()) - state.setTeleportedGeo(emptySet()) - } - } - } catch (e: Exception) { - Log.e(TAG, "❌ Error in immediate channel switch: ${e.message}") - } - - // STEP 2: Async subscription setup (non-blocking background) - coroutineScope.launch { - try { - Log.d(TAG, "🔄 Starting async subscription setup...") - - // Clear processed events when switching channels to get fresh timeline - processedNostrEvents.clear() - processedNostrEventOrder.clear() - - // Unsubscribe from previous geohash ephemeral events (async) - currentGeohashSubscriptionId?.let { subId -> - try { - val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.unsubscribe(subId) - currentGeohashSubscriptionId = null - Log.d(TAG, "🔄 Unsubscribed from previous geohash ephemeral events: $subId") - } catch (e: Exception) { - Log.w(TAG, "Failed to unsubscribe from geohash events: ${e.message}") - } - } - - // Unsubscribe from previous geohash DMs (async) - currentGeohashDmSubscriptionId?.let { dmSubId -> - try { - val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.unsubscribe(dmSubId) - currentGeohashDmSubscriptionId = null - Log.d(TAG, "🔄 Unsubscribed from previous geohash DMs: $dmSubId") - } catch (e: Exception) { - Log.w(TAG, "Failed to unsubscribe from DMs: ${e.message}") - } - } - - // Setup new subscriptions for location channels - if (channel is com.bitchat.android.geohash.ChannelID.Location) { - Log.d(TAG, "🌐 Setting up Nostr subscriptions for geohash: ${channel.channel.geohash}") - - try { - val nostrRelayManager = NostrRelayManager.getInstance(application) - - // Subscribe to geohash ephemeral events for this specific channel using geohash-specific relays - val geohashSubId = "geohash-${channel.channel.geohash}" - currentGeohashSubscriptionId = geohashSubId - - val geohashFilter = NostrFilter.geohashEphemeral( - geohash = channel.channel.geohash, - since = System.currentTimeMillis() - 3600000L, // Last hour for channel messages - limit = 200 - ) - - nostrRelayManager.subscribeForGeohash( - geohash = channel.channel.geohash, - filter = geohashFilter, - id = geohashSubId, - handler = { event -> - handleUnifiedGeohashEvent(event, channel.channel.geohash) - }, - includeDefaults = false, - nRelays = 5 - ) - - Log.i(TAG, "✅ Subscribed to geohash ephemeral events: #${channel.channel.geohash}") - - // Subscribe to DMs for this channel's identity - val dmIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = application - ) - - val dmSubId = "geo-dm-${channel.channel.geohash}" - currentGeohashDmSubscriptionId = dmSubId - - val dmFilter = NostrFilter.giftWrapsFor( - pubkey = dmIdentity.publicKeyHex, - since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) - ) - - // IMPORTANT: For geohash DMs, use default relays (iOS behavior) - nostrRelayManager.subscribe( - filter = dmFilter, - id = dmSubId, - handler = { giftWrap -> - handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity) - }, - targetRelayUrls = null - ) - - Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...") - - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to setup geohash subscriptions: ${e.message}") - } - } - - Log.d(TAG, "✅ Async subscription setup completed") - - } catch (e: Exception) { - Log.e(TAG, "❌ Failed in async channel switching: ${e.message}") - } - } - } - - /** - * Unified handler for all geohash ephemeral events (kind 20000) - * Handles participant tracking, nickname caching, message display, and teleport state - */ - private fun handleUnifiedGeohashEvent(event: NostrEvent, geohash: String) { - coroutineScope.launch(Dispatchers.Default) { - try { - Log.v(TAG, "🔍 handleUnifiedGeohashEvent called - eventGeohash: $geohash, currentGeohash: $currentGeohash, eventKind: ${event.kind}, eventId: ${event.id.take(8)}...") - - // Only handle ephemeral kind 20000 events - if (event.kind != 20000) { - Log.v(TAG, "❌ Skipping non-ephemeral event (kind ${event.kind})") - return@launch - } - - // Check if this user is blocked in geohash channels BEFORE any processing - if (isGeohashUserBlocked(event.pubkey)) { - Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...") - return@launch - } - - // Deduplicate events - if (processedNostrEvents.contains(event.id)) { - Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...") - return@launch - } - processedNostrEvents.add(event.id) - - // Manage deduplication cache size - processedNostrEventOrder.add(event.id) - if (processedNostrEventOrder.size > maxProcessedNostrEvents) { - val oldestId = processedNostrEventOrder.removeAt(0) - processedNostrEvents.remove(oldestId) - } - - // STEP 1: Always update participant activity for all geohashes (for location channel list) - val timestamp = Date(event.createdAt * 1000L) - withContext(Dispatchers.Main) { updateGeohashParticipant(geohash, event.pubkey, timestamp) } - - // STEP 2: Always cache nickname from tag if present (for all geohashes) - event.tags.find { it.size >= 2 && it[0] == "n" }?.let { nickTag -> - val nick = nickTag[1] - val pubkeyLower = event.pubkey.lowercase() - val previousNick = geoNicknames[pubkeyLower] - geoNicknames[pubkeyLower] = nick - Log.v(TAG, "📝 Cached nickname for ${event.pubkey.take(8)}: $nick") - - // If this is a new nickname or nickname change for current geohash, refresh people list - if (previousNick != nick && currentGeohash == geohash) { - withContext(Dispatchers.Main) { refreshGeohashPeople() } - } - } - - // STEP 3: Always track teleport tag for participants (iOS-compatible) - event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { - val key = event.pubkey.lowercase() - val currentTeleported = state.getTeleportedGeoValue().toMutableSet() - if (!currentTeleported.contains(key)) { - currentTeleported.add(key) - withContext(Dispatchers.Main) { state.setTeleportedGeo(currentTeleported) } - Log.d(TAG, "📍 Marked geohash participant as teleported: ${event.pubkey.take(8)}...") - } - } - - // STEP 4: Skip our own events for message display (we already locally echoed) - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - if (myGeoIdentity.publicKeyHex.lowercase() == event.pubkey.lowercase()) { - return@launch - } - - // STEP 5: Store mapping for potential geohash DM initiation - val key16 = "nostr_${event.pubkey.take(16)}" - val key8 = "nostr:${event.pubkey.take(8)}" - nostrKeyMapping[key16] = event.pubkey - nostrKeyMapping[key8] = event.pubkey - - // STEP 6: Process message content for display (only for current/visible geohash) - // Skip empty teleport presence events - val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && - event.content.trim().isEmpty() - if (isTeleportPresence) { - Log.v(TAG, "Skipping empty teleport presence event") - return@launch - } - - val senderHandle = displayNameForNostrPubkey(event.pubkey) - val senderName = displayNameForNostrPubkeyUI(event.pubkey) - val content = event.content - - // Preserve Nostr event time to avoid reordering - val messageTimestamp = Date(event.createdAt.toLong() * 1000L) - // Note: mentions parsing needs peer nicknames parameter - // val mentions = messageManager.parseMentions(content, peerNicknames, nickname) - - val message = BitchatMessage( - id = event.id, - sender = senderName, - content = content, - timestamp = messageTimestamp, - isRelay = false, - originalSender = senderHandle, - senderPeerID = "nostr:${event.pubkey.take(8)}", - mentions = null, // mentions need to be passed from outside - channel = "#$geohash" - ) - - // Store in geohash history for persistence across channel switches - storeGeohashMessage(geohash, message) - - // CRITICAL BUG FIX: Add to message timeline if we're viewing this geohash OR if it matches our selected location channel - // This prevents messages from being lost during channel switching race conditions - val selectedLocationChannel = state.selectedLocationChannel.value - val shouldShowMessage = currentGeohash == geohash || - (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && - selectedLocationChannel.channel.geohash == geohash) - - if (shouldShowMessage) { - withContext(Dispatchers.Main) { messageManager.addMessage(message) } - } - - // NOTIFICATION LOGIC: Check for mentions and first messages - checkAndTriggerGeohashNotifications(geohash, senderName, content, message) - - Log.d(TAG, "📥 Unified geohash event processed - geohash: $geohash, sender: $senderName, content: ${content.take(50)}") - - } catch (e: Exception) { - Log.e(TAG, "Error handling unified geohash event: ${e.message}") - } - } - } - - /** - * Check and trigger geohash notifications for mentions and first messages - */ - private suspend fun checkAndTriggerGeohashNotifications( - geohash: String, - senderName: String, - content: String, - message: BitchatMessage - ) { - try { - // Get user's current nickname - val currentNickname = state.getNicknameValue() - if (currentNickname.isNullOrEmpty()) { - return - } - - // Check if this message mentions the current user - val isMention = checkForMention(content, currentNickname) - - // Check if this is the first message in a subscribed geohash chat - val isFirstMessage = checkIfFirstMessage(geohash, message) - - // Only trigger notifications if we have a mention or first message - if (isMention || isFirstMessage) { - Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage") - - withContext(Dispatchers.Main) { - // Sanitize mention for notifications: hide '#hash' from @username#hash if not needed - val contentForNotification = if (isMention) { - var sanitized = content - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myDisplay = displayNameForNostrPubkeyUI(myGeoIdentity.publicKeyHex) - val needsDisambiguation = myDisplay.contains("#") - if (!needsDisambiguation) { - val pattern = ("@" + java.util.regex.Pattern.quote(currentNickname) + "#[a-fA-F0-9]{4}\\b").toRegex() - sanitized = sanitized.replace(pattern, "@" + currentNickname) - } - } catch (_: Exception) { } - sanitized - } else content - - val locationName = getLocationNameForGeohash(geohash) - notificationManager.showGeohashNotification( - geohash = geohash, - senderNickname = senderName, - messageContent = contentForNotification, - isMention = isMention, - isFirstMessage = isFirstMessage, - locationName = locationName - ) - } - } - } catch (e: Exception) { - Log.e(TAG, "Error checking geohash notifications: ${e.message}") - } - } - - /** - * Check if the content mentions the current user with @nickname#hash format - */ - private fun checkForMention(content: String, currentNickname: String): Boolean { - // iOS-style mention pattern: @nickname#1234 or @nickname - val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() - - return mentionPattern.findAll(content).any { match -> - val mentionWithoutAt = match.groupValues[1] - // Split the mention to get base nickname (without #hash suffix) - val baseName = if (mentionWithoutAt.contains("#")) { - mentionWithoutAt.substringBeforeLast("#") - } else { - mentionWithoutAt - } - - // Check if the base name matches current user's nickname - baseName.equals(currentNickname, ignoreCase = true) - } - } - - /** - * Check if this is the first message in a subscribed geohash chat - */ - private fun checkIfFirstMessage(geohash: String, message: BitchatMessage): Boolean { - // Get the message history for this geohash - val messageHistory = geohashMessageHistory[geohash] ?: return true - - // Filter out our own messages (local echoes and messages from our identity) - val otherUserMessages = messageHistory.filter { msg -> - // Check if this is our own message by comparing sender ID with our geohash identity - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" - msg.senderPeerID != myIdentityId && msg.senderPeerID != message.senderPeerID - } catch (e: Exception) { - // If we can't determine identity, assume it's not our message - msg.senderPeerID != message.senderPeerID - } - } - - // This is a first message if there are no other user messages and this isn't from us - val isFromUs = try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" - message.senderPeerID == myIdentityId - } catch (e: Exception) { - false - } - - return otherUserMessages.isEmpty() && !isFromUs - } - - /** - * Handle geohash DM event (private messages in geohash context) - iOS compatible - */ - private fun handleGeohashDmEvent( - giftWrap: NostrEvent, - geohash: String, - identity: NostrIdentity - ) { - coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { - try { - // Deduplicate - if (processedNostrEvents.contains(giftWrap.id)) return@launch - processedNostrEvents.add(giftWrap.id) - - // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID - - // Decrypt with per-geohash identity - val decryptResult = NostrProtocol.decryptPrivateMessage( - giftWrap = giftWrap, - recipientIdentity = identity - ) - - if (decryptResult == null) { - Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)") - return@launch - } - - val (content, senderPubkey, rumorTimestamp) = decryptResult - - // Only process BitChat embedded messages - if (!content.startsWith("bitchat1:")) return@launch - - val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) ?: return@launch - val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return@launch - - if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch - - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch - val messageTimestamp = Date(rumorTimestamp * 1000L) - val convKey = "nostr_${senderPubkey.take(16)}" - nostrKeyMapping[convKey] = senderPubkey - - when (noisePayload.type) { - com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return@launch - val messageId = pm.messageID - - Log.d(TAG, "📥 Received geohash DM from ${senderPubkey.take(8)}...") - - // Check for duplicate message - val existingChats = state.getPrivateChatsValue() - var messageExists = false - for ((_, messages) in existingChats) { - if (messages.any { it.id == messageId }) { - messageExists = true - break - } - } - if (messageExists) return@launch - - val senderHandle = displayNameForNostrPubkey(senderPubkey) - val senderName = displayNameForNostrPubkeyUI(senderPubkey) - val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey - - val message = BitchatMessage( - id = messageId, - sender = senderName, - content = pm.content, - timestamp = messageTimestamp, - isRelay = false, - isPrivate = true, - recipientNickname = state.getNicknameValue(), - senderPeerID = convKey, - originalSender = senderHandle, - deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered( - to = state.getNicknameValue() ?: "Unknown", - at = Date() - ) - ) - - // Add to private chats (suppress unread if already read) - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = seen.hasRead(messageId)) - - // Send delivery ACK for geohash DMs only once - if (!seen.hasDelivered(messageId)) { - val nostrTransport = NostrTransport.getInstance(application) - nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity) - seen.markDelivered(messageId) - } - - // Send read receipt if viewing this chat - if (isViewingThisChat && !seen.hasRead(messageId)) { - // Send read receipt via Nostr for geohash DM - sendGeohashReadReceipt(messageId, senderPubkey, geohash) - // Mark message as read persistently - try { seen.markRead(messageId) } catch (_: Exception) { } - } - } - - com.bitchat.android.model.NoisePayloadType.DELIVERED -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) - } - - com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) - } - } - - } catch (e: Exception) { - Log.e(TAG, "Error handling geohash DM event: ${e.message}") - } - } - } - - /** - * Display name for Nostr pubkey (iOS-compatible) - */ - private fun displayNameForNostrPubkey(pubkeyHex: String): String { - val suffix = pubkeyHex.takeLast(4) - val pubkeyLower = pubkeyHex.lowercase() - - // If this is our per-geohash identity, use our nickname - val currentGeohash = this.currentGeohash - if (currentGeohash != null) { - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = currentGeohash, - context = application - ) - if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) { - return "${state.getNicknameValue()}#$suffix" - } - } catch (e: Exception) { - // Continue with other methods - } - } - - // If we have a cached nickname for this pubkey, use it - geoNicknames[pubkeyLower]?.let { nick -> - //Log.v(TAG, "✅ Found cached nickname for ${pubkeyHex.take(8)}: $nick") - return "$nick#$suffix" - } - - // Otherwise, anonymous with collision-resistant suffix - //Log.v(TAG, "❌ No cached nickname for ${pubkeyHex.take(8)}, using anon") - return "anon#$suffix" - } - - /** - * UI display name for Nostr pubkey (conditionally hides #hash when not needed) - * Used in chat window sender labels, geohash people list, and notifications. - */ - fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { - val pubkeyLower = pubkeyHex.lowercase() - val suffix = pubkeyHex.takeLast(4) - val currentGeohash = this.currentGeohash - - // Determine base name (self nickname, cached nickname, or anon) - val baseName: String = try { - if (currentGeohash != null) { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = currentGeohash, - context = application - ) - if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) { - state.getNicknameValue() ?: "anon" - } else { - geoNicknames[pubkeyLower] ?: "anon" - } - } else { - geoNicknames[pubkeyLower] ?: "anon" - } - } catch (_: Exception) { - geoNicknames[pubkeyLower] ?: "anon" - } - - // If not in a geohash context, no need for disambiguation - if (currentGeohash == null) return baseName - - // Count active participants sharing the same base name within 5 minutes - return try { - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) - val participants = geohashParticipants[currentGeohash] ?: emptyMap() - - var count = 0 - for ((participantKey, lastSeen) in participants) { - if (lastSeen.before(cutoff)) continue - val participantLower = participantKey.lowercase() - val name = if (participantLower == pubkeyLower) baseName else (geoNicknames[participantLower] ?: "anon") - if (name.equals(baseName, ignoreCase = true)) { - count++ - if (count > 1) break - } - } - - if (!participants.containsKey(pubkeyLower)) { - count += 1 - } - - if (count > 1) "$baseName#$suffix" else baseName - } catch (_: Exception) { - baseName - } - } - - /** - * Mention handle for a Nostr pubkey (ALWAYS includes #hash) - * Use for mentions, hug/slap targets, and mention detection contexts. - */ - fun mentionHandleForNostrPubkey(pubkeyHex: String): String { - return displayNameForNostrPubkey(pubkeyHex) - } - - // MARK: - Color System - - /** - * Get consistent color for a Nostr pubkey (iOS-compatible) - */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - val seed = "nostr:${pubkeyHex.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } - - // MARK: - Nostr Direct Message Sending - - /** - * Send private message via Nostr for geohash contacts - */ - fun sendNostrGeohashDM(content: String, recipientPeerID: String, messageID: String, myPeerID: String) { - coroutineScope.launch { - try { - // Get the current geohash from location channel manager - val locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - val selectedChannel = locationChannelManager.selectedChannel.value - - if (selectedChannel !is com.bitchat.android.geohash.ChannelID.Location) { - Log.w(TAG, "Cannot send geohash DM: not in a location channel") - return@launch - } - - // Get the recipient's Nostr public key from the mapping - val recipientHex = nostrKeyMapping[recipientPeerID] - - if (recipientHex == null) { - Log.w(TAG, "Cannot send geohash DM: no public key mapping for $recipientPeerID") - return@launch - } - - // Derive geohash-specific identity for sending - val senderIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = selectedChannel.channel.geohash, - context = application - ) - - // Send via Nostr transport - val nostrTransport = NostrTransport.getInstance(application) - // Ensure the senderPeerID is set properly - nostrTransport.senderPeerID = myPeerID - nostrTransport.sendPrivateMessageGeohash( - content = content, - toRecipientHex = recipientHex, - fromIdentity = senderIdentity, - messageID = messageID - ) - - Log.d(TAG, "📤 Sent geohash DM to $recipientPeerID via Nostr") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}") - } - } - } - - /** - * Send read receipt via Nostr for geohash contacts - */ - fun sendNostrGeohashReadReceipt(messageID: String, recipientPeerID: String, myPeerID: String) { - coroutineScope.launch { - try { - // Get the current geohash from location channel manager - val locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - val selectedChannel = locationChannelManager.selectedChannel.value - - if (selectedChannel !is com.bitchat.android.geohash.ChannelID.Location) { - Log.w(TAG, "Cannot send geohash read receipt: not in a location channel") - return@launch - } - - // Get the recipient's Nostr public key from the mapping - val recipientHex = nostrKeyMapping[recipientPeerID] - - if (recipientHex == null) { - Log.w(TAG, "Cannot send geohash read receipt: no public key mapping for $recipientPeerID") - return@launch - } - - // Derive geohash-specific identity for sending - val senderIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = selectedChannel.channel.geohash, - context = application - ) - - // Send via Nostr transport - val nostrTransport = NostrTransport.getInstance(application) - // Ensure the senderPeerID is set properly - nostrTransport.senderPeerID = myPeerID - nostrTransport.sendReadReceiptGeohash( - messageID = messageID, - toRecipientHex = recipientHex, - fromIdentity = senderIdentity - ) - - Log.d(TAG, "📤 Sent geohash read receipt for $messageID to $recipientPeerID via Nostr") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}") - } - } - } - - // MARK: - Geohash Blocking - - /** - * Block a user in geohash channels by their nickname - */ - fun blockUserInGeohash(targetNickname: String) { - // Find the pubkey for this nickname - val pubkeyHex = geoNicknames.entries.firstOrNull { (_, nickname) -> - val baseName = nickname.split("#").firstOrNull() ?: nickname - baseName == targetNickname - }?.key - - if (pubkeyHex != null) { - // Add to geohash block list - dataManager.addGeohashBlockedUser(pubkeyHex) - - // Add system message - val systemMessage = com.bitchat.android.model.BitchatMessage( - sender = "system", - content = "blocked $targetNickname in geohash channels", - timestamp = java.util.Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) - - Log.i(TAG, "🚫 Blocked geohash user: $targetNickname (pubkey: ${pubkeyHex.take(8)}...)") - } else { - // User not found - val systemMessage = com.bitchat.android.model.BitchatMessage( - sender = "system", - content = "user '$targetNickname' not found in current geohash", - timestamp = java.util.Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) - } - } - - /** - * Check if a user is blocked in geohash channels - */ - private fun isGeohashUserBlocked(pubkeyHex: String): Boolean { - return dataManager.isGeohashUserBlocked(pubkeyHex) - } - - /** - * Get location name for a geohash if available - */ - private fun getLocationNameForGeohash(geohash: String): String? { - return try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - val locationNames = locationManager.locationNames.value ?: return null - val available = locationManager.availableChannels.value ?: emptyList() - - // Determine the level from geohash length - val level = when (geohash.length) { - in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION - in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE - 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY - 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD - 7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - } - - // Only show location name if the notification's geohash matches the device's current geohash at that level - val currentAtLevel = available.firstOrNull { it.level == level }?.geohash - if (currentAtLevel != null && currentAtLevel.equals(geohash, ignoreCase = true)) { - locationNames[level] - } else { - null - } - } catch (e: Exception) { - Log.w(TAG, "Failed to get location name for geohash $geohash: ${e.message}") - null - } - } - -} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 01583dde..665bfd50 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -2,9 +2,11 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.identity.SecureIdentityStateManager import java.security.MessageDigest import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap /** * Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging @@ -99,7 +101,7 @@ object NostrIdentityBridge { private const val DEVICE_SEED_KEY = "nostr_device_seed" // Cache for derived geohash identities to avoid repeated crypto operations - private val geohashIdentityCache = mutableMapOf() + private val geohashIdentityCache = ConcurrentHashMap() /** * Get or create the current Nostr identity @@ -131,8 +133,7 @@ object NostrIdentityBridge { * Uses HMAC-SHA256(deviceSeed, geohash) as private key material with fallback rehashing * if the candidate is not a valid secp256k1 private key. * - * Direct port from iOS implementation for 100% compatibility - * OPTIMIZED: Cached for UI responsiveness + * Cached for UI responsiveness. */ fun deriveIdentity(forGeohash: String, context: Context): NostrIdentity { // Check cache first for immediate response @@ -157,7 +158,7 @@ object NostrIdentityBridge { // Cache the result for future UI responsiveness geohashIdentityCache[forGeohash] = identity - Log.d(TAG, "Derived geohash identity for $forGeohash (iteration $i)") + Log.d(TAG, "Derived geohash identity") return identity } } @@ -172,7 +173,7 @@ object NostrIdentityBridge { // Cache the fallback result too geohashIdentityCache[forGeohash] = fallbackIdentity - Log.d(TAG, "Used fallback identity derivation for $forGeohash") + Log.d(TAG, "Used fallback geohash identity derivation") return fallbackIdentity } @@ -188,12 +189,8 @@ object NostrIdentityBridge { * Associate a Nostr identity with a Noise public key (for favorites) */ fun associateNostrIdentity(nostrPubkey: String, noisePublicKey: ByteArray, context: Context) { - val stateManager = SecureIdentityStateManager(context) - - // We'll use the existing signing key storage mechanism for associations - // For now, we'll store this as a preference since it's just for favorites mapping - // In a full implementation, you'd want a proper association storage system - + FavoritesPersistenceService.initialize(context) + FavoritesPersistenceService.shared.updateNostrPublicKey(noisePublicKey, nostrPubkey) Log.d(TAG, "Associated Nostr pubkey ${nostrPubkey.take(16)}... with Noise key") } @@ -201,9 +198,8 @@ object NostrIdentityBridge { * Get Nostr public key associated with a Noise public key */ fun getNostrPublicKey(noisePublicKey: ByteArray, context: Context): String? { - // This would need proper implementation based on your favorites storage system - // For now, return null as we don't have the full association system - return null + FavoritesPersistenceService.initialize(context) + return FavoritesPersistenceService.shared.findNostrPubkey(noisePublicKey) } /** @@ -224,6 +220,10 @@ object NostrIdentityBridge { Log.e(TAG, "Failed to clear Nostr data: ${e.message}") } } + + fun clearGeohashIdentityCache(geohashes: Collection) { + geohashes.forEach(geohashIdentityCache::remove) + } // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt b/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt new file mode 100644 index 00000000..77f27387 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt @@ -0,0 +1,13 @@ +package com.bitchat.android.nostr + +internal object NostrLiveSubscriptionPrivacy { + fun closeTargets( + liveSubscriptionIds: Set, + subscriptionsByRelay: Map>, + ): Map> = buildMap { + subscriptionsByRelay.forEach { (relayUrl, relaySubscriptionIds) -> + val matchingIds = relaySubscriptionIds.intersect(liveSubscriptionIds) + if (matchingIds.isNotEmpty()) put(relayUrl, matchingIds) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt new file mode 100644 index 00000000..dd9e16d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -0,0 +1,90 @@ +package com.bitchat.android.nostr + +/** + * Thread-safe bounded queue of relay deliveries awaiting a usable WebSocket. + * + * Queue entries have a local ID rather than using the Nostr event ID: the same signed event may be + * intentionally published more than once with different relay sets or privacy provenance. + */ +internal class NostrPendingEventQueue( + private val capacity: Int +) { + init { + require(capacity > 0) + } + + data class Delivery( + val queueId: Long, + val event: NostrEvent, + val liveLocationToken: Long? + ) + + private data class Entry( + val queueId: Long, + val event: NostrEvent, + val pendingRelayUrls: MutableSet, + val liveLocationToken: Long? + ) + + private val lock = Any() + private val entries = ArrayDeque() + private var nextQueueId = 1L + + fun enqueue( + event: NostrEvent, + relayUrls: Collection, + liveLocationToken: Long? + ): Long? { + val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() } + if (pendingRelays.isEmpty()) return null + + return synchronized(lock) { + if (entries.size >= capacity) entries.removeFirst() + val queueId = nextQueueId++ + entries.addLast( + Entry( + queueId = queueId, + event = event, + pendingRelayUrls = pendingRelays, + liveLocationToken = liveLocationToken + ) + ) + queueId + } + } + + fun pendingForRelay(relayUrl: String): List = synchronized(lock) { + entries + .asSequence() + .filter { relayUrl in it.pendingRelayUrls } + .map { Delivery(it.queueId, it.event, it.liveLocationToken) } + .toList() + } + + fun markDelivered(queueId: Long, relayUrl: String) { + synchronized(lock) { + val iterator = entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.queueId != queueId) continue + entry.pendingRelayUrls.remove(relayUrl) + if (entry.pendingRelayUrls.isEmpty()) iterator.remove() + return + } + } + } + + fun removeLiveLocationEvents() { + synchronized(lock) { + entries.removeAll { it.liveLocationToken != null } + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + } + } + + internal fun size(): Int = synchronized(lock) { entries.size } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt new file mode 100644 index 00000000..ffbe6607 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt @@ -0,0 +1,222 @@ +package com.bitchat.android.nostr + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.security.MessageDigest +import kotlin.random.Random + +/** + * Nostr Proof of Work (PoW) implementation following NIP-13 + * + * This implements the Proof of Work system for Nostr events to provide spam deterrence. + * The difficulty is defined as the number of leading zero bits in the event ID. + * + * Reference: https://github.com/nostr-protocol/nips/blob/master/13.md + */ +object NostrProofOfWork { + + private const val TAG = "NostrProofOfWork" + + /** + * Calculate the difficulty (number of leading zero bits) of an event ID + * @param eventIdHex The hexadecimal event ID + * @return The number of leading zero bits + */ + fun calculateDifficulty(eventIdHex: String): Int { + var count = 0 + + for (i in eventIdHex.indices) { + val nibble = eventIdHex[i].toString().toInt(16) + if (nibble == 0) { + count += 4 + } else { + // Count leading zeros in the nibble + count += when (nibble) { + 1 -> 3 // 0001 + 2, 3 -> 2 // 001x + 4, 5, 6, 7 -> 1 // 01xx + else -> 0 // 1xxx + } + break + } + } + + return count + } + + /** + * Validate that an event meets the minimum difficulty requirement + * @param event The Nostr event to validate + * @param minimumDifficulty The minimum required difficulty + * @return true if the event meets the difficulty requirement + */ + fun validateDifficulty(event: NostrEvent, minimumDifficulty: Int): Boolean { + if (minimumDifficulty <= 0) return true + + // Require explicit nonce tag to recognize PoW per NIP-13 intent + if (!hasNonce(event)) { + Log.w(TAG, "Event ${event.id.take(16)}... missing nonce tag; treating as no PoW") + return false + } + + val actualDifficulty = calculateDifficulty(event.id) + val committedDifficulty = getCommittedDifficulty(event) + + Log.d(TAG, "Validating PoW: actual=$actualDifficulty, required=$minimumDifficulty, committed=$committedDifficulty") + + // Check if actual difficulty meets requirement + if (actualDifficulty < minimumDifficulty) { + Log.w(TAG, "Event ${event.id.take(16)}... has insufficient difficulty: $actualDifficulty < $minimumDifficulty") + return false + } + + // If there's a committed difficulty, it should match or exceed the minimum + if (committedDifficulty != null && committedDifficulty < minimumDifficulty) { + Log.w(TAG, "Event ${event.id.take(16)}... has committed difficulty $committedDifficulty but achieved $actualDifficulty (possible spam)") + return false + } + + return true + } + + /** + * Mine a Nostr event to achieve the target difficulty + * @param event The event to mine (will be modified with nonce tag) + * @param targetDifficulty The target difficulty to achieve + * @param maxIterations Maximum number of iterations before giving up (default: 1,000,000) + * @return The mined event with nonce tag, or null if mining failed + */ + suspend fun mineEvent( + event: NostrEvent, + targetDifficulty: Int, + maxIterations: Int = 1_000_000 + ): NostrEvent? = withContext(Dispatchers.Default) { + if (targetDifficulty <= 0) return@withContext event + + Log.d(TAG, "Starting PoW mining for difficulty $targetDifficulty...") + val startTime = System.currentTimeMillis() + + var nonce = Random.nextLong(0, 1_000_000).toString() + var iterations = 0 + + while (iterations < maxIterations) { + // Create a copy of the event with the nonce tag + val eventWithNonce = addNonceTag(event, nonce, targetDifficulty) + + // Calculate the event ID + val eventId = eventWithNonce.computeEventIdHex() + val actualDifficulty = calculateDifficulty(eventId) + + if (actualDifficulty >= targetDifficulty) { + val timeElapsed = System.currentTimeMillis() - startTime + Log.i(TAG, "✅ PoW mining successful! Difficulty: $actualDifficulty, iterations: $iterations, time: ${timeElapsed}ms") + + // Return the event with the computed ID + return@withContext eventWithNonce.copy(id = eventId) + } + + // Increment nonce and try again + nonce = (nonce.toLongOrNull()?.plus(1) ?: Random.nextLong()).toString() + iterations++ + + // Log progress every 100,000 iterations + if (iterations % 100_000 == 0) { + val timeElapsed = System.currentTimeMillis() - startTime + Log.d(TAG, "PoW mining progress: $iterations iterations, ${timeElapsed}ms elapsed") + } + } + + val timeElapsed = System.currentTimeMillis() - startTime + Log.w(TAG, "❌ PoW mining failed after $maxIterations iterations (${timeElapsed}ms)") + return@withContext null + } + + /** + * Add or update the nonce tag in an event + * @param event The original event + * @param nonce The nonce value + * @param targetDifficulty The target difficulty being attempted + * @return A new event with the nonce tag added/updated + */ + private fun addNonceTag(event: NostrEvent, nonce: String, targetDifficulty: Int): NostrEvent { + val newTags = event.tags.toMutableList() + + // Remove existing nonce tag if present + newTags.removeAll { tag -> tag.isNotEmpty() && tag[0] == "nonce" } + + // Add new nonce tag with format: ["nonce", nonce_value, target_difficulty] + newTags.add(listOf("nonce", nonce, targetDifficulty.toString())) + + // Update created_at as recommended by NIP-13 + val updatedCreatedAt = (System.currentTimeMillis() / 1000).toInt() + + return event.copy( + tags = newTags, + createdAt = updatedCreatedAt + ) + } + + /** + * Get the committed difficulty from an event's nonce tag + * @param event The event to check + * @return The committed difficulty, or null if not present + */ + private fun getCommittedDifficulty(event: NostrEvent): Int? { + val nonceTag = event.tags.find { tag -> + tag.isNotEmpty() && tag[0] == "nonce" && tag.size >= 3 + } + + return nonceTag?.get(2)?.toIntOrNull() + } + + /** + * Check if an event has a nonce tag (indicating it was mined) + * @param event The event to check + * @return true if the event has a nonce tag + */ + fun hasNonce(event: NostrEvent): Boolean { + return event.tags.any { tag -> tag.isNotEmpty() && tag[0] == "nonce" } + } + + /** + * Get the nonce value from an event + * @param event The event to check + * @return The nonce value, or null if not present + */ + fun getNonce(event: NostrEvent): String? { + val nonceTag = event.tags.find { tag -> + tag.isNotEmpty() && tag[0] == "nonce" && tag.size >= 2 + } + + return nonceTag?.get(1) + } + + /** + * Estimate the computational work required for a given difficulty + * @param difficulty The target difficulty + * @return Estimated number of hash operations required + */ + fun estimateWork(difficulty: Int): Long { + return if (difficulty <= 0) 1L else 1L shl difficulty + } + + /** + * Get a human-readable description of the estimated mining time + * @param difficulty The target difficulty + * @param hashesPerSecond Estimated hashes per second (default: 100,000) + * @return Human-readable time estimate + */ + fun estimateMiningTime(difficulty: Int, hashesPerSecond: Int = 100_000): String { + val estimatedHashes = estimateWork(difficulty) + val estimatedSeconds = estimatedHashes / hashesPerSecond + + return when { + estimatedSeconds < 1 -> "< 1 second" + estimatedSeconds < 60 -> "${estimatedSeconds}s" + estimatedSeconds < 3600 -> "${estimatedSeconds / 60}m ${estimatedSeconds % 60}s" + estimatedSeconds < 86400 -> "${estimatedSeconds / 3600}h ${(estimatedSeconds % 3600) / 60}m" + else -> "${estimatedSeconds / 86400}d ${(estimatedSeconds % 86400) / 3600}h" + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 5b646088..7463a378 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -3,6 +3,8 @@ package com.bitchat.android.nostr import android.util.Log import com.google.gson.Gson import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * NIP-17 Protocol Implementation for Private Direct Messages @@ -35,7 +37,7 @@ object NostrProtocol { val rumorId = rumorBase.computeEventIdHex() val rumor = rumorBase.copy(id = rumorId) - // 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days + // 2. Seal the rumor with 2 hours of slack inside iOS's 24-hour lookback. val sealedEvent = createSeal( rumor = rumor, recipientPubkey = recipientPubkey, @@ -71,14 +73,24 @@ object NostrProtocol { } Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...") - + + if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) { + Log.w(TAG, "❌ Invalid NIP-17 seal signature") + return null + } + // 2. Open the seal val rumor = openSeal(seal, recipientIdentity.privateKeyHex) ?: run { Log.w(TAG, "❌ Failed to open seal") return null } - + + if (seal.pubkey != rumor.pubkey) { + Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey") + return null + } + Log.v(TAG, "Successfully opened seal") Triple(rumor.content, rumor.pubkey, rumor.createdAt) @@ -89,15 +101,66 @@ object NostrProtocol { } /** - * Create a geohash-scoped ephemeral public message (kind 20000) + * Create a geohash-scoped text note (kind 1) with optional nickname + * This creates a persistent text note that can be retrieved later */ - fun createEphemeralGeohashEvent( + suspend fun createGeohashTextNote( + content: String, + geohash: String, + senderIdentity: NostrIdentity, + nickname: String? = null + ): NostrEvent = withContext(Dispatchers.Default) { + val tags = mutableListOf>() + tags.add(listOf("g", geohash)) + + if (!nickname.isNullOrEmpty()) { + tags.add(listOf("n", nickname)) + } + + val event = NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.TEXT_NOTE, + tags = tags, + content = content + ) + + return@withContext senderIdentity.signEvent(event) + } + + /** + * Create a geohash-scoped presence event (kind 20001) + * Has no content and no nickname, used for participant counting + */ + suspend fun createGeohashPresenceEvent( + geohash: String, + senderIdentity: NostrIdentity + ): NostrEvent = withContext(Dispatchers.Default) { + val tags = mutableListOf>() + tags.add(listOf("g", geohash)) + + val event = NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.GEOHASH_PRESENCE, + tags = tags, + content = "" + ) + + return@withContext senderIdentity.signEvent(event) + } + + /** + * Create a geohash-scoped ephemeral public message (kind 20000) + * Includes Proof of Work mining if enabled in settings + */ + suspend fun createEphemeralGeohashEvent( content: String, geohash: String, senderIdentity: NostrIdentity, nickname: String? = null, teleported: Boolean = false - ): NostrEvent { + ): NostrEvent = withContext(Dispatchers.Default) { val tags = mutableListOf>() tags.add(listOf("g", geohash)) @@ -110,7 +173,7 @@ object NostrProtocol { tags.add(listOf("t", "teleport")) } - val event = NostrEvent( + var event = NostrEvent( pubkey = senderIdentity.publicKeyHex, createdAt = (System.currentTimeMillis() / 1000).toInt(), kind = NostrKind.EPHEMERAL_EVENT, @@ -118,7 +181,36 @@ object NostrProtocol { content = content ) - return senderIdentity.signEvent(event) + // Check if Proof of Work is enabled + val powSettings = PoWPreferenceManager.getCurrentSettings() + if (powSettings.enabled && powSettings.difficulty > 0) { + Log.d(TAG, "PoW enabled for geohash event: difficulty=${powSettings.difficulty}") + + try { + // Start mining state for animated indicators + PoWPreferenceManager.startMining() + + // Mine the event before signing + val minedEvent = NostrProofOfWork.mineEvent( + event = event, + targetDifficulty = powSettings.difficulty, + maxIterations = 2_000_000 // Allow up to 2M iterations for reasonable mining time + ) + + if (minedEvent != null) { + event = minedEvent + val actualDifficulty = NostrProofOfWork.calculateDifficulty(event.id) + Log.d(TAG, "✅ PoW mining successful: target=${powSettings.difficulty}, actual=$actualDifficulty, nonce=${NostrProofOfWork.getNonce(event)}") + } else { + Log.w(TAG, "❌ PoW mining failed, proceeding without PoW") + } + } finally { + // Always stop mining state when done (success or failure) + PoWPreferenceManager.stopMining() + } + } + + return@withContext senderIdentity.signEvent(event) } // MARK: - Private Methods @@ -145,7 +237,7 @@ object NostrProtocol { content = encrypted ) - // Sign with the ephemeral key + // NIP-17 requires the seal to be signed by the sender identity key. return seal.sign(senderPrivateKey) } @@ -182,8 +274,6 @@ object NostrProtocol { giftWrap: NostrEvent, recipientPrivateKey: String ): NostrEvent? { - Log.d(TAG, "Unwrapping gift wrap; content prefix='${giftWrap.content.take(3)}' length=${giftWrap.content.length}") - return try { val decrypted = NostrCrypto.decryptNIP44( ciphertext = giftWrap.content, diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 2bf252ab..81c1cac6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -1,17 +1,19 @@ package com.bitchat.android.nostr import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.google.gson.Gson -import com.google.gson.JsonArray +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import com.google.gson.JsonParser import kotlinx.coroutines.* import okhttp3.* +import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit -import kotlin.math.min -import kotlin.math.pow +import java.util.concurrent.atomic.AtomicBoolean /** * Manages WebSocket connections to Nostr relays @@ -24,11 +26,15 @@ class NostrRelayManager private constructor() { val shared = NostrRelayManager() private const val TAG = "NostrRelayManager" + private const val MAX_QUEUED_EVENTS = 500 + const val OWNER_LEGACY = "legacy" + const val OWNER_BACKGROUND = "background" /** * Get instance for Android compatibility (context-aware calls) */ fun getInstance(context: android.content.Context): NostrRelayManager { + shared.appContext = context.applicationContext return shared } @@ -40,12 +46,8 @@ class NostrRelayManager private constructor() { "wss://nostr21.com" ) - // Exponential backoff configuration (same as iOS) - private const val INITIAL_BACKOFF_INTERVAL = 1000L // 1 second - private const val MAX_BACKOFF_INTERVAL = 300000L // 5 minutes - private const val BACKOFF_MULTIPLIER = 2.0 - private const val MAX_RECONNECT_ATTEMPTS = 10 - + // Reconnect backoff lives in RelayReconnectPolicy. + // Track gift-wraps we initiated for logging private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() @@ -72,15 +74,17 @@ class NostrRelayManager private constructor() { ) // Published state - private val _relays = MutableLiveData>() - val relays: LiveData> = _relays + private val _relays = MutableStateFlow>(emptyList()) + val relays: StateFlow> = _relays.asStateFlow() - private val _isConnected = MutableLiveData() - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Internal state private val relaysList = mutableListOf() private val connections = ConcurrentHashMap() + private val reconnectJobs = ConcurrentHashMap() + private val desiredConnected = AtomicBoolean(false) private val subscriptions = ConcurrentHashMap>() // relay URL -> subscription IDs private val messageHandlers = ConcurrentHashMap Unit>() @@ -96,22 +100,25 @@ class NostrRelayManager private constructor() { val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays val createdAt: Long = System.currentTimeMillis(), - val originGeohash: String? = null // used for logging and grouping + val originGeohash: String? = null, + val owner: String = OWNER_LEGACY, + val liveLocationToken: Long? = null ) // Event deduplication system private val eventDeduplicator = NostrEventDeduplicator.getInstance() - // Message queue for reliability - private val messageQueue = mutableListOf>>() - private val messageQueueLock = Any() + // Bounded per-relay delivery queue for reconnect reliability. + private val messageQueue = NostrPendingEventQueue(MAX_QUEUED_EVENTS) // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Subscription validation timer private var subscriptionValidationJob: Job? = null - private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds + private val powerManager: com.bitchat.android.mesh.PowerManager? + get() = appContext?.let { com.bitchat.android.mesh.PowerManager.getInstance(it) } + @Volatile private var appContext: android.content.Context? = null // OkHttp client for WebSocket connections (via provider to honor Tor) private val httpClient: OkHttpClient @@ -121,27 +128,49 @@ class NostrRelayManager private constructor() { // Per-geohash relay selection private val geohashToRelays = ConcurrentHashMap>() // geohash -> relay URLs + private val liveGeohashTokens = ConcurrentHashMap() + private val liveLocationRelayTokens = ConcurrentHashMap() + private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet() + private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet() // --- Public API for geohash-specific operation --- /** * Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping. */ - fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) { + fun ensureGeohashRelaysConnected( + geohash: String, + nRelays: Int = 5, + includeDefaults: Boolean = false, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return try { val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays) val selected = if (includeDefaults) { (nearest + Companion.defaultRelays()).toSet() } else nearest.toSet() if (selected.isEmpty()) { - Log.w(TAG, "No relays selected for geohash=$geohash") + Log.w(TAG, "No relays selected for a geohash") return } - geohashToRelays[geohash] = selected - Log.i(TAG, "🌐 Geohash $geohash using ${selected.size} relays: ${selected.joinToString()}") - ensureConnectionsFor(selected) + runNetworkAction(liveLocationToken) { + geohashToRelays[geohash] = selected + if (liveLocationToken == null) { + liveGeohashTokens.remove(geohash) + nonLiveRelayUrls.addAll(selected) + } else { + liveGeohashTokens[geohash] = liveLocationToken + selected.forEach { relayUrl -> + if (relayUrl !in nonLiveRelayUrls) { + liveLocationRelayTokens[relayUrl] = liveLocationToken + } + } + } + ensureConnectionsFor(selected, liveLocationToken) + } } catch (e: Exception) { - Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}") + Log.e(TAG, "Failed to ensure geohash relays") } } @@ -161,20 +190,30 @@ class NostrRelayManager private constructor() { id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, includeDefaults: Boolean = false, - nRelays: Int = 5 + nRelays: Int = 5, + owner: String = OWNER_LEGACY, + liveLocationToken: Long? = null ): String { - ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults) + if (!isNetworkActionAllowed(liveLocationToken)) return id + ensureGeohashRelaysConnected( + geohash, + nRelays, + includeDefaults, + liveLocationToken + ) + if (!isNetworkActionAllowed(liveLocationToken)) return id val relayUrls = getRelaysForGeohash(geohash) - Log.d(TAG, "📡 Subscribing id=$id for geohash=$geohash on ${relayUrls.size} relays") return subscribe( filter = filter, id = id, handler = handler, - targetRelayUrls = relayUrls - ).also { - // update origin geohash for this subscription - activeSubscriptions[it]?.let { sub -> - activeSubscriptions[it] = sub.copy(originGeohash = geohash) + targetRelayUrls = relayUrls, + owner = owner, + liveLocationToken = liveLocationToken + ).also { subscriptionId -> + activeSubscriptions[subscriptionId]?.let { subscription -> + activeSubscriptions[subscriptionId] = + subscription.copy(originGeohash = geohash) } } } @@ -182,21 +221,121 @@ class NostrRelayManager private constructor() { /** * Send an event specifically to a geohash's relays (+ optional defaults). */ - fun sendEventToGeohash(event: NostrEvent, geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5) { - ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults) + fun sendEventToGeohash( + event: NostrEvent, + geohash: String, + includeDefaults: Boolean = false, + nRelays: Int = 5, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return + ensureGeohashRelaysConnected( + geohash, + nRelays, + includeDefaults, + liveLocationToken + ) + if (!isNetworkActionAllowed(liveLocationToken)) return val relayUrls = getRelaysForGeohash(geohash) if (relayUrls.isEmpty()) { - Log.w(TAG, "No target relays to send event for geohash=$geohash; falling back to defaults") - sendEvent(event, Companion.defaultRelays()) + Log.w(TAG, "No target relays for geohash event; falling back to defaults") + sendEvent(event, Companion.defaultRelays(), liveLocationToken) return } - Log.v(TAG, "📤 Sending event kind=${event.kind} to ${relayUrls.size} relays for geohash=$geohash") - sendEvent(event, relayUrls) + sendEvent(event, relayUrls, liveLocationToken) } // --- Internal helpers --- - private fun ensureConnectionsFor(relayUrls: Set) { + private fun isNetworkActionAllowed(liveLocationToken: Long?): Boolean = + liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken) + + private fun runNetworkAction( + liveLocationToken: Long?, + action: () -> Unit + ): Boolean = if (liveLocationToken == null) { + action() + true + } else { + LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action) + } + + /** + * Privacy teardown is allowed to bypass an already-revoked token solely to stop + * server-side delivery. Live subscription IDs are opaque, so CLOSE carries no + * geohash. If a CLOSE cannot be queued, fail closed by dropping that socket. + */ + private fun closeSubscriptionsOnConnectedRelays(subscriptionIds: Set) { + if (subscriptionIds.isEmpty()) return + + val closeTargets = NostrLiveSubscriptionPrivacy.closeTargets( + liveSubscriptionIds = subscriptionIds, + subscriptionsByRelay = subscriptions, + ) + closeTargets.forEach { (relayUrl, relaySubscriptionIds) -> + val webSocket = connections[relayUrl] ?: return@forEach + for (subscriptionId in relaySubscriptionIds) { + val request = NostrRequest.Close(subscriptionId) + val message = gson.toJson(request, NostrRequest::class.java) + val closeQueued = runCatching { webSocket.send(message) } + .getOrDefault(false) + if (!closeQueued) { + connections.remove(relayUrl, webSocket) + subscriptions.remove(relayUrl) + webSocket.cancel() + updateRelayStatus( + relayUrl, + isConnected = false, + error = IllegalStateException("Failed to close revoked subscription") + ) + if (desiredConnected.get() && relayUrl in nonLiveRelayUrls) { + scope.launch { connectToRelay(relayUrl, liveLocationToken = null) } + } + break + } + } + } + } + + private fun revokeLiveLocationAccess() { + liveLocationConnectionJobs.forEach(Job::cancel) + liveLocationConnectionJobs.clear() + + val liveSubscriptionIds = activeSubscriptions.values + .filter { it.liveLocationToken != null } + .mapTo(mutableSetOf()) { it.id } + closeSubscriptionsOnConnectedRelays(liveSubscriptionIds) + liveSubscriptionIds.forEach { id -> + activeSubscriptions.remove(id) + messageHandlers.remove(id) + } + subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } + + messageQueue.removeLiveLocationEvents() + + liveGeohashTokens.keys.forEach(geohashToRelays::remove) + liveGeohashTokens.clear() + + val liveOnlyRelayUrls = liveLocationRelayTokens.keys + .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } + liveOnlyRelayUrls.forEach { relayUrl -> + connections.remove(relayUrl)?.cancel() + subscriptions.remove(relayUrl) + reconnectJobs.remove(relayUrl)?.cancel() + } + synchronized(relaysList) { + relaysList.removeAll { it.url in liveOnlyRelayUrls } + } + liveLocationRelayTokens.clear() + updateRelaysList() + updateConnectionStatus() + } + + private fun ensureConnectionsFor( + relayUrls: Set, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return // Ensure relays are tracked for UI/status relayUrls.forEach { url -> if (relaysList.none { it.url == url }) { @@ -205,15 +344,26 @@ class NostrRelayManager private constructor() { } updateRelaysList() - scope.launch { + if (!desiredConnected.get()) return + val job = scope.launch { + if (!desiredConnected.get() || + !isNetworkActionAllowed(liveLocationToken) + ) return@launch relayUrls.forEach { relayUrl -> launch { - if (!connections.containsKey(relayUrl)) { - connectToRelay(relayUrl) + if (desiredConnected.get() && + !connections.containsKey(relayUrl) && + isNetworkActionAllowed(liveLocationToken) + ) { + connectToRelay(relayUrl, liveLocationToken) } } } } + if (liveLocationToken != null) { + liveLocationConnectionJobs.add(job) + job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) } + } } init { @@ -226,14 +376,15 @@ class NostrRelayManager private constructor() { "wss://nostr21.com" ) relaysList.addAll(defaultRelayUrls.map { Relay(it) }) - _relays.postValue(relaysList.toList()) + nonLiveRelayUrls.addAll(defaultRelayUrls) + _relays.value = relaysList.toList() updateConnectionStatus() - Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays") + LiveLocationPrivacyGate.addRevocationListener(::revokeLiveLocationAccess) } catch (e: Exception) { Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e) // Initialize with empty list as fallback - _relays.postValue(emptyList()) - _isConnected.postValue(false) + _relays.value = emptyList() + _isConnected.value = false } } @@ -241,12 +392,16 @@ class NostrRelayManager private constructor() { * Connect to all configured relays */ fun connect() { - Log.d(TAG, "🌐 Connecting to ${relaysList.size} Nostr relays") - + desiredConnected.set(true) + Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays") scope.launch { relaysList.forEach { relay -> launch { - connectToRelay(relay.url) + val liveToken = liveLocationRelayTokens[relay.url] + ?.takeIf { relay.url !in nonLiveRelayUrls } + if (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) { + connectToRelay(relay.url, liveToken) + } } } } @@ -259,42 +414,64 @@ class NostrRelayManager private constructor() { * Disconnect from all relays */ fun disconnect() { - Log.d(TAG, "Disconnecting from all relays") - + Log.i(TAG, "Disconnecting from all Nostr relays") + desiredConnected.set(false) + // Stop subscription validation stopSubscriptionValidation() - - connections.values.forEach { webSocket -> + reconnectJobs.values.forEach(Job::cancel) + reconnectJobs.clear() + liveLocationConnectionJobs.forEach(Job::cancel) + liveLocationConnectionJobs.clear() + + val sockets = connections.values.toList() + connections.clear() + sockets.forEach { webSocket -> webSocket.close(1000, "Manual disconnect") } - connections.clear() - - // Clear subscriptions + + // Preserve logical subscriptions for controlled resets, but forget per-socket state. subscriptions.clear() - + relaysList.forEach { + it.isConnected = false + it.nextReconnectTime = null + } + updateRelaysList() updateConnectionStatus() } /** * Send an event to specified relays (or all if none specified) */ - fun sendEvent(event: NostrEvent, relayUrls: List? = null) { - val targetRelays = relayUrls ?: relaysList.map { it.url } - - // Add to queue for reliability - synchronized(messageQueueLock) { - messageQueue.add(Pair(event, targetRelays)) - } - - // Attempt immediate send - scope.launch { - targetRelays.forEach { relayUrl -> - val webSocket = connections[relayUrl] - if (webSocket != null) { - sendToRelay(event, webSocket, relayUrl) + fun sendEvent( + event: NostrEvent, + relayUrls: List? = null, + liveLocationToken: Long? = null + ) { + val targetRelays = (relayUrls ?: relaysList.map { it.url }) + .filter { it.isNotBlank() } + .distinct() + if (targetRelays.isEmpty()) return + + val queued = runNetworkAction(liveLocationToken) { + val queueId = messageQueue.enqueue( + event = event, + relayUrls = targetRelays, + liveLocationToken = liveLocationToken + ) ?: return@runNetworkAction + scope.launch { + if (!isNetworkActionAllowed(liveLocationToken)) return@launch + targetRelays.forEach { relayUrl -> + val webSocket = connections[relayUrl] + if (webSocket != null) { + if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) { + messageQueue.markDelivered(queueId, relayUrl) + } + } } } } + if (!queued) return } /** @@ -305,23 +482,24 @@ class NostrRelayManager private constructor() { filter: NostrFilter, id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, - targetRelayUrls: List? = null + targetRelayUrls: List? = null, + owner: String = OWNER_LEGACY, + liveLocationToken: Long? = null ): String { - // Store subscription info for persistent tracking val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, handler = handler, - targetRelayUrls = targetRelayUrls?.toSet() + targetRelayUrls = targetRelayUrls?.toSet(), + owner = owner, + liveLocationToken = liveLocationToken ) - activeSubscriptions[id] = subscriptionInfo - messageHandlers[id] = handler - - Log.d(TAG, "📡 Subscribing to Nostr filter id=$id ${filter.getDebugDescription()}") - - // Send subscription to appropriate relays - sendSubscriptionToRelays(subscriptionInfo) + runNetworkAction(liveLocationToken) { + activeSubscriptions[id] = subscriptionInfo + messageHandlers[id] = handler + sendSubscriptionToRelays(subscriptionInfo) + } return id } @@ -330,37 +508,36 @@ class NostrRelayManager private constructor() { * Send a subscription to the appropriate relays */ private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) { + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) - - // DEBUG: Log the actual serialized message format - Log.v(TAG, "🔍 DEBUG: Serialized subscription message: $message") - + scope.launch { + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList() targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { try { - val success = webSocket.send(message) - if (success) { - // Track subscription for this relay - val currentSubs = subscriptions[relayUrl] ?: emptySet() - subscriptions[relayUrl] = currentSubs + subscriptionInfo.id - - Log.v(TAG, "✅ Subscription '${subscriptionInfo.id}' sent to relay: $relayUrl") - } else { - Log.w(TAG, "❌ Failed to send subscription to $relayUrl: WebSocket send failed") + var success = false + runNetworkAction(subscriptionInfo.liveLocationToken) { + success = webSocket.send(message) + if (success) { + val currentSubs = subscriptions[relayUrl] ?: emptySet() + subscriptions[relayUrl] = + currentSubs + subscriptionInfo.id + } + } + if (!success) { + Log.w(TAG, "Failed to send subscription: WebSocket send failed") } } catch (e: Exception) { - Log.e(TAG, "❌ Failed to send subscription to $relayUrl: ${e.message}") + Log.e(TAG, "Failed to send subscription") } - } else { - Log.v(TAG, "⏳ Relay $relayUrl not connected, subscription will be sent on reconnection") } } - + if (connections.isEmpty()) { Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection") } @@ -376,48 +553,70 @@ class NostrRelayManager private constructor() { messageHandlers.remove(id) if (subscriptionInfo == null) { - Log.w(TAG, "⚠️ Attempted to unsubscribe from unknown subscription: $id") return } - - Log.d(TAG, "🚫 Unsubscribing from subscription: $id") - + + if (subscriptionInfo.liveLocationToken != null && + !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) + ) { + closeSubscriptionsOnConnectedRelays(setOf(id)) + subscriptions.replaceAll { _, ids -> ids - id } + return + } + val request = NostrRequest.Close(id) val message = gson.toJson(request, NostrRequest::class.java) scope.launch { + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { + closeSubscriptionsOnConnectedRelays(setOf(id)) + subscriptions.replaceAll { _, ids -> ids - id } + return@launch + } connections.forEach { (relayUrl, webSocket) -> val currentSubs = subscriptions[relayUrl] if (currentSubs?.contains(id) == true) { try { - webSocket.send(message) + runNetworkAction(subscriptionInfo.liveLocationToken) { + webSocket.send(message) + } subscriptions[relayUrl] = currentSubs - id - Log.v(TAG, "Unsubscribed '$id' from relay: $relayUrl") } catch (e: Exception) { - Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}") + Log.e(TAG, "Failed to unsubscribe from relay") } } } } } + + fun unsubscribeOwner(owner: String) { + activeSubscriptions.values + .filter { it.owner == owner } + .map { it.id } + .forEach(::unsubscribe) + } /** * Manually retry connection to a specific relay */ fun retryConnection(relayUrl: String) { val relay = relaysList.find { it.url == relayUrl } ?: return + desiredConnected.set(true) + val liveToken = liveLocationRelayTokens[relayUrl] + ?.takeIf { relayUrl !in nonLiveRelayUrls } + if (!isNetworkActionAllowed(liveToken)) return // Reset reconnection attempts relay.reconnectAttempts = 0 relay.nextReconnectTime = null // Disconnect if connected - connections[relayUrl]?.close(1000, "Manual retry") - connections.remove(relayUrl) + reconnectJobs.remove(relayUrl)?.cancel() + connections.remove(relayUrl)?.close(1000, "Manual retry") // Attempt immediate reconnection scope.launch { - connectToRelay(relayUrl) + connectToRelay(relayUrl, liveToken) } } @@ -426,6 +625,7 @@ class NostrRelayManager private constructor() { * This will automatically restore all subscriptions when reconnected */ fun resetAllConnections() { + val shouldReconnect = desiredConnected.get() disconnect() // Reset all relay states @@ -435,8 +635,8 @@ class NostrRelayManager private constructor() { relay.lastError = null } - // Reconnect - subscriptions will be automatically restored in onOpen - connect() + // Reconnect only when connectivity was desired before the controlled reset. + if (shouldReconnect) connect() } /** @@ -444,8 +644,6 @@ class NostrRelayManager private constructor() { * Useful for ensuring subscription consistency after network issues */ fun reestablishAllSubscriptions() { - Log.d(TAG, "🔄 Force re-establishing all ${activeSubscriptions.size} active subscriptions") - scope.launch { connections.forEach { (relayUrl, webSocket) -> restoreSubscriptionsForRelay(relayUrl, webSocket) @@ -468,15 +666,31 @@ class NostrRelayManager private constructor() { geohashToRelays.clear() // Clear any queued messages waiting to be sent - synchronized(messageQueueLock) { - messageQueue.clear() - } + messageQueue.clear() - Log.i(TAG, "🧹 Cleared all Nostr subscriptions and routing caches") + Log.i(TAG, "Cleared all Nostr subscriptions and routing caches") } catch (e: Exception) { Log.e(TAG, "Failed to clear subscriptions: ${e.message}") } } + + /** + * Clear all subscription tracking, deduplication cache, message queue, and connections for panic mode. + */ + fun clearAllOnPanic() { + try { + val wasConnected = desiredConnected.get() + clearAllSubscriptions() + clearDeduplicationCache() + disconnect() + if (wasConnected) { + desiredConnected.set(true) + } + Log.w(TAG, "🚨 Cleared NostrRelayManager subscriptions, cache, and connections for panic mode") + } catch (e: Exception) { + Log.e(TAG, "Failed to clear NostrRelayManager on panic: ${e.message}") + } + } /** * Get detailed status for all relays @@ -497,7 +711,6 @@ class NostrRelayManager private constructor() { */ fun clearDeduplicationCache() { eventDeduplicator.clear() - Log.i(TAG, "🧹 Cleared event deduplication cache") } /** @@ -563,36 +776,52 @@ class NostrRelayManager private constructor() { stopSubscriptionValidation() // Stop any existing validation subscriptionValidationJob = scope.launch { - while (isActive) { - delay(SUBSCRIPTION_VALIDATION_INTERVAL) - - try { - val report = validateSubscriptionConsistency() - if (!report.isConsistent && report.connectedRelayCount > 0) { - Log.w(TAG, "⚠️ Subscription inconsistencies detected: ${report.inconsistencies}") - - // Auto-repair: re-establish subscriptions for relays with missing ones - connections.forEach { (relayUrl, webSocket) -> - val currentSubs = subscriptions[relayUrl] ?: emptySet() - val expectedSubs = activeSubscriptions.keys.filter { subId -> - val subInfo = activeSubscriptions[subId] - subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl) - }.toSet() - - val missingSubs = expectedSubs - currentSubs - if (missingSubs.isNotEmpty()) { - Log.i(TAG, "🔧 Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl") - restoreSubscriptionsForRelay(relayUrl, webSocket) - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error during subscription validation: ${e.message}") + val manager = powerManager + if (manager == null) { + runSubscriptionValidationLoop( + com.bitchat.android.util.AppConstants.Nostr + .SUBSCRIPTION_VALIDATION_INTERVAL_MS + ) + return@launch + } + + manager.profile + .map { it.nostr.subscriptionValidationMs } + .distinctUntilChanged() + .collectLatest(::runSubscriptionValidationLoop) + } + } + + private suspend fun runSubscriptionValidationLoop(intervalMs: Long) { + while (currentCoroutineContext().isActive && desiredConnected.get()) { + delay(intervalMs) + if (!desiredConnected.get()) break + validateAndRepairSubscriptions() + } + } + + private fun validateAndRepairSubscriptions() { + try { + val report = validateSubscriptionConsistency() + if (report.isConsistent || report.connectedRelayCount == 0) return + + Log.w(TAG, "Nostr subscription inconsistencies detected") + connections.forEach { (relayUrl, webSocket) -> + val currentSubs = subscriptions[relayUrl] ?: emptySet() + val expectedSubs = activeSubscriptions.keys.filter { subId -> + val subInfo = activeSubscriptions[subId] + subInfo?.targetRelayUrls == null || + subInfo.targetRelayUrls.contains(relayUrl) + }.toSet() + + if ((expectedSubs - currentSubs).isNotEmpty()) { + Log.i(TAG, "Auto-repairing missing subscriptions") + restoreSubscriptionsForRelay(relayUrl, webSocket) } } + } catch (e: Exception) { + Log.e(TAG, "Error during subscription validation: ${e.message}") } - - Log.d(TAG, "🔄 Started periodic subscription validation (${SUBSCRIPTION_VALIDATION_INTERVAL / 1000}s interval)") } /** @@ -601,59 +830,87 @@ class NostrRelayManager private constructor() { private fun stopSubscriptionValidation() { subscriptionValidationJob?.cancel() subscriptionValidationJob = null - Log.v(TAG, "⏹️ Stopped subscription validation") } // MARK: - Private Methods - private suspend fun connectToRelay(urlString: String) { + private suspend fun connectToRelay( + urlString: String, + liveLocationToken: Long? = null + ) { + if (!desiredConnected.get()) return + val connectionToken = liveLocationToken + ?.takeIf { urlString !in nonLiveRelayUrls } + if (!isNetworkActionAllowed(connectionToken)) return // Skip if we already have a connection if (connections.containsKey(urlString)) { return } - - Log.v(TAG, "Attempting to connect to Nostr relay: $urlString") - + try { val request = Request.Builder() .url(urlString) .build() - val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString)) - connections[urlString] = webSocket + val started = runNetworkAction(connectionToken) { + val webSocket = httpClient.newWebSocket( + request, + RelayWebSocketListener(urlString, connectionToken) + ) + val existing = connections.putIfAbsent(urlString, webSocket) + when { + existing != null -> webSocket.close(1000, "Duplicate connection") + !desiredConnected.get() -> { + connections.remove(urlString, webSocket) + webSocket.close(1000, "Connection no longer desired") + } + } + } + if (!started) return } catch (e: Exception) { - Log.e(TAG, "❌ Failed to create WebSocket connection to $urlString: ${e.message}") - handleDisconnection(urlString, e) + Log.e(TAG, "Failed to create WebSocket connection") + handleConnectionCreationFailure(urlString, e, connectionToken) } } - private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { - try { + private fun sendToRelay( + event: NostrEvent, + webSocket: WebSocket, + relayUrl: String, + liveLocationToken: Long? = null + ): Boolean { + if (!isNetworkActionAllowed(liveLocationToken)) return false + return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) - - Log.v(TAG, "📤 Sending Nostr event (kind: ${event.kind}) to relay: $relayUrl") - - val success = webSocket.send(message) + + var success = false + runNetworkAction(liveLocationToken) { + success = webSocket.send(message) + } if (success) { // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.messagesSent = (relay?.messagesSent ?: 0) + 1 + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesSent += 1 + } updateRelaysList() + true } else { - Log.e(TAG, "❌ Failed to send event to $relayUrl: WebSocket send failed") + Log.e(TAG, "Failed to send event: WebSocket send failed") + false } } catch (e: Exception) { - Log.e(TAG, "❌ Failed to send event to $relayUrl: ${e.message}") + Log.e(TAG, "Failed to send event") + false } } - + private fun handleMessage(message: String, relayUrl: String) { try { val jsonElement = JsonParser.parseString(message) if (!jsonElement.isJsonArray) { - Log.w(TAG, "Received non-array message from $relayUrl") + Log.w(TAG, "Received non-array message from relay") return } @@ -662,109 +919,124 @@ class NostrRelayManager private constructor() { when (response) { is NostrResponse.Event -> { // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1 + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesReceived += 1 + } updateRelaysList() - // DEDUPLICATION: Check if we've already processed this event - val wasProcessed = eventDeduplicator.processEvent(response.event) { event -> - // Only log non-gift-wrap events to reduce noise - if (event.kind != NostrKind.GIFT_WRAP) { - val originGeo = activeSubscriptions[response.subscriptionId]?.originGeohash - if (originGeo != null) { - Log.v(TAG, "📥 Processing event (kind=${event.kind}) from relay=$relayUrl geo=$originGeo sub=${response.subscriptionId}") - } else { - Log.v(TAG, "📥 Processing event (kind=${event.kind}) from relay=$relayUrl sub=${response.subscriptionId}") - } + // CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter + val subscriptionInfo = activeSubscriptions[response.subscriptionId] + ?: return + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return + subscriptionInfo.let { subInfo -> + val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true } + if (!matches) { + // Do NOT call deduplicator here to allow the correct subscription to process it later + return } - + } + + // DEDUPLICATION: Check if we've already processed this event + eventDeduplicator.processEvent(response.event) { event -> // Call handler for new events only val handler = messageHandlers[response.subscriptionId] if (handler != null) { scope.launch(Dispatchers.Main) { - handler(event) + if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { + handler(event) + } } } else { - Log.w(TAG, "⚠️ No handler for subscription ${response.subscriptionId}") + Log.w(TAG, "⚠️ No handler for Nostr subscription") } } - - if (!wasProcessed) { - //Log.v(TAG, "🔄 Duplicate event ${response.event.id.take(16)}... from relay: $relayUrl") - } + } - + is NostrResponse.EndOfStoredEvents -> { - Log.v(TAG, "End of stored events for subscription: ${response.subscriptionId}") + // No action needed } - + is NostrResponse.Ok -> { val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) - if (response.accepted) { - Log.d(TAG, "✅ Event accepted id=${response.eventId.take(16)}... by relay: $relayUrl") - } else { + if (!response.accepted) { val level = if (wasGiftWrap) Log.WARN else Log.ERROR - Log.println(level, TAG, "📮 Event ${response.eventId.take(16)}... rejected by relay: ${response.message ?: "no reason"}") + Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}") } } - + is NostrResponse.Notice -> { - Log.i(TAG, "📢 Notice from $relayUrl: ${response.message}") + // No action needed } - + is NostrResponse.Unknown -> { - Log.v(TAG, "Unknown message type from $relayUrl: ${response.raw}") + // No action needed } } } catch (e: Exception) { - Log.e(TAG, "Failed to parse message from $relayUrl: ${e.message}") + Log.e(TAG, "Failed to parse relay message") } } - private fun handleDisconnection(relayUrl: String, error: Throwable) { - connections.remove(relayUrl) - // NOTE: Don't remove subscriptions here - keep them for restoration on reconnection - // subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss - + private fun handleDisconnection( + relayUrl: String, + webSocket: WebSocket, + error: Throwable, + liveLocationToken: Long? = null + ) { + // Ignore callbacks from intentionally closed or replaced sockets. They must not remove a + // newer socket or schedule a reconnect after a controlled disconnect/privacy revocation. + if (!connections.remove(relayUrl, webSocket)) return + subscriptions.remove(relayUrl) + handleCurrentDisconnection(relayUrl, error, liveLocationToken) + } + + private fun handleConnectionCreationFailure( + relayUrl: String, + error: Throwable, + liveLocationToken: Long? + ) { + if (!desiredConnected.get()) return + handleCurrentDisconnection(relayUrl, error, liveLocationToken) + } + + private fun handleCurrentDisconnection( + relayUrl: String, + error: Throwable, + liveLocationToken: Long? + ) { + val connectionToken = liveLocationToken + ?.takeIf { relayUrl !in nonLiveRelayUrls } + updateRelayStatus(relayUrl, false, error) - - // Check if this is a DNS error - val errorMessage = error.message?.lowercase() ?: "" - if (errorMessage.contains("hostname could not be found") || - errorMessage.contains("dns") || - errorMessage.contains("unable to resolve host")) { - - val relay = relaysList.find { it.url == relayUrl } - if (relay?.lastError == null) { - Log.w(TAG, "Nostr relay DNS failure for $relayUrl - not retrying") - } - return - } - - // Implement exponential backoff for non-DNS errors + if (!desiredConnected.get() || + !isNetworkActionAllowed(connectionToken) + ) return + + // Every failure backs off and retries, including a name-resolution + // failure: "unable to resolve host" is what this device reports when it + // simply has no network, so treating it as permanent turns a walk + // through a tunnel into a dead relay layer for the rest of the process. val relay = relaysList.find { it.url == relayUrl } ?: return - relay.reconnectAttempts++ - - // Stop attempting after max attempts - if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - Log.w(TAG, "Max reconnection attempts ($MAX_RECONNECT_ATTEMPTS) reached for $relayUrl") - return - } - - // Calculate backoff interval - val backoffInterval = min( - INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0), - MAX_BACKOFF_INTERVAL.toDouble() - ).toLong() - + relay.reconnectAttempts = RelayReconnectPolicy.nextAttempt(relay.reconnectAttempts) + val backoffInterval = RelayReconnectPolicy.backoffMs(relay.reconnectAttempts) + relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval - Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})") + Log.d(TAG, "Scheduling Nostr relay reconnection") - // Schedule reconnection - scope.launch { + reconnectJobs.remove(relayUrl)?.cancel() + val reconnectJob = scope.launch { delay(backoffInterval) - connectToRelay(relayUrl) + if (desiredConnected.get() && + isNetworkActionAllowed(connectionToken) + ) { + connectToRelay(relayUrl, connectionToken) + } + } + reconnectJobs[relayUrl] = reconnectJob + reconnectJob.invokeOnCompletion { + reconnectJobs.remove(relayUrl, reconnectJob) } } @@ -787,16 +1059,16 @@ class NostrRelayManager private constructor() { } private fun updateRelaysList() { - _relays.postValue(relaysList.toList()) + _relays.value = relaysList.toList() } private fun updateConnectionStatus() { val connected = relaysList.any { it.isConnected } - _isConnected.postValue(connected) + _isConnected.value = connected } private fun generateSubscriptionId(): String { - return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}" + return "sub-${UUID.randomUUID()}" } /** @@ -805,33 +1077,34 @@ class NostrRelayManager private constructor() { private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) { val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo -> // Include subscription if it targets all relays or specifically targets this relay - subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl) + isNetworkActionAllowed(subscriptionInfo.liveLocationToken) && + (subscriptionInfo.targetRelayUrls == null || + subscriptionInfo.targetRelayUrls.contains(relayUrl)) } if (subscriptionsToRestore.isEmpty()) { - Log.v(TAG, "🔄 No subscriptions to restore for relay: $relayUrl") return } - - Log.d(TAG, "🔄 Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl") - + subscriptionsToRestore.forEach { subscriptionInfo -> try { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) - - val success = webSocket.send(message) - if (success) { - // Track subscription for this relay - val currentSubs = subscriptions[relayUrl] ?: emptySet() - subscriptions[relayUrl] = currentSubs + subscriptionInfo.id - - Log.v(TAG, "✅ Restored subscription '${subscriptionInfo.id}' to relay: $relayUrl") - } else { - Log.w(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed") + + var success = false + runNetworkAction(subscriptionInfo.liveLocationToken) { + success = webSocket.send(message) + if (success) { + val currentSubs = subscriptions[relayUrl] ?: emptySet() + subscriptions[relayUrl] = + currentSubs + subscriptionInfo.id + } + } + if (!success) { + Log.w(TAG, "Failed to restore subscription: WebSocket send failed") } } catch (e: Exception) { - Log.e(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}") + Log.e(TAG, "Failed to restore subscription") } } } @@ -839,44 +1112,59 @@ class NostrRelayManager private constructor() { /** * WebSocket listener for relay connections */ - private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() { + private inner class RelayWebSocketListener( + private val relayUrl: String, + private val liveLocationToken: Long? + ) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - Log.d(TAG, "✅ Connected to Nostr relay: $relayUrl") + if (!desiredConnected.get() || + connections[relayUrl] !== webSocket || + !isNetworkActionAllowed(liveLocationToken) + ) { + connections.remove(relayUrl, webSocket) + webSocket.close(1000, "Stale connection") + return + } + reconnectJobs.remove(relayUrl)?.cancel() updateRelayStatus(relayUrl, true) // Restore all active subscriptions for this relay restoreSubscriptionsForRelay(relayUrl, webSocket) - // Process any queued messages for this relay - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val (event, targetRelays) = iterator.next() - if (relayUrl in targetRelays) { - sendToRelay(event, webSocket, relayUrl) - } + // Process only events still pending for this relay, outside the queue lock. + val queuedForRelay = messageQueue.pendingForRelay(relayUrl) + .filter { isNetworkActionAllowed(it.liveLocationToken) } + queuedForRelay.forEach { delivery -> + if (sendToRelay( + delivery.event, + webSocket, + relayUrl, + delivery.liveLocationToken + ) + ) { + messageQueue.markDelivered(delivery.queueId, relayUrl) } } } override fun onMessage(webSocket: WebSocket, text: String) { + if (connections[relayUrl] !== webSocket) return handleMessage(text, relayUrl) } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "WebSocket closing for $relayUrl: $code $reason") + // Server-initiated close; onClosed will follow } - + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "WebSocket closed for $relayUrl: $code $reason") val error = Exception("WebSocket closed: $code $reason") - handleDisconnection(relayUrl, error) + handleDisconnection(relayUrl, webSocket, error, liveLocationToken) } - + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "❌ WebSocket failure for $relayUrl: ${t.message}") - handleDisconnection(relayUrl, t) + Log.e(TAG, "Nostr WebSocket failure") + handleDisconnection(relayUrl, webSocket, t, liveLocationToken) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt new file mode 100644 index 00000000..19ebeb9c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -0,0 +1,107 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.geohash.LiveLocationPrivacyGate + +/** + * NostrSubscriptionManager + * - Encapsulates ordered subscription lifecycle with NostrRelayManager. + * + * Relay-manager operations are already non-blocking and schedule network I/O on their own scope. + * Keeping this facade synchronous prevents lifecycle cancellation from dropping unsubscribe work + * and guarantees that a channel switch closes the old subscription before opening the new one. + */ +class NostrSubscriptionManager( + private val application: Application, + private val owner: String = NostrRelayManager.OWNER_LEGACY +) { + companion object { private const val TAG = "NostrSubscriptionManager" } + + private val relayManager get() = NostrRelayManager.getInstance(application) + + fun connect() { + runCatching { relayManager.connect() } + .onFailure { Log.e(TAG, "connect failed: ${it.message}") } + } + + fun disconnect() { + runCatching { relayManager.disconnect() } + .onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } + } + + fun subscribeGiftWraps( + pubkey: String, + sinceMs: Long, + id: String, + handler: (NostrEvent) -> Unit, + liveLocationToken: Long? = null + ) { + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) + relayManager.subscribe( + filter = filter, + id = id, + handler = handler, + owner = owner, + liveLocationToken = liveLocationToken + ) + } + + /** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */ + fun subscribeGeohashMessages( + geohash: String, + sinceMs: Long, + limit: Int, + id: String, + handler: (NostrEvent) -> Unit, + liveLocationToken: Long? = null + ) { + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner, + liveLocationToken = liveLocationToken + ) + } + + /** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */ + fun subscribeGeohashPresence( + geohash: String, + sinceMs: Long, + limit: Int, + id: String, + handler: (NostrEvent) -> Unit, + liveLocationToken: Long? = null + ) { + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner, + liveLocationToken = liveLocationToken + ) + } + + fun unsubscribe(id: String) { + runCatching { relayManager.unsubscribe(id) } + } + + fun unsubscribeAllOwned() { + runCatching { relayManager.unsubscribeOwner(owner) } + } + + private fun isAllowed(liveLocationToken: Long?): Boolean = + liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken) +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt index 66f3b095..76f42249 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt @@ -165,7 +165,7 @@ class NostrTestManager(private val context: Context) { // Subscribe to private messages (won't receive any in test, but tests the subscription mechanism) nostrClient.subscribeToPrivateMessages { content, senderNpub, timestamp -> - Log.d(TAG, "📥 Received test private message from $senderNpub: $content") + Log.d(TAG, "Received test private message (${content.length} chars)") messageReceived = true } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 217e081d..f39324fd 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -2,15 +2,17 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log +import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.ReadReceipt import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver import kotlinx.coroutines.* import java.util.* import java.util.concurrent.ConcurrentLinkedQueue /** - * Minimal Nostr transport for offline sending - * Direct port from iOS NostrTransport for 100% compatibility + * Nostr transport for offline private messages and receipts. */ class NostrTransport( private val context: Context, @@ -19,7 +21,7 @@ class NostrTransport( companion object { private const val TAG = "NostrTransport" - private const val READ_ACK_INTERVAL = 350L // ~3 per second (0.35s interval like iOS) + private const val READ_ACK_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.READ_ACK_INTERVAL_MS // ~3 per second (0.35s interval like iOS) @Volatile private var INSTANCE: NostrTransport? = null @@ -53,12 +55,7 @@ class NostrTransport( ) { transportScope.launch { try { - // Resolve favorite by full noise key or by short peerID fallback - var recipientNostrPubkey: String? = null - - // Try to resolve from favorites persistence service - // This would need integration with the existing favorites system - recipientNostrPubkey = resolveNostrPublicKey(to) + val recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for peerID: $to") @@ -71,28 +68,28 @@ class NostrTransport( return@launch } - Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...") - - // Convert recipient npub -> hex (x-only) - val recipientHex = try { - val (hrp, data) = Bech32.decode(recipientNostrPubkey) - if (hrp != "npub") { - Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)") - return@launch - } - data.joinToString("") { "%02x".format(it) } - } catch (e: Exception) { - Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e") + val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) + if (recipientHex == null) { + Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey") + return@launch + } + + val recipientPeerIDForEmbed = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .findPeerIDForNostrPubkey(recipientNostrPubkey) + } catch (_: Exception) { null } + if (recipientPeerIDForEmbed.isNullOrBlank()) { + Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM") return@launch } - val embedded = NostrEmbeddedBitChat.encodePMForNostr( content = content, messageID = messageID, - recipientPeerID = to, + recipientPeerID = recipientPeerIDForEmbed, senderPeerID = senderPeerID ) + if (embedded == null) { Log.e(TAG, "NostrTransport: failed to embed PM packet") return@launch @@ -105,10 +102,10 @@ class NostrTransport( ) giftWraps.forEach { event -> - Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...") + NostrRelayManager.registerPendingGiftWrap(event.id) NostrRelayManager.getInstance(context).sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send private message via Nostr: ${e.message}") } @@ -138,10 +135,7 @@ class NostrTransport( transportScope.launch { try { - var recipientNostrPubkey: String? = null - - // Try to resolve from favorites persistence service - recipientNostrPubkey = resolveNostrPublicKey(item.peerID) + val recipientNostrPubkey = resolveNostrPublicKey(item.peerID) if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}") @@ -156,17 +150,8 @@ class NostrTransport( return@launch } - Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...") - - // Convert recipient npub -> hex - val recipientHex = try { - val (hrp, data) = Bech32.decode(recipientNostrPubkey) - if (hrp != "npub") { - scheduleNextReadAck() - return@launch - } - data.joinToString("") { "%02x".format(it) } - } catch (e: Exception) { + val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) + if (recipientHex == null) { scheduleNextReadAck() return@launch } @@ -191,10 +176,10 @@ class NostrTransport( ) giftWraps.forEach { event -> - Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...") + NostrRelayManager.registerPendingGiftWrap(event.id) NostrRelayManager.getInstance(context).sendEvent(event) } - + scheduleNextReadAck() } catch (e: Exception) { @@ -215,10 +200,7 @@ class NostrTransport( fun sendFavoriteNotification(to: String, isFavorite: Boolean) { transportScope.launch { try { - var recipientNostrPubkey: String? = null - - // Try to resolve from favorites persistence service - recipientNostrPubkey = resolveNostrPublicKey(to) + val recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for favorite notification to: $to") @@ -231,20 +213,10 @@ class NostrTransport( return@launch } - val content = if (isFavorite) { - "[FAVORITED]:${senderIdentity.npub}" - } else { - "[UNFAVORITED]:${senderIdentity.npub}" - } - - Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...") - - // Convert recipient npub -> hex - val recipientHex = try { - val (hrp, data) = Bech32.decode(recipientNostrPubkey) - if (hrp != "npub") return@launch - data.joinToString("") { "%02x".format(it) } - } catch (e: Exception) { + val content = FavoriteControlMessage.encode(isFavorite, senderIdentity.npub) + + val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) + if (recipientHex == null) { return@launch } @@ -267,10 +239,10 @@ class NostrTransport( ) giftWraps.forEach { event -> - Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...") + NostrRelayManager.registerPendingGiftWrap(event.id) NostrRelayManager.getInstance(context).sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}") } @@ -280,10 +252,7 @@ class NostrTransport( fun sendDeliveryAck(messageID: String, to: String) { transportScope.launch { try { - var recipientNostrPubkey: String? = null - - // Try to resolve from favorites persistence service - recipientNostrPubkey = resolveNostrPublicKey(to) + val recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for delivery ack to: $to") @@ -296,16 +265,11 @@ class NostrTransport( return@launch } - Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...") - - val recipientHex = try { - val (hrp, data) = Bech32.decode(recipientNostrPubkey) - if (hrp != "npub") return@launch - data.joinToString("") { "%02x".format(it) } - } catch (e: Exception) { + val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) + if (recipientHex == null) { return@launch } - + val ack = NostrEmbeddedBitChat.encodeAckForNostr( type = NoisePayloadType.DELIVERED, messageID = messageID, @@ -325,10 +289,10 @@ class NostrTransport( ) giftWraps.forEach { event -> - Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...") + NostrRelayManager.registerPendingGiftWrap(event.id) NostrRelayManager.getInstance(context).sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}") } @@ -344,8 +308,6 @@ class NostrTransport( ) { transportScope.launch { try { - Log.d(TAG, "GeoDM: send DELIVERED -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...") - val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.DELIVERED, messageID = messageID, @@ -379,8 +341,6 @@ class NostrTransport( ) { transportScope.launch { try { - Log.d(TAG, "GeoDM: send READ -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...") - val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.READ_RECEIPT, messageID = messageID, @@ -412,39 +372,52 @@ class NostrTransport( fun sendPrivateMessageGeohash( content: String, toRecipientHex: String, - fromIdentity: NostrIdentity, - messageID: String + messageID: String, + sourceGeohash: String? = null ) { + // Use provided geohash or derive from current location + val geohash = sourceGeohash ?: run { + val selected = try { + com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value + } catch (_: Exception) { null } + if (selected !is com.bitchat.android.geohash.ChannelID.Location) { + Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided") + return + } + selected.channel.geohash + } + + val fromIdentity = try { + NostrIdentityBridge.deriveIdentity(geohash, context) + } catch (e: Exception) { + Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}") + return + } + transportScope.launch { try { if (toRecipientHex.isEmpty()) return@launch - - Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...") - + // Build embedded BitChat packet without recipient peer ID val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient( content = content, messageID = messageID, senderPeerID = senderPeerID - ) - - if (embedded == null) { + ) ?: run { Log.e(TAG, "NostrTransport: failed to embed geohash PM packet") return@launch } - + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - + giftWraps.forEach { event -> - Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...") NostrRelayManager.registerPendingGiftWrap(event.id) NostrRelayManager.getInstance(context).sendEvent(event) } - } catch (e: Exception) { Log.e(TAG, "Failed to send geohash private message: ${e.message}") } @@ -458,15 +431,17 @@ class NostrTransport( */ private fun resolveNostrPublicKey(peerID: String): String? { try { - // Try to resolve from favorites persistence service - val noiseKey = hexStringToByteArray(peerID) - val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - if (favoriteStatus?.peerNostrPublicKey != null) { - return favoriteStatus.peerNostrPublicKey + ContactDirectory.resolve(peerID).nostrPubkey?.let { return it } + + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it } + + if (ContactIdentityResolver.isNoiseKeyHex(peerID)) { + val noiseKey = ContactIdentityResolver.bytesFromHex(peerID) ?: return null + val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey } - - // Fallback: try with 16-hex peerID lookup - if (peerID.length == 16) { + + if (ContactIdentityResolver.isMeshPeerId(peerID)) { val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID) return fallbackStatus?.peerNostrPublicKey } @@ -478,14 +453,6 @@ class NostrTransport( } } - /** - * Convert full hex string to byte array - */ - private fun hexStringToByteArray(hexString: String): ByteArray { - val clean = if (hexString.length % 2 == 0) hexString else "0$hexString" - return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } - fun cleanup() { transportScope.cancel() } diff --git a/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt new file mode 100644 index 00000000..3a3042fe --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt @@ -0,0 +1,149 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages Proof of Work preferences for Nostr events + */ +object PoWPreferenceManager { + + private const val PREFS_NAME = "pow_preferences" + private const val KEY_POW_ENABLED = "pow_enabled" + private const val KEY_POW_DIFFICULTY = "pow_difficulty" + + // Default values + private const val DEFAULT_POW_ENABLED = false + private const val DEFAULT_POW_DIFFICULTY = 12 // Reasonable default for geohash spam prevention + + // State flows for reactive UI + private val _powEnabled = MutableStateFlow(DEFAULT_POW_ENABLED) + val powEnabled: StateFlow = _powEnabled.asStateFlow() + + private val _powDifficulty = MutableStateFlow(DEFAULT_POW_DIFFICULTY) + val powDifficulty: StateFlow = _powDifficulty.asStateFlow() + + // Mining state for animated indicators + private val _isMining = MutableStateFlow(false) + val isMining: StateFlow = _isMining.asStateFlow() + + private lateinit var sharedPrefs: SharedPreferences + private var isInitialized = false + + /** + * Initialize the preference manager with application context + * Should be called once during app startup + */ + fun init(context: Context) { + if (isInitialized) return + + sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Load current values + _powEnabled.value = sharedPrefs.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED) + _powDifficulty.value = sharedPrefs.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY) + + isInitialized = true + } + + /** + * Get current PoW enabled state + */ + fun isPowEnabled(): Boolean { + return _powEnabled.value + } + + /** + * Set PoW enabled state + */ + fun setPowEnabled(enabled: Boolean) { + _powEnabled.value = enabled + if (::sharedPrefs.isInitialized) { + sharedPrefs.edit().putBoolean(KEY_POW_ENABLED, enabled).apply() + } + } + + /** + * Get current PoW difficulty setting + */ + fun getPowDifficulty(): Int { + return _powDifficulty.value + } + + /** + * Set PoW difficulty (clamped between 0 and 32) + */ + fun setPowDifficulty(difficulty: Int) { + val clampedDifficulty = difficulty.coerceIn(0, 32) + _powDifficulty.value = clampedDifficulty + if (::sharedPrefs.isInitialized) { + sharedPrefs.edit().putInt(KEY_POW_DIFFICULTY, clampedDifficulty).apply() + } + } + + /** + * Get current settings as a data class + */ + data class PoWSettings( + val enabled: Boolean, + val difficulty: Int + ) + + /** + * Get current settings + */ + fun getCurrentSettings(): PoWSettings { + return PoWSettings( + enabled = _powEnabled.value, + difficulty = _powDifficulty.value + ) + } + + /** + * Reset to default settings + */ + fun resetToDefaults() { + setPowEnabled(DEFAULT_POW_ENABLED) + setPowDifficulty(DEFAULT_POW_DIFFICULTY) + } + + /** + * Get difficulty levels with descriptions for UI + */ + fun getDifficultyLevels(): List> { + return listOf( + 0 to "Disabled (no PoW)", + 8 to "Very Low (instant)", + 12 to "Low (~0.1s)", + 16 to "Medium (~2s)", + 20 to "High (~30s)", + 24 to "Very High (~8m)", + 28 to "Extreme (~2h)", + 32 to "Maximum (~8h)" + ) + } + + /** + * Get current mining state + */ + fun isMining(): Boolean { + return _isMining.value + } + + /** + * Start mining state - triggers animated indicators + */ + fun startMining() { + _isMining.value = true + } + + /** + * Stop mining state - stops animated indicators + */ + fun stopMining() { + _isMining.value = false + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index f591b2b8..980b95ef 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -92,7 +92,7 @@ object RelayDirectory { val c = com.bitchat.android.geohash.Geohash.decodeToCenter(geohash) c } catch (e: Exception) { - Log.e(TAG, "Failed to decode geohash '$geohash': ${e.message}") + Log.e(TAG, "Failed to decode geohash") return emptyList() } @@ -300,4 +300,3 @@ object RelayDirectory { } } } - diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayReconnectPolicy.kt b/app/src/main/java/com/bitchat/android/nostr/RelayReconnectPolicy.kt new file mode 100644 index 00000000..199e0dcf --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/RelayReconnectPolicy.kt @@ -0,0 +1,45 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants +import kotlin.math.min +import kotlin.math.pow + +/** + * Reconnect schedule for a Nostr relay socket. + * + * A phone loses its data connection constantly — airplane mode, a tunnel, a + * Wi-Fi to cellular handover, a dead zone — and every relay fails at once when + * it does. The schedule therefore has to survive an outage of arbitrary length + * and heal on its own, because nothing else will: the relay layer registers no + * connectivity callback, the periodic subscription validator only repairs + * subscriptions on sockets that are already open, and `connect()` runs once at + * startup. + * + * So the backoff grows exponentially and then *saturates* rather than + * terminating. Retrying forever at the ceiling costs one connection attempt per + * relay per [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS]; giving up costs the + * user every internet DM, delivery receipt and geohash channel until they + * notice and restart the app. + */ +internal object RelayReconnectPolicy { + + /** + * Attempt count past which the interval stops growing. Beyond this the + * exponential term is already above the ceiling, so pinning it keeps the + * schedule at a steady [AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS] and + * keeps the exponent from running away over a long outage. + */ + const val SATURATION_ATTEMPTS: Int = AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS + + /** Attempt number to record after a failure at [previousAttempts]. */ + fun nextAttempt(previousAttempts: Int): Int = + (previousAttempts.coerceAtLeast(0) + 1).coerceAtMost(SATURATION_ATTEMPTS) + + /** Delay before the reconnect for a given attempt number (1-based). */ + fun backoffMs(attempt: Int): Long { + val step = attempt.coerceAtLeast(1) + val exponential = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS * + AppConstants.Nostr.BACKOFF_MULTIPLIER.pow(step - 1.0) + return min(exponential, AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS.toDouble()).toLong() + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt new file mode 100644 index 00000000..14fbb06d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -0,0 +1,246 @@ +package com.bitchat.android.onboarding + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Security +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R + +/** + * Explanation screen shown before requesting background location permission. + * + * Bottom action layout matches the battery optimization skip screen so the + * primary / check-again / skip buttons stay pixel-aligned across both screens. + */ +@Composable +fun BackgroundLocationPermissionScreen( + modifier: Modifier, + onContinue: () -> Unit, + onRetry: () -> Unit, + onSkip: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + Box( + modifier = modifier.padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Scrollable content area + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderSection(colorScheme) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = stringResource(R.string.cd_location_services), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_required_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_explanation), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_settings_tip), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily + ), + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Security, + contentDescription = stringResource(R.string.cd_privacy_protected), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_needs_for), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_needs_bullets), + style = MaterialTheme.typography.bodySmall, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_privacy_note), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium + ), + color = colorScheme.onBackground + ) + } + } + } + } + } + + // Fixed buttons at the bottom — same structure/spacing as battery optimization + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) + ) { + Text( + text = stringResource(R.string.grant_background_location), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold + ) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.check_again), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ) + ) + } + + TextButton( + onClick = onSkip, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.battery_optimization_skip), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ) + ) + } + } + } + } + } +} + +@Composable +private fun HeaderSection(colorScheme: ColorScheme) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = colorScheme.onBackground + ) + + Text( + text = stringResource(R.string.background_location_required_subtitle), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.7f) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt new file mode 100644 index 00000000..0a73e346 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.onboarding + +import android.content.Context + +/** + * Preference manager for background location skip choice. + */ +object BackgroundLocationPreferenceManager { + private const val PREFS_NAME = "bitchat_settings" + private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped" + + fun setSkipped(context: Context, skipped: Boolean) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply() + } + + fun isSkipped(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false) + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationPreferenceManager.kt new file mode 100644 index 00000000..c749dc43 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationPreferenceManager.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.onboarding + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Preference manager for battery optimization skip choice + */ +object BatteryOptimizationPreferenceManager { + private const val PREFS_NAME = "bitchat_settings" + private const val KEY_BATTERY_SKIP = "battery_optimization_skipped" + + private val _skipFlow = MutableStateFlow(false) + val skipFlow: StateFlow = _skipFlow + + fun init(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val skipped = prefs.getBoolean(KEY_BATTERY_SKIP, false) + _skipFlow.value = skipped + } + + fun setSkipped(context: Context, skipped: Boolean) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_BATTERY_SKIP, skipped).apply() + _skipFlow.value = skipped + } + + fun isSkipped(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_BATTERY_SKIP, false) + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt index a5491f46..713fe7f8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -1,45 +1,50 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily 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 com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** * Screen shown when checking battery optimization status or requesting battery optimization disable */ - - @Composable fun BatteryOptimizationScreen( + modifier: Modifier, status: BatteryOptimizationStatus, onDisableBatteryOptimization: () -> Unit, onRetry: () -> Unit, onSkip: () -> Unit, isLoading: Boolean = false ) { + val context = LocalContext.current val colorScheme = MaterialTheme.colorScheme + + // Initialize preference manager + LaunchedEffect(Unit) { + BatteryOptimizationPreferenceManager.init(context) + } Box( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), + modifier = modifier.padding(24.dp), contentAlignment = Alignment.Center ) { when (status) { @@ -77,6 +82,8 @@ private fun BatteryOptimizationEnabledContent( colorScheme: ColorScheme, isLoading: Boolean ) { + val context = LocalContext.current + Column( modifier = Modifier.fillMaxSize() ) { @@ -86,78 +93,112 @@ private fun BatteryOptimizationEnabledContent( .weight(1f) .verticalScroll(rememberScrollState()) .padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = "bitchat", - style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = colorScheme.primary + // Header Section - matching AboutSheet style + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = colorScheme.onBackground ) - ) + + Text( + text = stringResource(R.string.battery_optimization_detected_title), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.7f) + ) + } - Spacer(modifier = Modifier.height(16.dp)) - - Icon( - imageVector = Icons.Outlined.BatteryAlert, - contentDescription = "Battery Optimization", - modifier = Modifier.size(64.dp), - tint = colorScheme.error - ) - - Text( - text = stringResource(R.string.battery_optimization_detected), - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurface - ), - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(R.string.battery_optimization_explanation), - style = MaterialTheme.typography.bodyLarge.copy( - color = colorScheme.onSurfaceVariant - ), - textAlign = TextAlign.Center - ) - - Card( + // Battery optimization info section + Surface( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = stringResource(R.string.battery_optimization_why_disable), - style = MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurface + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Power, + contentDescription = stringResource(R.string.cd_battery_optimization), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) ) - ) - - Text( - text = stringResource(R.string.battery_optimization_benefits), - style = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.onSurfaceVariant - ) - ) + Column { + Text( + text = stringResource(R.string.battery_optimization_enabled_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.battery_optimization_explanation_short), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } } } - Text( - text = stringResource(R.string.battery_optimization_note), - style = MaterialTheme.typography.bodySmall.copy( - color = colorScheme.onSurfaceVariant - ), - textAlign = TextAlign.Center - ) + // Benefits section + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = stringResource(R.string.cd_benefits), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.benefits_of_disabling), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.battery_benefits_short), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } + } + } } // Fixed buttons at the bottom @@ -168,7 +209,10 @@ private fun BatteryOptimizationEnabledContent( Button( onClick = onDisableBatteryOptimization, modifier = Modifier.fillMaxWidth(), - enabled = !isLoading + enabled = !isLoading, + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) ) { if (isLoading) { CircularProgressIndicator( @@ -178,7 +222,13 @@ private fun BatteryOptimizationEnabledContent( ) Spacer(modifier = Modifier.width(8.dp)) } - Text(stringResource(R.string.battery_optimization_disable_button)) + Text( + text = stringResource(R.string.disable_battery_optimization), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold + ) + ) } Row( @@ -190,15 +240,28 @@ private fun BatteryOptimizationEnabledContent( modifier = Modifier.weight(1f), enabled = !isLoading ) { - Text(stringResource(R.string.battery_optimization_check_again)) + Text( + text = stringResource(R.string.check_again), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ) + ) } TextButton( - onClick = onSkip, + onClick = { + BatteryOptimizationPreferenceManager.setSkipped(context, true) + onSkip() + }, modifier = Modifier.weight(1f), enabled = !isLoading ) { - Text(stringResource(R.string.battery_optimization_skip)) + Text( + text = stringResource(R.string.battery_optimization_skip), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ) + ) } } } @@ -210,17 +273,31 @@ private fun BatteryOptimizationCheckingContent( colorScheme: ColorScheme ) { Column( - verticalArrangement = Arrangement.spacedBy(32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "bitchat", - style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = colorScheme.primary + // Header Section - matching AboutSheet style + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = colorScheme.onBackground ) - ) + + Text( + text = stringResource(R.string.battery_optimization_disabled_title), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.7f) + ) + } val infiniteTransition = rememberInfiniteTransition(label = "rotation") val rotation by infiniteTransition.animateFloat( @@ -235,26 +312,18 @@ private fun BatteryOptimizationCheckingContent( Icon( imageVector = Icons.Filled.BatteryStd, - contentDescription = "Checking Battery Optimization", + contentDescription = stringResource(R.string.cd_checking_battery_optimization), modifier = Modifier .size(64.dp) .rotate(rotation), tint = colorScheme.primary ) - Text( - text = stringResource(R.string.battery_optimization_disabled), - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurface - ), - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(R.string.battery_optimization_success_message), - style = MaterialTheme.typography.bodyLarge.copy( - color = colorScheme.onSurfaceVariant + Text( + text = stringResource(R.string.battery_optimization_success_message), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center ) @@ -270,44 +339,59 @@ private fun BatteryOptimizationNotSupportedContent( verticalArrangement = Arrangement.spacedBy(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "bitchat", - style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = colorScheme.primary + // Header Section - matching AboutSheet style + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = colorScheme.onBackground ) - ) + + Text( + text = stringResource(R.string.battery_optimization_not_required), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.7f) + ) + } Icon( imageVector = Icons.Filled.CheckCircle, - contentDescription = "Battery Optimization Not Supported", + contentDescription = stringResource(R.string.cd_not_supported_battery_optimization), modifier = Modifier.size(64.dp), tint = colorScheme.primary ) - Text( - text = stringResource(R.string.battery_optimization_not_required), - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurface - ), - textAlign = TextAlign.Center - ) - Text( text = stringResource(R.string.battery_optimization_not_supported_message), - style = MaterialTheme.typography.bodyLarge.copy( - color = colorScheme.onSurfaceVariant + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center ) Button( onClick = onRetry, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) ) { - Text(stringResource(R.string.battery_optimization_continue)) + Text( + text = stringResource(R.string.continue_btn), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold + ) + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index 390df992..3eb6eea9 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -1,37 +1,38 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* import androidx.compose.animation.core.* import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R /** * Screen shown when checking Bluetooth status or requesting Bluetooth enable */ @Composable fun BluetoothCheckScreen( + modifier: Modifier, status: BluetoothStatus, onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, isLoading: Boolean = false ) { val colorScheme = MaterialTheme.colorScheme Box( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), + modifier = modifier.padding(32.dp), contentAlignment = Alignment.Center ) { when (status) { @@ -39,13 +40,15 @@ fun BluetoothCheckScreen( BluetoothDisabledContent( onEnableBluetooth = onEnableBluetooth, onRetry = onRetry, + onSkip = onSkip, colorScheme = colorScheme, isLoading = isLoading ) } BluetoothStatus.NOT_SUPPORTED -> { BluetoothNotSupportedContent( - colorScheme = colorScheme + colorScheme = colorScheme, + onSkip = onSkip ) } BluetoothStatus.ENABLED -> { @@ -61,6 +64,7 @@ fun BluetoothCheckScreen( private fun BluetoothDisabledContent( onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, colorScheme: ColorScheme, isLoading: Boolean ) { @@ -71,15 +75,15 @@ private fun BluetoothDisabledContent( // Bluetooth icon - using Bluetooth outlined icon in app's green color Icon( imageVector = Icons.Outlined.Bluetooth, - contentDescription = "Bluetooth", + contentDescription = stringResource(R.string.cd_bluetooth), modifier = Modifier.size(64.dp), tint = Color(0xFF00C851) // App's main green color ) Text( - text = "Bluetooth Required", + text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -97,8 +101,8 @@ private fun BluetoothDisabledContent( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = "bitchat needs Bluetooth to:", + Text( + text = stringResource(R.string.bluetooth_needs_for), style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium, color = colorScheme.onSurface @@ -107,13 +111,10 @@ private fun BluetoothDisabledContent( modifier = Modifier.fillMaxWidth() ) - Text( - text = "• Discover nearby users\n" + - "• Create mesh network connections\n" + - "• Send and receive messages\n" + - "• Work without internet or servers", + Text( + text = stringResource(R.string.bluetooth_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -134,30 +135,27 @@ private fun BluetoothDisabledContent( containerColor = Color(0xFF00C851) // App's main green color ) ) { - Text( - text = "Enable Bluetooth", + Text( + text = stringResource(R.string.enable_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) ) } - //Since we are automatically checking bluetooth state -- commented - -// OutlinedButton( -// onClick = onRetry, -// modifier = Modifier.fillMaxWidth() -// ) { -// Text( -// text = "Check Again", -// style = MaterialTheme.typography.bodyMedium.copy( -// fontFamily = FontFamily.Monospace -// ), -// modifier = Modifier.padding(vertical = 4.dp) -// ) -// } + TextButton( + onClick = onSkip, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.skip), + style = MaterialTheme.typography.labelLarge.copy( + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + ) + } } } } @@ -165,7 +163,8 @@ private fun BluetoothDisabledContent( @Composable private fun BluetoothNotSupportedContent( - colorScheme: ColorScheme + colorScheme: ColorScheme, + onSkip: () -> Unit ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), @@ -179,16 +178,16 @@ private fun BluetoothNotSupportedContent( elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Text( - text = "❌", + text = stringResource(R.string.warning_emoji), style = MaterialTheme.typography.headlineLarge, modifier = Modifier.padding(16.dp) ) } Text( - text = "Bluetooth Not Supported", + text = stringResource(R.string.bluetooth_not_supported), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -203,15 +202,25 @@ private fun BluetoothNotSupportedContent( elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Text( - text = "This device doesn't support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet.", + text = stringResource(R.string.bluetooth_unsupported_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), textAlign = TextAlign.Center ) } + + Button( + onClick = onSkip, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary + ) + ) { + Text(text = stringResource(R.string.continue_btn)) + } } } @@ -224,9 +233,9 @@ private fun BluetoothCheckingContent( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "bitchat", + text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -236,9 +245,9 @@ private fun BluetoothCheckingContent( BluetoothLoadingIndicator() Text( - text = "Checking Bluetooth status...", + text = stringResource(R.string.checking_bluetooth_status), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt index f6bc2174..f7895c65 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt @@ -8,16 +8,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R /** * Loading screen shown during app initialization after permissions are granted */ @Composable -fun InitializingScreen() { +fun InitializingScreen(modifier: Modifier) { val colorScheme = MaterialTheme.colorScheme // Animated rotation for the loading indicator @@ -50,9 +52,7 @@ fun InitializingScreen() { } Box( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), + modifier = modifier.padding(32.dp), contentAlignment = Alignment.Center ) { Column( @@ -61,9 +61,9 @@ fun InitializingScreen() { ) { // App title Text( - text = "bitchat", + text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -90,9 +90,9 @@ fun InitializingScreen() { horizontalArrangement = Arrangement.Center ) { Text( - text = "Initializing mesh network", + text = stringResource(R.string.initializing_mesh_network), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) @@ -100,9 +100,9 @@ fun InitializingScreen() { // Animated dots dots.forEach { alpha -> Text( - text = ".", + text = stringResource(R.string.dot), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = alpha) ) ) @@ -125,18 +125,18 @@ fun InitializingScreen() { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Setting up Bluetooth mesh networking...", + text = stringResource(R.string.setting_up_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ), textAlign = TextAlign.Center ) Text( - text = "This should only take a few seconds", + text = stringResource(R.string.should_take_seconds), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.6f) ), textAlign = TextAlign.Center @@ -152,6 +152,7 @@ fun InitializingScreen() { */ @Composable fun InitializationErrorScreen( + modifier: Modifier, errorMessage: String, onRetry: () -> Unit, onOpenSettings: () -> Unit @@ -159,9 +160,7 @@ fun InitializationErrorScreen( val colorScheme = MaterialTheme.colorScheme Box( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), + modifier = modifier.padding(32.dp), contentAlignment = Alignment.Center ) { Column( @@ -176,16 +175,16 @@ fun InitializationErrorScreen( elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Text( - text = "⚠️", + text = stringResource(R.string.warning_emoji), style = MaterialTheme.typography.headlineLarge, modifier = Modifier.padding(16.dp) ) } Text( - text = "Setup Not Complete", + text = stringResource(R.string.setup_not_complete), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -202,7 +201,7 @@ fun InitializationErrorScreen( Text( text = errorMessage, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -219,9 +218,9 @@ fun InitializationErrorScreen( modifier = Modifier.fillMaxWidth() ) { Text( - text = "Try Again", + text = stringResource(R.string.try_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -233,9 +232,9 @@ fun InitializationErrorScreen( modifier = Modifier.fillMaxWidth() ) { Text( - text = "Open Settings", + text = stringResource(R.string.open_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index 5a8f06fd..3654aba8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -1,26 +1,29 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R /** * Screen shown when checking location services status or requesting location services enable */ @Composable fun LocationCheckScreen( + modifier: Modifier, status: LocationStatus, onEnableLocation: () -> Unit, onRetry: () -> Unit, @@ -29,9 +32,7 @@ fun LocationCheckScreen( val colorScheme = MaterialTheme.colorScheme Box( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), + modifier = modifier.padding(32.dp), contentAlignment = Alignment.Center ) { when (status) { @@ -71,15 +72,15 @@ private fun LocationDisabledContent( // Location icon - using LocationOn outlined icon in app's green color Icon( imageVector = Icons.Outlined.LocationOn, - contentDescription = "Location Services", + contentDescription = stringResource(R.string.cd_location_services), modifier = Modifier.size(64.dp), tint = Color(0xFF00C851) // App's main green color ) Text( - text = "Location Services Required", + text = stringResource(R.string.location_services_required), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -104,13 +105,13 @@ private fun LocationDisabledContent( ) { Icon( imageVector = Icons.Filled.Security, - contentDescription = "Privacy", + contentDescription = stringResource(R.string.cd_privacy), tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Privacy First", + Text( + text = stringResource(R.string.privacy_first), style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Bold, color = colorScheme.onSurface @@ -118,18 +119,18 @@ private fun LocationDisabledContent( ) } - Text( - text = "bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature.", + Text( + text = stringResource(R.string.location_explanation), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "bitchat needs location services for:", + Text( + text = stringResource(R.string.location_needs_for), style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium, color = colorScheme.onSurface @@ -138,13 +139,10 @@ private fun LocationDisabledContent( modifier = Modifier.fillMaxWidth() ) - Text( - text = "• Bluetooth device scanning\n" + - "• Discovering nearby users on mesh network\n" + - "• Geohash chat feature\n" + - "• No tracking or location collection", + Text( + text = stringResource(R.string.location_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -165,10 +163,10 @@ private fun LocationDisabledContent( containerColor = Color(0xFF00C851) // App's main green color ) ) { - Text( - text = "Open Location Settings", + Text( + text = stringResource(R.string.open_location_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -179,10 +177,10 @@ private fun LocationDisabledContent( onClick = onRetry, modifier = Modifier.fillMaxWidth() ) { - Text( - text = "Check Again", + Text( + text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) @@ -203,15 +201,15 @@ private fun LocationNotAvailableContent( // Error icon Icon( imageVector = Icons.Filled.ErrorOutline, - contentDescription = "Error", + contentDescription = stringResource(R.string.cd_error), modifier = Modifier.size(64.dp), tint = colorScheme.error ) Text( - text = "Location Services Unavailable", + text = stringResource(R.string.location_services_unavailable), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -226,9 +224,9 @@ private fun LocationNotAvailableContent( elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Text( - text = "Location services are not available on this device. This is unusual as location services are standard on Android devices.\n\nbitchat needs location services for Bluetooth scanning to work properly (Android requirement). Without this, the app cannot discover nearby users.", + text = stringResource(R.string.location_unavailable_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -247,9 +245,9 @@ private fun LocationCheckingContent( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "bitchat", + text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -259,9 +257,9 @@ private fun LocationCheckingContent( LocationLoadingIndicator() Text( - text = "Checking location services...", + text = stringResource(R.string.checking_location_services), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt index c1952c7c..641b3a5a 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -19,6 +19,7 @@ class OnboardingCoordinator( private val activity: ComponentActivity, private val permissionManager: PermissionManager, private val onOnboardingComplete: () -> Unit, + private val onBackgroundLocationRequired: () -> Unit, private val onOnboardingFailed: (String) -> Unit ) { @@ -27,9 +28,11 @@ class OnboardingCoordinator( } private var permissionLauncher: ActivityResultLauncher>? = null + private var backgroundLocationLauncher: ActivityResultLauncher? = null init { setupPermissionLauncher() + setupBackgroundLocationLauncher() } /** @@ -43,6 +46,14 @@ class OnboardingCoordinator( } } + private fun setupBackgroundLocationLauncher() { + backgroundLocationLauncher = activity.registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + handleBackgroundLocationResult(granted) + } + } + /** * Start the onboarding process */ @@ -50,9 +61,14 @@ class OnboardingCoordinator( Log.d(TAG, "Starting onboarding process") permissionManager.logPermissionStatus() - if (permissionManager.areAllPermissionsGranted()) { - Log.d(TAG, "All permissions already granted, completing onboarding") - completeOnboarding() + if (permissionManager.areRequiredPermissionsGranted()) { + if (shouldRequestBackgroundLocation()) { + Log.d(TAG, "Foreground permissions granted; background location recommended") + onBackgroundLocationRequired() + } else { + Log.d(TAG, "Required permissions already granted, completing onboarding") + completeOnboarding() + } } else { Log.d(TAG, "Missing permissions, need to start explanation flow") // The explanation screen will be shown by the calling activity @@ -65,13 +81,25 @@ class OnboardingCoordinator( fun requestPermissions() { Log.d(TAG, "User accepted permission explanation, requesting permissions") - val missingPermissions = permissionManager.getMissingPermissions() + // Required permissions + val missingRequired = permissionManager.getMissingPermissions() + + // Optional permissions (ask, but do not block if denied) + val optionalToRequest = permissionManager.getUnrequestedOptionalPermissions() + + val missingPermissions = (missingRequired + optionalToRequest).distinct() + if (missingPermissions.isEmpty()) { - completeOnboarding() + if (shouldRequestBackgroundLocation()) { + onBackgroundLocationRequired() + } else { + completeOnboarding() + } return } Log.d(TAG, "Requesting ${missingPermissions.size} permissions") + permissionManager.markOptionalPermissionsRequested(optionalToRequest) permissionLauncher?.launch(missingPermissions.toTypedArray()) } @@ -86,16 +114,25 @@ class OnboardingCoordinator( val allGranted = permissions.values.all { it } val criticalPermissions = getCriticalPermissions() - val criticalGranted = criticalPermissions.all { permissions[it] == true } + // The launcher result only contains permissions requested in this round. Returning + // users may be asked for POST_NOTIFICATIONS alone, so re-check required permissions + // against package state instead of treating absent result-map entries as denials. + val criticalGranted = criticalPermissions.all(permissionManager::isPermissionGranted) when { - allGranted -> { - Log.d(TAG, "All permissions granted successfully") - completeOnboarding() - } criticalGranted -> { - Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") - showPartialPermissionWarning(permissions) + if (shouldRequestBackgroundLocation()) { + Log.d(TAG, "Foreground permissions granted; requesting background location next") + onBackgroundLocationRequired() + return + } + if (allGranted) { + Log.d(TAG, "All permissions granted successfully") + completeOnboarding() + } else { + Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") + showPartialPermissionWarning(permissions) + } } else -> { Log.d(TAG, "Critical permissions denied") @@ -104,15 +141,50 @@ class OnboardingCoordinator( } } + fun requestBackgroundLocation() { + val permission = permissionManager.getBackgroundLocationPermission() + if (permission == null) { + completeOnboarding() + return + } + Log.d(TAG, "Requesting background location permission") + backgroundLocationLauncher?.launch(permission) + } + + private fun handleBackgroundLocationResult(granted: Boolean) { + if (granted) { + Log.d(TAG, "Background location permission granted") + } else { + Log.w(TAG, "Background location permission denied; continuing without it") + } + completeOnboarding() + } + + fun skipBackgroundLocation() { + Log.d(TAG, "User skipped background location permission") + BackgroundLocationPreferenceManager.setSkipped(activity, true) + completeOnboarding() + } + + fun checkBackgroundLocationAndProceed() { + if (!shouldRequestBackgroundLocation()) { + completeOnboarding() + } + } + + private fun shouldRequestBackgroundLocation(): Boolean { + return permissionManager.needsBackgroundLocationPermission() && + !permissionManager.isBackgroundLocationGranted() && + !BackgroundLocationPreferenceManager.isSkipped(activity) + } + /** * Get the list of critical permissions that are absolutely required */ private fun getCriticalPermissions(): List { // For bitchat, Bluetooth and location permissions are critical - // Notifications are nice-to-have but not critical - return permissionManager.getRequiredPermissions().filter { permission -> - !permission.contains("POST_NOTIFICATIONS") - } + // Notifications are nice-to-have but not critical and are not included in getRequiredPermissions() + return permissionManager.getRequiredPermissions() } /** @@ -199,7 +271,9 @@ class OnboardingCoordinator( private fun getPermissionDisplayName(permission: String): String { return when { permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" + permission.contains("BACKGROUND") -> "Background Location" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" + permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)" permission.contains("NOTIFICATION") -> "Notifications" else -> permission.substringAfterLast(".") } diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt index f06ddd08..7fa07817 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt @@ -6,8 +6,9 @@ enum class OnboardingState { LOCATION_CHECK, BATTERY_OPTIMIZATION_CHECK, PERMISSION_EXPLANATION, + BACKGROUND_LOCATION_EXPLANATION, PERMISSION_REQUESTING, INITIALIZING, COMPLETE, ERROR -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 2db177d8..afd91da3 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -1,18 +1,30 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Power +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.Settings import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.graphics.vector.ImageVector 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.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R /** * Permission explanation screen shown before requesting permissions @@ -20,6 +32,7 @@ import androidx.compose.ui.unit.sp */ @Composable fun PermissionExplanationScreen( + modifier: Modifier, permissionCategories: List, onContinue: () -> Unit ) { @@ -27,7 +40,7 @@ fun PermissionExplanationScreen( val scrollState = rememberScrollState() Box( - modifier = Modifier.fillMaxSize() + modifier = modifier ) { // Scrollable content Column( @@ -39,86 +52,85 @@ fun PermissionExplanationScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Spacer(modifier = Modifier.height(24.dp)) - // Header + + // Header Section - matching AboutSheet style Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = colorScheme.onBackground + ) + } + Text( - text = "Welcome to bitchat", - style = MaterialTheme.typography.headlineMedium.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = colorScheme.primary - ), - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Decentralized mesh messaging over Bluetooth", - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ), - textAlign = TextAlign.Center + text = stringResource(R.string.about_tagline), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.7f) ) } - Spacer(modifier = Modifier.height(16.dp)) - - // Privacy assurance section - Card( + // Privacy assurance section - matching AboutSheet card style + Surface( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = "🔒", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.size(20.dp) + Icon( + imageVector = Icons.Filled.Security, + contentDescription = stringResource(R.string.cd_privacy_protected), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) ) - Text( - text = "Your Privacy is Protected", - style = MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.Bold, - color = colorScheme.onSurface + Column { + Text( + text = stringResource(R.string.privacy_protected), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground ) - ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.privacy_bullets), + style = MaterialTheme.typography.bodySmall, + fontFamily = BitchatFontFamily, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } } - - Text( - text = "• bitchat doesn't track you or collect personal data\n" + - "• Bluetooth mesh chats are fully offline and require no internet\n" + - "• Geohash chats use the internet but your location is generalized\n" + - "• Your messages stay on your device and peer devices only", - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.8f) - ) - ) } } - Spacer(modifier = Modifier.height(8.dp)) - + // Section header Text( - text = "To work properly, bitchat needs these permissions:", - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) + text = stringResource(R.string.permissions_header), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onBackground.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp) ) // Permission categories @@ -150,9 +162,9 @@ fun PermissionExplanationScreen( ) ) { Text( - text = "Grant Permissions", + text = stringResource(R.string.grant_permissions), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -167,87 +179,48 @@ private fun PermissionCategoryCard( category: PermissionCategory, colorScheme: ColorScheme ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = getPermissionEmoji(category.type), - style = MaterialTheme.typography.titleLarge, - color = getPermissionIconColor(category.type), - modifier = Modifier.size(24.dp) - ) - - Text( - text = category.type.nameValue, - style = MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.Bold, - color = colorScheme.onSurface - ) - ) - } - + Icon( + imageVector = getPermissionIcon(category.type), + contentDescription = category.type.nameValue, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = category.type.nameValue, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) Text( text = category.description, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.8f), - lineHeight = 18.sp - ) + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) ) - if (category.type == PermissionType.PRECISE_LOCATION) { - // Extra emphasis for location permission - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = "⚠️", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.size(16.dp) - ) - Text( - text = "bitchat does NOT track your location", - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF9800) - ) - ) - } - } } } } -private fun getPermissionEmoji(permissionType: PermissionType): String { +private fun getPermissionIcon(permissionType: PermissionType): ImageVector { return when (permissionType) { - PermissionType.NEARBY_DEVICES -> "📱" - PermissionType.PRECISE_LOCATION -> "📍" - PermissionType.NOTIFICATIONS -> "🔔" - PermissionType.BATTERY_OPTIMIZATION -> "🔋" - PermissionType.OTHER -> "🔧" - } -} - -private fun getPermissionIconColor(permissionType: PermissionType): Color { - return when (permissionType) { - PermissionType.NEARBY_DEVICES -> Color(0xFF2196F3) // Blue - PermissionType.PRECISE_LOCATION -> Color(0xFFFF9800) // Orange - PermissionType.NOTIFICATIONS -> Color(0xFF4CAF50) // Green - PermissionType.BATTERY_OPTIMIZATION -> Color(0xFFF44336) // Red - PermissionType.OTHER -> Color(0xFF9C27B0) // Purple + PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth + PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn + PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn + PermissionType.MICROPHONE -> Icons.Filled.Mic + PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications + PermissionType.WIFI_AWARE -> Icons.Filled.Wifi + PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power + PermissionType.OTHER -> Icons.Filled.Settings } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index cfec35e7..8d61d10e 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -7,6 +7,7 @@ import android.os.Build import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat +import com.bitchat.android.R /** * Centralized permission management for bitchat app @@ -18,10 +19,48 @@ class PermissionManager(private val context: Context) { private const val TAG = "PermissionManager" private const val PREFS_NAME = "bitchat_permissions" private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete" + private const val KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX = + "optional_permission_requested_" } private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private fun shouldRequireWifiAwarePermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false + val enabled = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false) + } catch (_: Exception) { + false + } + if (!enabled) return false + + return try { + com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context) + } catch (_: Exception) { + false + } + } + + /** + * Runtime permissions for the Wi‑Fi Aware transport, version-gated because neither exists + * at minSdk 26 — requesting an unknown permission comes back permanently denied. + * + * ACCESS_LOCAL_NETWORK is defensive: Android 17 gates local network access, and the + * transport reaches peers over link-local IPv6 sockets. It is granted separately from + * NEARBY_WIFI_DEVICES but shares its permission group, so the two prompt only once. + */ + fun wifiAwarePermissions(): List { + val permissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + // API 37 == Android 17; no named VERSION_CODES constant is available yet. + if (Build.VERSION.SDK_INT >= 37) { + permissions.add(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + return permissions + } + /** * Check if this is the first time the user is launching the app */ @@ -40,7 +79,10 @@ class PermissionManager(private val context: Context) { } /** - * Get all permissions required by the app + * Get required permissions that can be requested together. + * Background location is handled separately to ensure correct request order. + * Note: Notification permission is optional and not included here, + * so the app works without notification access. */ fun getRequiredPermissions(): List { val permissions = mutableListOf() @@ -65,14 +107,76 @@ class PermissionManager(private val context: Context) { Manifest.permission.ACCESS_FINE_LOCATION )) - // Notification permission (Android 13+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions.add(Manifest.permission.POST_NOTIFICATIONS) + // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission + if (shouldRequireWifiAwarePermission()) { + permissions.addAll(wifiAwarePermissions()) } + // Notification permission intentionally excluded to keep it optional + return permissions } + /** + * Background location permission is required on Android 10+ for background BLE scanning. + * Must be requested after foreground location permissions are granted. + */ + fun needsBackgroundLocationPermission(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } + + fun getBackgroundLocationPermission(): String? { + return if (needsBackgroundLocationPermission()) { + Manifest.permission.ACCESS_BACKGROUND_LOCATION + } else { + null + } + } + + fun isBackgroundLocationGranted(): Boolean { + val permission = getBackgroundLocationPermission() ?: return true + return isPermissionGranted(permission) + } + + /** + * Get optional permissions that improve the experience but aren't required. + * Currently includes POST_NOTIFICATIONS on Android 13+. + */ + fun getOptionalPermissions(): List { + val optional = mutableListOf() + // Notifications on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + optional.add(Manifest.permission.POST_NOTIFICATIONS) + } + return optional + } + + /** + * Optional permissions are prompted once. A denial must not trap returning users in + * onboarding, while users upgrading to a notification-permission Android version + * should still receive one contextual request. + */ + fun getUnrequestedOptionalPermissions(): List { + return getOptionalPermissions().filter { permission -> + !isPermissionGranted(permission) && + !sharedPrefs.getBoolean(optionalPermissionRequestKey(permission), false) + } + } + + fun markOptionalPermissionsRequested(permissions: Collection) { + if (permissions.isEmpty()) return + + sharedPrefs.edit().apply { + permissions.forEach { permission -> + putBoolean(optionalPermissionRequestKey(permission), true) + } + }.apply() + } + + private fun optionalPermissionRequestKey(permission: String): String { + return KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX + permission + } + /** * Check if a specific permission is granted */ @@ -81,9 +185,13 @@ class PermissionManager(private val context: Context) { } /** - * Check if all required permissions are granted + * Check if all required permissions are granted (background location is optional). */ fun areAllPermissionsGranted(): Boolean { + return areRequiredPermissionsGranted() + } + + fun areRequiredPermissionsGranted(): Boolean { return getRequiredPermissions().all { isPermissionGranted(it) } } @@ -119,6 +227,11 @@ class PermissionManager(private val context: Context) { return getRequiredPermissions().filter { !isPermissionGranted(it) } } + fun getMissingBackgroundLocationPermission(): List { + val permission = getBackgroundLocationPermission() ?: return emptyList() + return if (isPermissionGranted(permission)) emptyList() else listOf(permission) + } + /** * Get categorized permission information for display */ @@ -165,6 +278,33 @@ class PermissionManager(private val context: Context) { ) ) + // Wi‑Fi Aware category (Android 13+) + if (shouldRequireWifiAwarePermission()) { + val wifiAwarePermissions = wifiAwarePermissions() + categories.add( + PermissionCategory( + type = PermissionType.WIFI_AWARE, + description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.", + permissions = wifiAwarePermissions, + isGranted = wifiAwarePermissions.all { isPermissionGranted(it) }, + systemDescription = "Allow bitchat to discover nearby Wi‑Fi devices" + ) + ) + } + + if (needsBackgroundLocationPermission()) { + val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + categories.add( + PermissionCategory( + type = PermissionType.BACKGROUND_LOCATION, + description = context.getString(R.string.perm_background_location_desc), + permissions = backgroundPermission, + isGranted = backgroundPermission.all { isPermissionGranted(it) }, + systemDescription = context.getString(R.string.perm_background_location_system) + ) + ) + } + // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -178,6 +318,8 @@ class PermissionManager(private val context: Context) { ) } + // Microphone category removed from onboarding + // Battery optimization category (if applicable) if (isBatteryOptimizationSupported()) { categories.add( @@ -202,7 +344,7 @@ class PermissionManager(private val context: Context) { appendLine("Permission Diagnostics:") appendLine("Android SDK: ${Build.VERSION.SDK_INT}") appendLine("First time launch: ${isFirstTimeLaunch()}") - appendLine("All permissions granted: ${areAllPermissionsGranted()}") + appendLine("Required permissions granted: ${areAllPermissionsGranted()}") appendLine() getCategorizedPermissions().forEach { category -> @@ -214,7 +356,7 @@ class PermissionManager(private val context: Context) { appendLine() } - val missing = getMissingPermissions() + val missing = getMissingPermissions() + getMissingBackgroundLocationPermission() if (missing.isNotEmpty()) { appendLine("Missing permissions:") missing.forEach { permission -> @@ -246,7 +388,10 @@ data class PermissionCategory( enum class PermissionType(val nameValue: String) { NEARBY_DEVICES("Nearby Devices"), PRECISE_LOCATION("Precise Location"), + BACKGROUND_LOCATION("Background Location"), + MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), + WIFI_AWARE("Wi‑Fi Aware"), BATTERY_OPTIMIZATION("Battery Optimization"), OTHER("Other") } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 3dcc47a2..6a0445b0 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -1,6 +1,7 @@ package com.bitchat.android.protocol import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import java.nio.ByteBuffer import java.nio.ByteOrder @@ -15,7 +16,10 @@ enum class MessageType(val value: UByte) { LEAVE(0x03u), NOISE_HANDSHAKE(0x10u), // Noise handshake NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message - FRAGMENT(0x20u); // Fragmentation for large packets + FRAGMENT(0x20u), // Fragmentation for large packets + REQUEST_SYNC(0x21u), // GCS-based sync request + FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.) + VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync companion object { fun fromValue(value: UByte): MessageType? { @@ -32,15 +36,36 @@ object SpecialRecipients { } /** - * Binary packet format - 100% compatible with iOS version - * - * Header (Fixed 13 bytes): + * Payload as it arrived on the wire, set by [BinaryProtocol.decode]. + * + * Signatures cover a re-encoding of the packet, and verification re-encodes too. + * DEFLATE output is not canonical and clients use different encoders + * (java.util.zip.Deflater here, Apple's compression_encode_buffer on iOS), so + * re-compressing can change the preimage and reject a valid packet. Reusing these + * bytes also stops a relay, which re-encodes on TTL decrement, from substituting + * its own encoding. + * + * [forPayload] ties the bytes to the payload they decode to: replace the payload + * and the encoder compresses instead. + * + * Not a data class: generated equals/hashCode over ByteArray compares references. + */ +class WirePayload( + val bytes: ByteArray, + val compressed: Boolean, + val forPayload: ByteArray +) + +/** + * Binary packet format - 100% backward compatible with iOS version + * + * Header (14 bytes for v1, 16 bytes for v2): * - Version: 1 byte - * - Type: 1 byte + * - Type: 1 byte * - TTL: 1 byte * - Timestamp: 8 bytes (UInt64, big-endian) * - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed) - * - PayloadLength: 2 bytes (UInt16, big-endian) + * - PayloadLength: 2 bytes (v1) / 4 bytes (v2) (big-endian) * * Variable sections: * - SenderID: 8 bytes (fixed) @@ -57,7 +82,11 @@ data class BitchatPacket( val timestamp: ULong, val payload: ByteArray, var signature: ByteArray? = null, // Changed from val to var for packet signing - var ttl: UByte + var ttl: UByte, + var route: List? = null, // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient + // Set by BinaryProtocol.decode. Not part of packet identity, so it stays out of + // the parcel, equals and hashCode. Losing it only costs a re-compression. + @IgnoredOnParcel val wirePayload: WirePayload? = null ) : Parcelable { constructor( @@ -76,8 +105,8 @@ data class BitchatPacket( ttl = ttl ) - fun toBinaryData(): ByteArray? { - return BinaryProtocol.encode(this) + fun toBinaryData(padding: Boolean = true): ByteArray? { + return BinaryProtocol.encode(this, padding = padding) } /** @@ -95,7 +124,9 @@ data class BitchatPacket( timestamp = timestamp, payload = payload, signature = null, // Remove signature for signing - ttl = 0u // Use fixed TTL=0 for signing to ensure relay compatibility + route = route, + ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS, // Use fixed TTL=0 for signing to ensure relay compatibility + wirePayload = wirePayload // preimage must use the originator's bytes ) return BinaryProtocol.encode(unsignedPacket) } @@ -147,6 +178,11 @@ data class BitchatPacket( if (!signature.contentEquals(other.signature)) return false } else if (other.signature != null) return false if (ttl != other.ttl) return false + if (route != null || other.route != null) { + val a = route?.map { it.toList() } ?: emptyList() + val b = other.route?.map { it.toList() } ?: emptyList() + if (a != b) return false + } return true } @@ -160,41 +196,79 @@ data class BitchatPacket( result = 31 * result + payload.contentHashCode() result = 31 * result + (signature?.contentHashCode() ?: 0) result = 31 * result + ttl.hashCode() + result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0) return result } } /** - * Binary Protocol implementation - exact same format as iOS version + * Binary Protocol implementation - supports v1 and v2, backward compatible */ object BinaryProtocol { - private const val HEADER_SIZE = 13 + private const val HEADER_SIZE_V1 = 14 + private const val HEADER_SIZE_V2 = 16 private const val SENDER_ID_SIZE = 8 private const val RECIPIENT_ID_SIZE = 8 private const val SIGNATURE_SIZE = 64 - object Flags { const val HAS_RECIPIENT: UByte = 0x01u const val HAS_SIGNATURE: UByte = 0x02u const val IS_COMPRESSED: UByte = 0x04u + const val HAS_ROUTE: UByte = 0x08u + } + + private fun getHeaderSize(version: UByte): Int { + return when (version) { + 1u.toUByte() -> HEADER_SIZE_V1 + else -> HEADER_SIZE_V2 // v2+ will use 4-byte payload length + } } - fun encode(packet: BitchatPacket): ByteArray? { + fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? { try { + val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + if (packet.payload.size > maxPayloadLength) { + Log.w( + "BinaryProtocol", + "Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength" + ) + return null + } + // Try to compress payload if beneficial var payload = packet.payload - var originalPayloadSize: UShort? = null + var originalPayloadSize: Int? = null var isCompressed = false - if (CompressionUtil.shouldCompress(payload)) { + // Re-encode of a decoded packet: reuse the originator's bytes (see WirePayload). + val wire = packet.wirePayload + if (wire != null && wire.forPayload.contentEquals(packet.payload)) { + if (wire.compressed) { + payload = wire.bytes + originalPayloadSize = packet.payload.size + isCompressed = true + } + // Uncompressed on the wire: keep it that way. shouldCompress agrees + // across clients, but the "only if smaller" check need not. + } else if (CompressionUtil.shouldCompress(payload)) { CompressionUtil.compress(payload)?.let { compressedPayload -> - originalPayloadSize = payload.size.toUShort() + originalPayloadSize = payload.size payload = compressedPayload isCompressed = true } } - val buffer = ByteBuffer.allocate(4096).apply { order(ByteOrder.BIG_ENDIAN) } + // Compute a safe capacity for the unpadded frame + val headerSize = getHeaderSize(packet.version) + val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0 + val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0 + val sizeFieldBytes = if (isCompressed) (if (packet.version >= 2u.toUByte()) 4 else 2) else 0 + val payloadBytes = payload.size + sizeFieldBytes + val routeBytes = if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) { + 1 + (packet.route!!.size.coerceAtMost(255) * SENDER_ID_SIZE) + } else 0 + val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + routeBytes + 16 // small slack + val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) } // Header buffer.put(packet.version.toByte()) @@ -215,11 +289,23 @@ object BinaryProtocol { if (isCompressed) { flags = flags or Flags.IS_COMPRESSED } + // HAS_ROUTE is only supported for v2+ packets + if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) { + flags = flags or Flags.HAS_ROUTE + } buffer.put(flags.toByte()) - // Payload length (2 bytes, big-endian) - includes original size if compressed - val payloadDataSize = payload.size + if (isCompressed) 2 else 0 - buffer.putShort(payloadDataSize.toShort()) + // Payload length (2 or 4 bytes, big-endian) - includes original size if compressed + val payloadDataSize = payload.size + sizeFieldBytes + if (packet.version >= 2u.toUByte()) { + buffer.putInt(payloadDataSize) // 4 bytes for v2+ + } else { + if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) { + Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes") + return null + } + buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1 + } // SenderID (exactly 8 bytes) val senderBytes = packet.senderID.take(SENDER_ID_SIZE).toByteArray() @@ -236,12 +322,26 @@ object BinaryProtocol { buffer.put(ByteArray(RECIPIENT_ID_SIZE - recipientBytes.size)) } } + + // Route (optional, v2+ only): 1 byte count + N*8 bytes + if (packet.version >= 2u.toUByte() && !packet.route.isNullOrEmpty()) { + packet.route?.let { routeList -> + val cleaned = routeList.map { bytes -> bytes.take(SENDER_ID_SIZE).toByteArray().let { if (it.size < SENDER_ID_SIZE) it + ByteArray(SENDER_ID_SIZE - it.size) else it } } + val count = cleaned.size.coerceAtMost(255) + buffer.put(count.toByte()) + cleaned.take(count).forEach { hop -> buffer.put(hop) } + } + } // Payload (with original size prepended if compressed) if (isCompressed) { val originalSize = originalPayloadSize if (originalSize != null) { - buffer.putShort(originalSize.toShort()) + if (packet.version >= 2u.toUByte()) { + buffer.putInt(originalSize.toInt()) + } else { + buffer.putShort(originalSize.toShort()) + } } } buffer.put(payload) @@ -255,11 +355,13 @@ object BinaryProtocol { buffer.rewind() buffer.get(result) - // Apply padding to standard block sizes for traffic analysis resistance - val optimalSize = MessagePadding.optimalBlockSize(result.size) - val paddedData = MessagePadding.pad(result, optimalSize) + // Apply padding if requested (iOS-compatible: selective padding for privacy) + if (padding) { + val optimalSize = MessagePadding.optimalBlockSize(result.size) + return MessagePadding.pad(result, optimalSize) + } - return paddedData + return result } catch (e: Exception) { Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}") @@ -267,50 +369,93 @@ object BinaryProtocol { } } - fun decode(data: ByteArray): BitchatPacket? { + fun decode(data: ByteArray): BitchatPacket? = + decode(data, CompressionUtil::decompressWithResourcesReserved) + + /** Test seam used to prove rejected expansion sizes never reach inflation. */ + internal fun decodeForTesting( + data: ByteArray, + decompress: (ByteArray, Int) -> ByteArray? + ): BitchatPacket? = decode(data, decompress) + + private fun decode( + data: ByteArray, + decompress: (ByteArray, Int) -> ByteArray? + ): BitchatPacket? { // Try decode as-is first (robust when padding wasn't applied) - iOS fix - decodeCore(data)?.let { return it } + decodeCore(data, decompress)?.let { return it } // If that fails, try after removing padding val unpadded = MessagePadding.unpad(data) if (unpadded.contentEquals(data)) return null // No padding was removed, already failed - return decodeCore(unpadded) + return decodeCore(unpadded, decompress) } /** * Core decoding implementation used by decode() with and without padding removal - iOS fix */ - private fun decodeCore(raw: ByteArray): BitchatPacket? { + private fun decodeCore( + raw: ByteArray, + decompress: (ByteArray, Int) -> ByteArray? + ): BitchatPacket? { try { - if (raw.size < HEADER_SIZE + SENDER_ID_SIZE) return null - + if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null + val buffer = ByteBuffer.wrap(raw).apply { order(ByteOrder.BIG_ENDIAN) } - + // Header val version = buffer.get().toUByte() - if (version != 1u.toUByte()) return null - + if (version.toUInt() != 1u && version.toUInt() != 2u) return null // Support v1 and v2 + + val headerSize = getHeaderSize(version) + val type = buffer.get().toUByte() val ttl = buffer.get().toUByte() - + // Timestamp val timestamp = buffer.getLong().toULong() - + // Flags val flags = buffer.get().toUByte() val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte() val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte() val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte() - - // Payload length - val payloadLength = buffer.getShort().toUShort() - - // Calculate expected total size - var expectedSize = HEADER_SIZE + SENDER_ID_SIZE + payloadLength.toInt() + // HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1 + val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte() + + // Payload length - version-dependent (2 or 4 bytes) + val payloadLength = if (version >= 2u.toUByte()) { + buffer.getInt().toUInt() // 4 bytes for v2+ + } else { + buffer.getShort().toUShort().toUInt() // 2 bytes for v1, convert to UInt + } + + if (payloadLength > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH.toUInt()) { + Log.w("BinaryProtocol", "Payload length ${payloadLength} exceeds maximum allowed (${com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH})") + return null + } + + var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt() if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE + var routeCount = 0 + if (hasRoute) { + // Peek count (1 byte) without consuming buffer for now + // The buffer is currently positioned at the start of SenderID (after fixed header) + // We must skip SenderID and RecipientID (if present) to find the route count + val currentPos = buffer.position() + var routeOffset = currentPos + SENDER_ID_SIZE + if (hasRecipient) { + routeOffset += RECIPIENT_ID_SIZE + } + + if (raw.size >= routeOffset + 1) { + routeCount = raw[routeOffset].toUByte().toInt() + } + expectedSize += 1 + (routeCount * SENDER_ID_SIZE) + } if (hasSignature) expectedSize += SIGNATURE_SIZE - + if (raw.size < expectedSize) return null // SenderID @@ -324,18 +469,75 @@ object BinaryProtocol { recipientBytes } else null + // Route (optional) + val route: List? = if (hasRoute) { + val count = buffer.get().toUByte().toInt() + if (count == 0) { + null // Treat empty route list as null to enforce canonical representation + } else { + val hops = mutableListOf() + repeat(count) { + val hop = ByteArray(SENDER_ID_SIZE) + buffer.get(hop) + hops.add(hop) + } + hops + } + } else null + // Payload + // Kept so the packet can be re-encoded byte-identically (see WirePayload). + var receivedCompressed: ByteArray? = null val payload = if (isCompressed) { - // First 2 bytes are original size - if (payloadLength.toInt() < 2) return null - val originalSize = buffer.getShort().toInt() + val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2 + if (payloadLength.toInt() < lengthFieldBytes) return null + + val originalSize = if (version >= 2u.toUByte()) { + buffer.getInt() + } else { + buffer.getShort().toUShort().toInt() + } + + val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + if (originalSize <= 0 || originalSize > maxExpandedSize) { + Log.w( + "BinaryProtocol", + "Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize" + ) + return null + } // Compressed payload - val compressedPayload = ByteArray(payloadLength.toInt() - 2) - buffer.get(compressedPayload) + val compressedSize = payloadLength.toInt() - lengthFieldBytes + if (compressedSize == 0) { + Log.w("BinaryProtocol", "Compressed payload has no deflate bytes") + return null + } + // Security check: Compression bomb protection + val ratio = originalSize.toDouble() / compressedSize.toDouble() + if (ratio > 50_000.0) { + Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1") + return null + } - // Decompress - CompressionUtil.decompress(compressedPayload, originalSize) ?: return null + // Reserve the compressed copy plus expanded output before either allocation. + // Small packets share the memory pool; packets wait only while its budget is full. + val resourceBytes = compressedSize.toLong() + originalSize.toLong() + val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) { + val compressedPayload = ByteArray(compressedSize) + buffer.get(compressedPayload) + // Captured here so the allocation stays inside the reservation. + receivedCompressed = compressedPayload + decompress(compressedPayload, originalSize) + } ?: return null + if (expandedPayload.size != originalSize) { + Log.w( + "BinaryProtocol", + "Expanded payload size ${expandedPayload.size} did not match declared size $originalSize" + ) + return null + } + expandedPayload } else { val payloadBytes = ByteArray(payloadLength.toInt()) buffer.get(payloadBytes) @@ -357,7 +559,13 @@ object BinaryProtocol { timestamp = timestamp, payload = payload, signature = signature, - ttl = ttl + ttl = ttl, + route = route, + wirePayload = WirePayload( + bytes = receivedCompressed ?: payload, + compressed = receivedCompressed != null, + forPayload = payload + ) ) } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt index d6096e10..5f8e9d5a 100644 --- a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt +++ b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt @@ -2,6 +2,7 @@ package com.bitchat.android.protocol import android.util.Log import java.io.ByteArrayOutputStream +import java.util.zip.DataFormatException import java.util.zip.Deflater import java.util.zip.Inflater @@ -10,7 +11,9 @@ import java.util.zip.Inflater * Uses the same zlib algorithm as iOS CompressionUtil.swift */ object CompressionUtil { - private const val COMPRESSION_THRESHOLD = 100 // bytes - same as iOS + private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS + + private val decompressionPool = DecompressionResourcePool.forRuntime() /** * Helper to check if compression is worth it - exact same logic as iOS @@ -73,47 +76,124 @@ object CompressionUtil { * iOS COMPRESSION_ZLIB produces raw deflate data (no headers) */ fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? { - // iOS COMPRESSION_ZLIB produces raw deflate format (no headers) - try { - val inflater = Inflater(true) // true = raw deflate, no headers - inflater.setInput(compressedData) - - val decompressedBuffer = ByteArray(originalSize) - val actualSize = inflater.inflate(decompressedBuffer) - inflater.end() - - // Verify decompressed size matches expected (same validation as iOS) - return if (actualSize == originalSize) { - decompressedBuffer - } else if (actualSize > 0) { - // Handle case where actual size is different - decompressedBuffer.copyOfRange(0, actualSize) - } else { + if (!isValidRequest(compressedData, originalSize)) return null + return withDecompressionResources(originalSize.toLong()) { + decompressWithResourcesReserved(compressedData, originalSize) + } + } + + internal fun withDecompressionResources(bytes: Long, block: () -> T): T? = + decompressionPool.withReservation(bytes, block) + + /** + * Inflate after the caller has reserved all packet-specific allocations. + * This avoids nested acquisition when BinaryProtocol reserves both its input copy and output. + */ + internal fun decompressWithResourcesReserved( + compressedData: ByteArray, + originalSize: Int + ): ByteArray? { + if (!isValidRequest(compressedData, originalSize)) return null + return decompressExact(compressedData, originalSize) + } + + private fun isValidRequest(compressedData: ByteArray, originalSize: Int): Boolean { + val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + if (compressedData.isEmpty()) { + Log.w("CompressionUtil", "Refusing an empty compressed payload") + return false + } + if (originalSize <= 0 || originalSize > maxExpandedSize) { + Log.w( + "CompressionUtil", + "Refusing expanded payload size $originalSize outside 1..$maxExpandedSize" + ) + return false + } + return true + } + + private fun decompressExact(compressedData: ByteArray, originalSize: Int): ByteArray? { + return if (looksLikeZlib(compressedData)) { + // A raw stream can coincidentally begin with a valid-looking zlib header. The + // header therefore only determines which format to try first; any non-exact zlib + // result must still fall back to raw under the same size/completion bounds. + val zlibResult = try { + inflateExact(compressedData, originalSize, nowrap = false) + } catch (zlibException: DataFormatException) { null } - } catch (e: Exception) { - Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...") - - // Fallback: try with zlib headers in case of mixed usage - try { - val inflater = Inflater(false) // false = expect zlib headers - inflater.setInput(compressedData) - - val decompressedBuffer = ByteArray(originalSize) - val actualSize = inflater.inflate(decompressedBuffer) - inflater.end() - - return if (actualSize == originalSize) { - decompressedBuffer - } else if (actualSize > 0) { - decompressedBuffer.copyOfRange(0, actualSize) - } else { + if (zlibResult != null) { + zlibResult + } else { + try { + inflateExact(compressedData, originalSize, nowrap = true) + } catch (rawException: DataFormatException) { + Log.d("CompressionUtil", "Invalid zlib/raw deflate stream") null } - } catch (fallbackException: Exception) { - Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}") - return null } + } else { + try { + inflateExact(compressedData, originalSize, nowrap = true) + } catch (rawException: DataFormatException) { + Log.d("CompressionUtil", "Invalid raw deflate stream") + null + } + } + } + + /** RFC 1950 header check used to avoid speculative double inflation. */ + private fun looksLikeZlib(data: ByteArray): Boolean { + if (data.size < 2) return false + val cmf = data[0].toInt() and 0xFF + val flg = data[1].toInt() and 0xFF + return (cmf and 0x0F) == 8 && + (cmf ushr 4) <= 7 && + ((cmf shl 8) or flg) % 31 == 0 + } + + /** + * Inflate one complete stream into exactly [originalSize] bytes. + * + * A full output buffer alone is not success: an attacker can under-declare a larger stream so + * the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely, + * a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected, + * as are trailing bytes after the compressed stream. + * + * [DataFormatException] is deliberately allowed to escape so the caller can try the legacy + * zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the + * same bytes are a complete, exact-sized zlib stream before they can be accepted. + */ + @Throws(DataFormatException::class) + private fun inflateExact( + compressedData: ByteArray, + originalSize: Int, + nowrap: Boolean + ): ByteArray? { + val inflater = Inflater(nowrap) + return try { + inflater.setInput(compressedData) + val output = ByteArray(originalSize) + var written = 0 + + while (written < originalSize) { + val count = inflater.inflate(output, written, originalSize - written) + if (count == 0) break + written += count + } + + if (written != originalSize) return null + + // Give Inflater one byte of room to consume the end marker. Any produced byte proves + // the declared size was smaller than the actual expansion. + val overflowProbe = ByteArray(1) + if (inflater.inflate(overflowProbe) != 0) return null + + if (!inflater.finished() || inflater.remaining != 0) return null + output + } finally { + inflater.end() } } diff --git a/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt new file mode 100644 index 00000000..a72c8070 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.protocol + +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit +import kotlin.math.ceil + +/** + * Fair, weighted admission control for decompression allocations. + * + * Permits represent memory rather than workers: small packets can proceed concurrently while + * near-limit packets consume most of the budget. Callers must reserve before allocating any + * packet-specific compressed copy or expanded output. + */ +internal class DecompressionResourcePool( + budgetBytes: Long, + private val unitBytes: Int, + private val waitTimeoutMs: Long +) { + private val totalPermits = (budgetBytes / unitBytes).toInt().coerceAtLeast(1) + private val permits = Semaphore(totalPermits, true) + + fun withReservation(bytes: Long, block: () -> T): T? { + val requiredPermits = permitsFor(bytes) + val acquired = try { + permits.tryAcquire(requiredPermits, waitTimeoutMs, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + if (!acquired) return null + + return try { + block() + } finally { + permits.release(requiredPermits) + } + } + + internal fun permitsFor(bytes: Long): Int = + ceil(bytes.coerceAtLeast(1).toDouble() / unitBytes.toDouble()) + .toInt() + .coerceAtMost(totalPermits) + + internal val availablePermits: Int + get() = permits.availablePermits() + + companion object { + private const val DEFAULT_UNIT_BYTES = 256 * 1024 + private const val DEFAULT_WAIT_TIMEOUT_MS = 1_000L + private const val HEAP_BUDGET_DIVISOR = 8L + private const val MAX_BUDGET_BYTES = 64L * 1024 * 1024 + + fun forRuntime( + maxHeapBytes: Long = Runtime.getRuntime().maxMemory(), + maxPacketResourceBytes: Long = + 2L * com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + ): DecompressionResourcePool { + val budget = recommendedBudgetBytes(maxHeapBytes, maxPacketResourceBytes) + return DecompressionResourcePool( + budgetBytes = budget, + unitBytes = DEFAULT_UNIT_BYTES, + waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS + ) + } + + internal fun recommendedBudgetBytes( + maxHeapBytes: Long, + maxPacketResourceBytes: Long + ): Long = (maxHeapBytes / HEAP_BUDGET_DIVISOR) + .coerceAtLeast(maxPacketResourceBytes) + .coerceAtMost(MAX_BUDGET_BYTES.coerceAtLeast(maxPacketResourceBytes)) + } +} diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt new file mode 100644 index 00000000..cdef409f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -0,0 +1,104 @@ +package com.bitchat.android.service + +import android.app.Application +import android.os.Process +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicLong + +/** + * Coordinates a full application shutdown: + * - Stop mesh cleanly + * - Stop Tor without changing persistent setting + * - Clear in-memory AppState + * - Stop foreground service/notification + * - Kill the process after completion or after a 5s timeout + */ +object AppShutdownCoordinator { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val shutdownToken = AtomicLong(0L) + @Volatile + private var shutdownJob: Job? = null + + fun cancelPendingShutdown() { + shutdownToken.incrementAndGet() + shutdownJob?.cancel() + shutdownJob = null + } + + fun requestFullShutdownAndKill( + app: Application, + mesh: MeshService?, + notificationManager: NotificationManagerCompat, + stopForeground: () -> Unit, + stopService: () -> Unit + ) { + val token = shutdownToken.incrementAndGet() + shutdownJob?.cancel() + val job = scope.launch { + // Signal UI to finish gracefully before we kill the process + try { + val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) + .setPackage(app.packageName) + app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH) + } catch (_: Exception) { } + + // Stop mesh (best-effort) + try { mesh?.stopServices() } catch (_: Exception) { } + try { com.bitchat.android.nostr.NostrRelayManager.shared.disconnect() } catch (_: Exception) { } + try { com.bitchat.android.mesh.PowerManager.getInstance(app).shutdown() } catch (_: Exception) { } + + // Stop Tor temporarily (do not change user setting) + val torProvider = ArtiTorManager.getInstance() + val torStop = async { + try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } + } + val conversationFlush = async { + try { + com.bitchat.android.services.AppStateStore + .awaitConversationPersistence() + } catch (_: Exception) { } + } + + // Clear AppState in-memory store + try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } + + // Stop foreground and clear notification + try { stopForeground() } catch (_: Exception) { } + try { notificationManager.cancel(10001) } catch (_: Exception) { } + + // Wait up to 5 seconds for shutdown tasks + withTimeoutOrNull(5000) { + try { torStop.await() } catch (_: Exception) { } + try { conversationFlush.await() } catch (_: Exception) { } + delay(100) + } + + // Stop the service itself + if (!isActive || shutdownToken.get() != token) return@launch + try { stopService() } catch (_: Exception) { } + + // Hard kill the app process + if (!isActive || shutdownToken.get() != token) return@launch + try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } + try { System.exit(0) } catch (_: Exception) { } + } + shutdownJob = job + job.invokeOnCompletion { + if (shutdownJob === job) { + shutdownJob = null + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt new file mode 100644 index 00000000..03a9dcd8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt @@ -0,0 +1,16 @@ +package com.bitchat.android.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Ensure preferences are initialized on cold boot before reading values + try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { } + + if (MeshServicePreferences.isAutoStartEnabled(true)) { + MeshForegroundService.start(context.applicationContext) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt new file mode 100644 index 00000000..6feca2a7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt @@ -0,0 +1,88 @@ +package com.bitchat.android.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.RemoteInput +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.MessageRouter +import com.bitchat.android.ui.NotificationManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.Date +import java.util.UUID + +/** Handles privacy-scoped direct reply and mark-read actions from DM notifications. */ +class ConversationNotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val conversationID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID) + ?.let(ContactDirectory::canonicalConversationId) + ?: return + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + var acknowledged = false + when (intent.action) { + NotificationManager.ACTION_MARK_CONVERSATION_READ -> { + acknowledged = + AppStateStore.setPrivateConversationRead(conversationID, true) + } + + NotificationManager.ACTION_REPLY_TO_CONVERSATION -> { + val reply = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(NotificationManager.KEY_TEXT_REPLY) + ?.toString() + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: return@launch + // A notification can outlive the process/service that posted it. Promote + // the mesh runtime before dispatch so Android keeps the transport alive + // after this short-lived receiver finishes. + MeshForegroundService.start(context.applicationContext) + val mesh = MeshServiceHolder.getUnifiedOrCreate( + context.applicationContext + ) + val message = BitchatMessage( + id = UUID.randomUUID().toString().uppercase(), + sender = mesh.myPeerID, + content = reply, + timestamp = Date(), + isPrivate = true, + recipientNickname = intent.getStringExtra( + NotificationManager.EXTRA_SENDER_NICKNAME + ), + senderPeerID = mesh.myPeerID, + deliveryStatus = DeliveryStatus.Sending + ) + val persisted = AppStateStore.addPrivateMessageDurably( + peerID = conversationID, + msg = message, + forceRead = true + ) + if (persisted) { + MessageRouter.getInstance(context.applicationContext, mesh) + .sendPrivate( + content = reply, + toPeerID = conversationID, + recipientNickname = message.recipientNickname.orEmpty(), + messageID = message.id + ) + acknowledged = + AppStateStore.setPrivateConversationRead(conversationID, true) + } + } + } + if (acknowledged) { + NotificationManager.acknowledgeConversation(context, conversationID) + } + } finally { + pendingResult.finish() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt new file mode 100644 index 00000000..42feca39 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -0,0 +1,383 @@ +package com.bitchat.android.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import com.bitchat.android.mesh.BluetoothMeshService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +class MeshForegroundService : Service() { + + companion object { + private const val CHANNEL_ID = "bitchat_mesh_service" + private const val NOTIFICATION_ID = 10001 + + const val ACTION_START = "com.bitchat.android.service.START" + const val ACTION_STOP = "com.bitchat.android.service.STOP" + const val ACTION_QUIT = "com.bitchat.android.service.QUIT" + const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION" + + fun start(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START } + + // Only launch as an FGS when onStartCommand can promote immediately. + val shouldStartForeground = shouldStartAsForeground(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (shouldStartForeground) { + context.startForegroundService(intent) + } else { + android.util.Log.i( + "MeshForegroundService", + "Not starting service on API>=26 (shouldStartForeground=$shouldStartForeground)" + ) + } + } else { + if (MeshServicePreferences.isBackgroundEnabled(true)) { + context.startService(intent) + } else { + android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)") + } + } + } + + fun stop(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP } + context.startService(intent) + } + + private fun shouldStartAsForeground(context: Context): Boolean { + return MeshServicePreferences.isBackgroundEnabled(true) && + hasBluetoothPermissionsStatic(context) + } + + private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + } + + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var peerAvailabilityNotifier: PeerAvailabilityNotifier + private var updateJob: Job? = null + private val meshService: BluetoothMeshService? + get() = MeshServiceHolder.meshService + private val unifiedMeshService: com.bitchat.android.mesh.MeshService? + get() = MeshServiceHolder.unifiedMeshService + private val serviceJob = Job() + // Service lifecycle callbacks and notification state are main-thread confined. + private val scope = CoroutineScope(Dispatchers.Main.immediate + serviceJob) + private var isInForeground: Boolean = false + private var isShuttingDown: Boolean = false + private var lastNotifiedPeerCount: Int? = null + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + peerAvailabilityNotifier = PeerAvailabilityNotifier( + context = applicationContext, + scope = scope, + isAppCurrentlyInBackground = { + !ProcessLifecycleOwner.get() + .lifecycle + .currentState + .isAtLeast(Lifecycle.State.STARTED) + } + ) + createChannel() + + // Ensure mesh service exists in holder (create if needed) + val existing = MeshServiceHolder.meshService + if (existing != null) { + Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder") + } else { + val created = MeshServiceHolder.getOrCreate(applicationContext) + Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder") + MeshServiceHolder.attach(created) + } + MeshServiceHolder.getUnifiedOrCreate(applicationContext) + + // Notification content is driven by peer-state changes, not a permanent timer. + updateJob = scope.launch { + com.bitchat.android.services.AppStateStore.peers + .map { peers -> peers.distinct().size } + .distinctUntilChanged() + .collect { peerCount -> + peerAvailabilityNotifier.onPeerCountChanged( + peerCount = peerCount, + isAppInBackground = !ProcessLifecycleOwner.get() + .lifecycle + .currentState + .isAtLeast(Lifecycle.State.STARTED) + ) + if (isInForeground) updateNotification(force = false) + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (isShuttingDown && intent?.action == ACTION_START) { + AppShutdownCoordinator.cancelPendingShutdown() + isShuttingDown = false + } + if (isShuttingDown && intent?.action != ACTION_QUIT) { + return START_NOT_STICKY + } + when (intent?.action) { + ACTION_STOP -> { + // Stop FGS and mesh cleanly + updateJob?.cancel() + updateJob = null + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { } + try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { } + try { MeshServiceHolder.clear() } catch (_: Exception) { } + try { stopForeground(true) } catch (_: Exception) { } + clearMeshNotifications() + isInForeground = false + stopSelf() + return START_NOT_STICKY + } + ACTION_QUIT -> { + isShuttingDown = true + updateJob?.cancel() + updateJob = null + try { stopForeground(true) } catch (_: Exception) { } + clearMeshNotifications() + isInForeground = false + // Fully stop all background activity, stop Tor (without changing setting), then kill the app + AppShutdownCoordinator.requestFullShutdownAndKill( + app = application, + mesh = unifiedMeshService, + notificationManager = notificationManager, + stopForeground = { + try { stopForeground(true) } catch (_: Exception) { } + isInForeground = false + }, + stopService = { stopSelf() } + ) + return START_NOT_STICKY + } + ACTION_UPDATE_NOTIFICATION -> { + // If we became eligible and are not in foreground yet, promote once + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val count = getUnifiedActivePeerCount() + val n = buildNotification(count) + startForegroundCompat(n) + isInForeground = true + lastNotifiedPeerCount = count + } else { + updateNotification(force = true) + } + } + else -> { /* ACTION_START or null */ } + } + + // Ensure mesh is running (only after permissions are granted) + ensureMeshStarted() + + // Promote exactly once when eligible, otherwise stay background (or stop). + // POST_NOTIFICATIONS is intentionally not an eligibility requirement: Android 13+ + // still allows foreground services and exposes them in the system task manager. + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val count = getUnifiedActivePeerCount() + val notification = buildNotification(count) + startForegroundCompat(notification) + isInForeground = true + lastNotifiedPeerCount = count + } + + return START_STICKY + } + + private fun ensureMeshStarted() { + if (isShuttingDown) return + try { + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() + } catch (e: Exception) { + android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}") + } + + try { + android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") + val service = MeshServiceHolder.getUnifiedOrCreate(applicationContext) + service.startServices() + } catch (e: Exception) { + android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") + } + } + + private fun updateNotification(force: Boolean) { + if (isShuttingDown) { + clearMeshNotifications() + return + } + val count = getUnifiedActivePeerCount() + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { + if (lastNotifiedPeerCount != count) { + startForegroundCompat(buildNotification(count)) + lastNotifiedPeerCount = count + } + } else if (force) { + // If disabled and forced, make sure to remove any prior foreground state + try { stopForeground(false) } catch (_: Exception) { } + clearMeshNotifications() + isInForeground = false + lastNotifiedPeerCount = null + } + } + + private fun clearMeshNotifications() { + notificationManager.cancel(NOTIFICATION_ID) + peerAvailabilityNotifier.clear() + } + + private fun hasAllRequiredPermissions(): Boolean { + // For starting FGS with connectedDevice|dataSync, we need: + // - Foreground service permissions (declared in manifest) + // - One of the device-related permissions (we request BL perms at runtime) + // POST_NOTIFICATIONS controls notification-drawer visibility, not FGS eligibility. + return hasBluetoothPermissions() + } + + private fun getUnifiedActivePeerCount(): Int { + return try { + unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0 + } catch (_: Exception) { + 0 + } + } + + private fun hasBluetoothPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + // Prior to S, scanning requires location permissions + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + private fun buildNotification(activePeers: Int): Notification { + val openIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + // Action: Quit Bitchat + val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT } + val quitPendingIntent = PendingIntent.getService( + this, 1, quitIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + val title = getString(R.string.app_name) + val content = getString(R.string.mesh_service_notification_content, activePeers) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(pendingIntent) + // Add an action button that appears when notification is expanded + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + getString(R.string.notification_action_quit_bitchat), + quitPendingIntent + ) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.mesh_service_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.mesh_service_channel_desc) + setShowBadge(false) + } + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + private fun hasLocationPermission(): Boolean { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + return fine || coarse + } + + private fun startForegroundCompat(notification: Notification) { + if (Build.VERSION.SDK_INT >= 34) { + val type = if (hasLocationPermission()) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + try { + startForeground(NOTIFICATION_ID, notification, type) + } catch (e: SecurityException) { + // Fallback for cases where "While In Use" permission exists but background start is restricted + if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) { + android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}") + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + } else { + throw e + } + } + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + override fun onDestroy() { + updateJob?.cancel() + updateJob = null + // Cancel the service coroutine scope to prevent leaks + try { serviceJob.cancel() } catch (_: Exception) { } + // Best-effort ensure we are not marked foreground + if (isInForeground) { + try { stopForeground(true) } catch (_: Exception) { } + isInForeground = false + } + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt new file mode 100644 index 00000000..aea8fce6 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -0,0 +1,146 @@ +package com.bitchat.android.service + +import android.content.Context +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.UnifiedMeshService +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.sync.GossipSyncManager + +/** + * Process-wide holder to share a single BluetoothMeshService instance + * between the foreground service and UI (MainActivity/ViewModels). + */ +object MeshServiceHolder { + private const val TAG = "MeshServiceHolder" + @Volatile + var sharedGossipSyncManager: GossipSyncManager? = null + private set + + private val activeGossipOwners = mutableSetOf() + + @Synchronized + fun setGossipManager( + mgr: GossipSyncManager, + signer: (BitchatPacket) -> BitchatPacket + ) { + val previous = sharedGossipSyncManager + if (previous !== mgr) { + try { previous?.stop() } catch (_: Exception) { } + } + sharedGossipSyncManager = mgr + mgr.delegate = TransportGossipDelegate(signer) + if (activeGossipOwners.isNotEmpty()) { + mgr.start() + } + } + + @Synchronized + fun startSharedGossip(owner: String) { + val wasIdle = activeGossipOwners.isEmpty() + activeGossipOwners.add(owner) + if (wasIdle) { + sharedGossipSyncManager?.start() + } + } + + @Synchronized + fun stopSharedGossip(owner: String) { + activeGossipOwners.remove(owner) + if (activeGossipOwners.isEmpty()) { + sharedGossipSyncManager?.stop() + } + } + + private class TransportGossipDelegate( + private val signer: (BitchatPacket) -> BitchatPacket + ) : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) + } + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + TransportBridgeService.sendToPeerFromLocal(peerID, packet) + } + + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signer(packet) + } + } + + @Volatile + var meshService: BluetoothMeshService? = null + private set + + @Volatile + var unifiedMeshService: UnifiedMeshService? = null + private set + + @Synchronized + fun getOrCreate(context: Context): BluetoothMeshService { + val existing = meshService + if (existing != null) { + // If the existing instance is healthy, reuse it; otherwise, replace it. + return try { + if (existing.isReusable()) { + android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance") + existing + } else { + android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance") + // Best-effort stop before replacing + try { existing.stopServices() } catch (e: Exception) { + android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}") + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)") + meshService = created + unifiedMeshService = null + created + } + } catch (e: Exception) { + android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}") + val created = BluetoothMeshService(context.applicationContext) + meshService = created + unifiedMeshService = null + created + } + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)") + meshService = created + unifiedMeshService = null + return created + } + + @Synchronized + fun getUnifiedOrCreate(context: Context): UnifiedMeshService { + val bluetooth = getOrCreate(context) + val existing = unifiedMeshService + if (existing != null) { + existing.refreshDelegates() + return existing + } + val created = UnifiedMeshService(context.applicationContext, bluetooth) + unifiedMeshService = created + android.util.Log.i(TAG, "Created new UnifiedMeshService") + return created + } + + @Synchronized + fun attach(service: BluetoothMeshService) { + android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder") + meshService = service + unifiedMeshService = null + } + + @Synchronized + fun clear() { + android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder") + try { sharedGossipSyncManager?.clear() } catch (_: Exception) { } + try { sharedGossipSyncManager?.stop() } catch (_: Exception) { } + sharedGossipSyncManager = null + activeGossipOwners.clear() + meshService = null + unifiedMeshService = null + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt new file mode 100644 index 00000000..47335ee3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt @@ -0,0 +1,32 @@ +package com.bitchat.android.service + +import android.content.Context +import android.content.SharedPreferences + +object MeshServicePreferences { + private const val PREFS_NAME = "bitchat_mesh_service_prefs" + private const val KEY_AUTO_START = "auto_start_on_boot" + private const val KEY_BACKGROUND_ENABLED = "background_enabled" + + private lateinit var prefs: SharedPreferences + + fun init(context: Context) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + fun isAutoStartEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_AUTO_START, default) + } + + fun setAutoStartEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply() + } + + fun isBackgroundEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default) + } + + fun setBackgroundEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt b/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt new file mode 100644 index 00000000..714d0708 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/PeerAvailabilityNotifier.kt @@ -0,0 +1,280 @@ +package com.bitchat.android.service + +import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal enum class PeerAvailabilityAction { + NONE, + SHOW, + CLEAR +} + +internal interface PeerAvailabilityAlertHistory { + var lastAlertAtMillis: Long? +} + +internal class SharedPreferencesPeerAvailabilityAlertHistory( + context: Context +) : PeerAvailabilityAlertHistory { + companion object { + internal const val PREFERENCES_NAME = "peer_availability_notifications" + private const val KEY_LAST_ALERT_AT_MILLIS = "last_alert_at_millis" + } + + private val preferences = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + override var lastAlertAtMillis: Long? + get() = if (preferences.contains(KEY_LAST_ALERT_AT_MILLIS)) { + preferences.getLong(KEY_LAST_ALERT_AT_MILLIS, 0L) + } else { + null + } + set(value) { + preferences.edit { + if (value == null) { + remove(KEY_LAST_ALERT_AT_MILLIS) + } else { + putLong(KEY_LAST_ALERT_AT_MILLIS, value) + } + } + } +} + +/** + * Tracks mesh availability epochs with two anti-flapping gates: + * - no more than one alert per persisted cooldown window; + * - after an alert, the mesh must remain empty before another epoch can re-arm. + */ +internal class PeerAvailabilityTracker( + private val alertHistory: PeerAvailabilityAlertHistory, + private val nowMillis: () -> Long = System::currentTimeMillis, + private val alertCooldownMs: Long = ALERT_COOLDOWN_MS, + private val emptyRearmDelayMs: Long = EMPTY_REARM_DELAY_MS +) { + companion object { + internal const val ALERT_COOLDOWN_MS = 5 * 60_000L + internal const val EMPTY_REARM_DELAY_MS = 30_000L + } + + private var previousPeerCount = 0 + private var isArmed = true + private var emptySinceMillis: Long? = null + + init { + require(alertCooldownMs >= 0) { "alertCooldownMs must not be negative" } + require(emptyRearmDelayMs >= 0) { "emptyRearmDelayMs must not be negative" } + } + + fun update(peerCount: Int, isAppInBackground: Boolean): PeerAvailabilityAction { + require(peerCount >= 0) { "peerCount must not be negative" } + + val now = nowMillis() + if (peerCount == 0) { + if (previousPeerCount > 0 || emptySinceMillis == null) { + emptySinceMillis = now + } + previousPeerCount = 0 + return PeerAvailabilityAction.CLEAR + } + + val transitionedFromEmpty = previousPeerCount == 0 + previousPeerCount = peerCount + if (!transitionedFromEmpty) return PeerAvailabilityAction.NONE + + if (!isArmed) { + val emptySince = emptySinceMillis + val remainedEmptyLongEnough = + emptySince != null && now - emptySince >= emptyRearmDelayMs + if (!remainedEmptyLongEnough) { + emptySinceMillis = null + return PeerAvailabilityAction.NONE + } + isArmed = true + } + emptySinceMillis = null + + val lastAlertAt = alertHistory.lastAlertAtMillis + val cooldownElapsed = + lastAlertAt == null || now - lastAlertAt >= alertCooldownMs + return if (isAppInBackground && cooldownElapsed) { + PeerAvailabilityAction.SHOW + } else { + PeerAvailabilityAction.NONE + } + } + + /** + * Called only after NotificationManager accepts the post. Failed or disabled posts + * do not consume the cooldown or require the mesh to re-arm. + */ + fun markAlertShown() { + alertHistory.lastAlertAtMillis = nowMillis() + isArmed = false + } +} + +internal interface PeerAvailabilityTextProvider { + fun title(): String + fun body(peerCount: Int): String +} + +private class AndroidPeerAvailabilityTextProvider( + private val context: Context +) : PeerAvailabilityTextProvider { + override fun title(): String { + return context.getString(R.string.notification_active_peers_title) + } + + override fun body(peerCount: Int): String { + return if (peerCount == 1) { + context.getString(R.string.notification_active_peers_one) + } else { + context.getString(R.string.notification_active_peers_many, peerCount) + } + } +} + +/** + * Owns the user-visible "bitchatters nearby" notification independently of the UI delegate. + * The first eligible peer starts a fixed aggregation window; arrivals within that window update + * the count without postponing the alert indefinitely. + */ +internal class PeerAvailabilityNotifier( + private val context: Context, + private val scope: CoroutineScope, + private val notificationManager: NotificationManagerCompat = + NotificationManagerCompat.from(context), + private val tracker: PeerAvailabilityTracker = PeerAvailabilityTracker( + SharedPreferencesPeerAvailabilityAlertHistory(context) + ), + private val textProvider: PeerAvailabilityTextProvider = + AndroidPeerAvailabilityTextProvider(context), + private val isAppCurrentlyInBackground: () -> Boolean, + private val aggregationWindowMs: Long = AGGREGATION_WINDOW_MS, + private val canPostNotifications: () -> Boolean = { + notificationManager.areNotificationsEnabled() && + ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) + } +) { + companion object { + internal const val CHANNEL_ID = "bitchat_peer_availability_notifications" + internal const val NOTIFICATION_ID = 997 + internal const val AGGREGATION_WINDOW_MS = 10_000L + private const val TAG = "PeerAvailability" + } + + private var latestPeerCount = 0 + private var pendingNotificationJob: Job? = null + + init { + require(aggregationWindowMs >= 0) { "aggregationWindowMs must not be negative" } + createNotificationChannel() + } + + fun onPeerCountChanged(peerCount: Int, isAppInBackground: Boolean) { + latestPeerCount = peerCount + when (tracker.update(peerCount, isAppInBackground)) { + PeerAvailabilityAction.NONE -> Unit + PeerAvailabilityAction.CLEAR -> clear() + PeerAvailabilityAction.SHOW -> scheduleNotification() + } + } + + fun clear() { + pendingNotificationJob?.cancel() + pendingNotificationJob = null + notificationManager.cancel(NOTIFICATION_ID) + } + + private fun scheduleNotification() { + if (pendingNotificationJob?.isActive == true) return + + pendingNotificationJob = scope.launch { + delay(aggregationWindowMs) + pendingNotificationJob = null + + val peerCount = latestPeerCount + if (peerCount > 0 && isAppCurrentlyInBackground()) { + showNotification(peerCount) + } + } + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + textProvider.title(), + NotificationManager.IMPORTANCE_HIGH + ).apply { + enableVibration(true) + setShowBadge(false) + } + val systemManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + systemManager.createNotificationChannel(channel) + } + + @SuppressLint("MissingPermission") + private fun showNotification(peerCount: Int) { + if (!canPostNotifications()) { + Log.i(TAG, "Skipping peer availability notification because notifications are disabled") + return + } + + val openAppIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_ID, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(textProvider.title()) + .setContentText(textProvider.body(peerCount)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + .build() + + try { + notificationManager.notify(NOTIFICATION_ID, notification) + tracker.markAlertShown() + Log.i(TAG, "Posted peer availability notification for $peerCount peer(s)") + } catch (error: SecurityException) { + Log.w(TAG, "Notification permission changed before peer alert was posted", error) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt new file mode 100644 index 00000000..67a7a1d8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -0,0 +1,257 @@ +package com.bitchat.android.service + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CancellationException +import java.security.MessageDigest +import java.util.Collections +import java.util.LinkedHashMap +import java.util.concurrent.ConcurrentHashMap + +/** + * Central bridge for routing packets between different transport layers + * (e.g., Bluetooth LE <-> Wi-Fi Aware). + * + * Allows a packet received on one transport to be seamlessly relayed + * to all other active transports, effectively bridging separate meshes. + */ +object TransportBridgeService { + private const val TAG = "TransportBridgeService" + private const val MAX_SEEN_PACKETS = 4096 + private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L + + /** + * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement + * to receive bridged packets. + */ + interface TransportLayer { + /** + * Send a packet out via this transport. + */ + fun send(packet: RoutedPacket) + + /** + * Send a packet and report whether at least one concrete transport write was accepted. + * + * Receipt retries use this path so a registered-but-disconnected transport cannot be + * mistaken for a successful send. + */ + suspend fun sendAndReport(packet: RoutedPacket): Boolean = false + + /** + * Send a packet to a specific peer via this transport (optional). + */ + fun sendToPeer(peerID: String, packet: BitchatPacket) { } + } + + private val transports = ConcurrentHashMap() + private val seenPackets = Collections.synchronizedMap( + object : LinkedHashMap(MAX_SEEN_PACKETS, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MAX_SEEN_PACKETS + } + } + ) + private data class PreparedForward( + val packet: BitchatPacket, + val seenKey: String, + val reservedAtMs: Long + ) + + /** + * Register a transport layer to receive bridged packets. + * @param id Unique identifier (e.g., "BLE", "WIFI") + * @param layer The transport implementation + */ + fun register(id: String, layer: TransportLayer) { + Log.i(TAG, "Registering transport layer: $id") + transports[id] = layer + } + + /** + * Unregister a transport layer. + */ + fun unregister(id: String) { + Log.i(TAG, "Unregistering transport layer: $id") + transports.remove(id) + } + + /** + * Broadcast a packet from a specific source transport to ALL other registered transports. + * + * @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE"). + * The packet will NOT be sent back to this source. + * @param packet The packet to bridge. + */ + fun broadcast(sourceId: String, packet: RoutedPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + val prepared = prepareForwardedPacket("broadcast", packet.packet) ?: return + val forwardedPacket = prepared.packet + // Prepared private-media fragments must remain the admitted plan when + // crossing transports, but relay TTL still has to advance on every + // hop. TTL is excluded from the signature and does not affect size. + val forwarded = packet.copy( + packet = forwardedPacket, + preparedPackets = packet.preparedPackets?.map { prepared -> + prepared.copy(ttl = forwardedPacket.ttl) + } + ) + + // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}") + + targets.forEach { (id, layer) -> + try { + layer.send(forwarded) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") + } + } + } + + /** + * Broadcasts through every other active transport and reports whether any concrete write was + * accepted. Failed attempts release their duplicate-suppression reservation so a later retry + * can use a transport that reconnects during the retry window. + */ + suspend fun broadcastAndReport(sourceId: String, packet: RoutedPacket): Boolean { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return false + val kind = "broadcast" + val prepared = prepareForwardedPacket(kind, packet.packet) ?: return false + val forwardedPacket = prepared.packet + val forwarded = packet.copy( + packet = forwardedPacket, + preparedPackets = packet.preparedPackets?.map { prepared -> + prepared.copy(ttl = forwardedPacket.ttl) + } + ) + + var accepted = false + targets.forEach { (id, layer) -> + val targetAccepted = try { + layer.sendAndReport(forwarded) + } catch (e: CancellationException) { + releaseSeenPacket(prepared) + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") + false + } + accepted = targetAccepted || accepted + } + if (!accepted) { + releaseSeenPacket(prepared) + } + return accepted + } + + /** + * Send a packet to a specific peer across all other transports. + */ + fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + val forwardedPacket = + prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return + + targets.forEach { (id, layer) -> + try { + layer.sendToPeer(peerID, forwardedPacket) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}") + } + } + } + + /** + * Send a locally originated packet to every active transport without applying relay TTL + * handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is + * intentionally zero on the first radio hop. + */ + fun broadcastFromLocal(packet: RoutedPacket) { + val targets = transports.toMap() + if (targets.isEmpty()) return + + targets.forEach { (id, layer) -> + try { + layer.send(packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to send local packet to $id: ${e.message}") + } + } + } + + /** + * Send a locally originated packet directly to a peer on every active transport. + */ + fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) { + val targets = transports.toMap() + if (targets.isEmpty()) return + + targets.forEach { (id, layer) -> + try { + layer.sendToPeer(peerID, packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}") + } + } + } + + private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? { + if (packet.ttl == 0u.toUByte()) { + Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") + return null + } + + val key = "$kind:${logicalPacketId(packet)}" + val now = System.currentTimeMillis() + synchronized(seenPackets) { + pruneSeen(now) + val previous = seenPackets[key] + if (previous != null && now - previous < SEEN_PACKET_TTL_MS) { + Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}") + return null + } + seenPackets[key] = now + } + + return PreparedForward( + packet = packet.copy(ttl = (packet.ttl - 1u).toUByte()), + seenKey = key, + reservedAtMs = now + ) + } + + private fun releaseSeenPacket(prepared: PreparedForward) { + synchronized(seenPackets) { + if (seenPackets[prepared.seenKey] == prepared.reservedAtMs) { + seenPackets.remove(prepared.seenKey) + } + } + } + + private fun pruneSeen(now: Long) { + val iterator = seenPackets.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > SEEN_PACKET_TTL_MS) { + iterator.remove() + } + } + } + + private fun logicalPacketId(packet: BitchatPacket): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(packet.type.toByte()) + digest.update(packet.senderID) + packet.recipientID?.let { digest.update(it) } + digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8)) + digest.update(packet.payload) + packet.route?.forEach { digest.update(it) } + packet.signature?.let { digest.update(it) } + return digest.digest().toHexString() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt new file mode 100644 index 00000000..c4f52675 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -0,0 +1,1120 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide in-memory state store that survives Activity recreation. + * The foreground Mesh service updates this store; UI subscribes/hydrates from it. + */ +object AppStateStore { + // Global de-dup set by message id to avoid duplicate keys in Compose lists + private val seenMessageIds = mutableSetOf() + private val reservedPrivateMessageIds = mutableSetOf() + private val seenPublicMessageKeys = mutableSetOf() + private val peerIdsByTransport = mutableMapOf>() + private var privateWritesSinceGlobalPrune = 0 + // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. + private val directPeerIdsByTransport = mutableMapOf>() + private val _directPeers = MutableStateFlow>(emptySet()) + val directPeers: StateFlow> = _directPeers.asStateFlow() + // Connected peer IDs (mesh ephemeral IDs) + private val _peers = MutableStateFlow>(emptyList()) + val peers: StateFlow> = _peers.asStateFlow() + + // Public mesh timeline messages (non-channel) + private val _publicMessages = MutableStateFlow>(emptyList()) + val publicMessages: StateFlow> = _publicMessages.asStateFlow() + + // Private messages by peerID + private val _privateMessages = MutableStateFlow>>(emptyMap()) + val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + private val _readPrivateMessageIDs = MutableStateFlow>(emptySet()) + val readPrivateMessageIDs: StateFlow> = _readPrivateMessageIDs.asStateFlow() + private val _unreadPrivateMessageCounts = MutableStateFlow>(emptyMap()) + val unreadPrivateMessageCounts: StateFlow> = + _unreadPrivateMessageCounts.asStateFlow() + private val _privateConversationDisplayNames = + MutableStateFlow>(emptyMap()) + val privateConversationDisplayNames: StateFlow> = + _privateConversationDisplayNames.asStateFlow() + + @Volatile + private var conversationRepository: ConversationRepository? = null + private var privateConversationWritesSuspended = false + private var privateConversationGeneration = 0L + + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() + + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() + + // Channel messages by channel name + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() + + fun setPeers(ids: List) { + synchronized(this) { + _peers.value = ids.distinct() + } + } + + fun setNickname(nickname: String) { + _nickname.value = nickname + } + + fun setSelectedPrivateChatPeer(peerID: String?) { + _selectedPrivateChatPeer.value = peerID + } + + fun initializeConversationPersistence(context: Context) { + val repository = ConversationRepository.getInstance(context.applicationContext) + conversationRepository = repository + repository.initialize(::restorePrivateConversations) + } + + /** + * Restores database state again for a newly created UI, even if Android reused this process + * after a controlled shutdown cleared the process-wide state. + */ + fun reloadConversationPersistence(context: Context) { + val repository = ConversationRepository.getInstance(context.applicationContext) + conversationRepository = repository + repository.reload(::restorePrivateConversations) + } + + internal fun setConversationRepositoryForTest(repository: ConversationRepository?) { + conversationRepository = repository + } + + suspend fun awaitConversationPersistence() { + conversationRepository?.awaitPendingWrites() + } + + suspend fun loadPrivateConversationHistory(conversationID: String): Boolean { + val repository = conversationRepository ?: return false + val snapshot = repository.loadConversationAndWait( + ContactDirectory.canonicalConversationId(conversationID) + ) ?: return false + restorePrivateConversations(snapshot) + return true + } + + /** + * Drops an opened conversation's full payloads from memory while retaining its summary row. + * The complete bounded history remains encrypted in SQLite and is loaded again on demand. + */ + fun releasePrivateConversationHistory(conversationID: String) { + synchronized(this) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val matching = _privateMessages.value.entries.filter { (id, _) -> + ContactDirectory.canonicalConversationId(id) + .equals(canonicalID, ignoreCase = true) + } + val latest = matching + .flatMap { it.value } + .distinctBy { it.id } + .maxWithOrNull( + compareBy { + PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MIN_VALUE + }.thenBy { it.timestamp.time } + ) + ?: return + val compacted = _privateMessages.value.toMutableMap() + matching.forEach { compacted.remove(it.key) } + compacted[canonicalID] = listOf(latest) + _privateMessages.value = compacted + } + } + + val conversationStoreState: StateFlow + get() = conversationRepository?.storeState ?: EMPTY_CONVERSATION_STORE_STATE + + fun setTransportPeers(transportId: String, ids: List) { + synchronized(this) { + peerIdsByTransport[transportId] = ids.toSet() + publishTransportPeersLocked() + } + } + + fun clearTransportPeers(transportId: String) { + synchronized(this) { + peerIdsByTransport.remove(transportId) + publishTransportPeersLocked() + } + } + + private fun publishTransportPeersLocked() { + _peers.value = peerIdsByTransport.values + .asSequence() + .flatten() + .distinct() + .toList() + } + + /** + * Record the set of direct (single-hop) peers reachable over a given transport. Each transport + * (BLE, Wi-Fi Aware, ...) only knows its own direct peers; [getDirectPeers] unions them so every + * transport can gossip the same complete neighbor list under our shared node identity. + */ + fun setTransportDirectPeers(transportId: String, ids: Collection) { + synchronized(this) { + directPeerIdsByTransport[transportId] = ids.toSet() + publishDirectPeersLocked() + } + } + + fun clearTransportDirectPeers(transportId: String) { + synchronized(this) { + directPeerIdsByTransport.remove(transportId) + publishDirectPeersLocked() + } + } + + /** Union of direct peers across all transports. */ + fun getDirectPeers(): Set = _directPeers.value + + private fun publishDirectPeersLocked() { + _directPeers.value = directPeerIdsByTransport.values + .asSequence() + .flatten() + .toSet() + } + + fun addPublicMessage(msg: BitchatMessage) { + synchronized(this) { + val publicKey = publicMessageKey(msg) + if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return + seenMessageIds.add(msg.id) + seenPublicMessageKeys.add(publicKey) + _publicMessages.value = _publicMessages.value + msg + } + } + + /** Replace a live media row by ID, or append it if the row was not admitted yet. */ + fun upsertPublicMessage(msg: BitchatMessage) { + synchronized(this) { + val index = _publicMessages.value.indexOfFirst { it.id == msg.id } + if (index >= 0) { + _publicMessages.value = _publicMessages.value.toMutableList().also { it[index] = msg } + } else { + seenMessageIds.add(msg.id) + seenPublicMessageKeys.add(publicMessageKey(msg)) + _publicMessages.value = _publicMessages.value + msg + } + } + } + + fun removePublicMessage(messageID: String) { + synchronized(this) { + val existing = _publicMessages.value.firstOrNull { it.id == messageID } ?: return + _publicMessages.value = _publicMessages.value.filterNot { it.id == messageID } + seenMessageIds.remove(messageID) + seenPublicMessageKeys.remove(publicMessageKey(existing)) + } + } + + fun addPrivateMessage( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean = false + ): Boolean = synchronized(this) { + addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true) + } + + /** Replace-or-append used by a live voice row as its partial file becomes final media. */ + fun upsertPrivateMessage(peerID: String, msg: BitchatMessage, forceRead: Boolean = false) { + synchronized(this) { + if (privateConversationWritesSuspended) return + val canonicalID = ContactDirectory.canonicalConversationId(peerID) + val map = _privateMessages.value.toMutableMap() + val matchingKey = map.keys.firstOrNull { + ContactDirectory.canonicalConversationId(it).equals(canonicalID, ignoreCase = true) + } ?: canonicalID + val messages = map[matchingKey].orEmpty().toMutableList() + val index = messages.indexOfFirst { it.id == msg.id } + if (index >= 0) { + messages[index] = msg + } else { + messages += msg + seenMessageIds.add(msg.id) + } + map[matchingKey] = messages + _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + if (forceRead) { + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id + } + conversationRepository?.upsertMessage( + conversationID = canonicalID, + aliases = privateConversationAliases(peerID, canonicalID), + displayName = ContactDirectory.resolve(canonicalID).displayName, + message = msg, + isRead = forceRead + ) + } + } + + /** + * Persists an incoming private message before it is admitted to UI, unread, haptic, or + * notification state. Transport callbacks invoke this from their background worker. + */ + suspend fun addPrivateMessageDurably( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean = false + ): Boolean { + val persistence = synchronized(this) { + if (privateConversationWritesSuspended) return false + if (seenMessageIds.contains(msg.id) || !reservedPrivateMessageIds.add(msg.id)) { + return false + } + privateMessagePersistence(peerID, msg, forceRead) + } + val repository = persistence.repository + if (repository == null) { + synchronized(this) { reservedPrivateMessageIds.remove(msg.id) } + return false + } + val persisted = repository.upsertMessageAndWait( + conversationID = persistence.conversationID, + aliases = persistence.aliases, + displayName = persistence.displayName, + message = msg, + isRead = persistence.isRead + ) + return synchronized(this) { + reservedPrivateMessageIds.remove(msg.id) + if ( + !persisted || + privateConversationWritesSuspended || + persistence.generation != privateConversationGeneration || + seenMessageIds.contains(msg.id) + ) { + return@synchronized false + } + addPrivateMessageLocked( + peerID = peerID, + msg = msg, + forceRead = forceRead, + persistAsynchronously = false + ) + } + } + + private fun addPrivateMessageLocked( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean, + persistAsynchronously: Boolean + ): Boolean { + if (privateConversationWritesSuspended) return false + if (seenMessageIds.contains(msg.id)) return false + seenMessageIds.add(msg.id) + PrivateMessageArrivalOrder.record(msg.id) + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val map = _privateMessages.value.toMutableMap() + val list = (map[conversationID] ?: emptyList()) + msg + map[conversationID] = list + _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + + val isRead = forceRead || + msg.sender == "system" || + msg.sender == _nickname.value || + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(conversationID, ignoreCase = true) == true + if (isRead) { + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id + } else { + val counts = _unreadPrivateMessageCounts.value.toMutableMap() + counts[conversationID] = (counts[conversationID] ?: 0) + 1 + _unreadPrivateMessageCounts.value = counts + } + val aliases = privateConversationAliases(peerID, conversationID) + val displayName = ContactDirectory.resolve(conversationID).displayName + ?: msg.sender.takeUnless { + it.isBlank() || it == "system" || it == _nickname.value + } + displayName + ?.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } + ?.let { updateConversationDisplayNameLocked(conversationID, it) } + if (persistAsynchronously) { + conversationRepository?.upsertMessage( + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + message = msg, + isRead = isRead + ) + } + prunePrivateMessagesLocked(conversationID) + return true + } + + private fun privateMessagePersistence( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean + ): PendingPrivateMessagePersistence { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val existingMessages = _privateMessages.value[conversationID].orEmpty() + val isRead = forceRead || + msg.sender == "system" || + msg.sender == _nickname.value || + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(conversationID, ignoreCase = true) == true + val aliases = privateConversationAliases(peerID, conversationID) + val displayName = ContactDirectory.resolve(conversationID).displayName + ?: (existingMessages + msg) + .lastOrNull { candidate -> + candidate.sender.isNotBlank() && + candidate.sender != "system" && + candidate.sender != _nickname.value + } + ?.sender + return PendingPrivateMessagePersistence( + repository = conversationRepository, + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + isRead = isRead, + generation = privateConversationGeneration + ) + } + + /** + * Derive aliases from the routed conversation, never from the message author. + * + * For outgoing messages senderPeerID is our own ID, which is shared by every private chat. + * Persisting it as an alias would merge otherwise unrelated conversation histories. + */ + private fun privateConversationAliases( + peerID: String, + conversationID: String + ): Set = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.aliasesForConversation(conversationID) + }.getOrDefault(setOf(peerID, conversationID)) + + fun hasSeenMessage(messageID: String): Boolean = synchronized(this) { + messageID in seenMessageIds + } + + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 + } + + fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) { + synchronized(this) { + if (privateConversationWritesSuspended) return + val map = _privateMessages.value.toMutableMap() + var changed = false + map.keys.toList().forEach { peer -> + val list = map[peer]?.toMutableList() ?: mutableListOf() + val idx = list.indexOfFirst { it.id == messageID } + if (idx >= 0) { + val current = list[idx].deliveryStatus + // Do not downgrade (e.g., Read -> Delivered) + val mayReplace = when { + status is DeliveryStatus.Failed -> + current !is DeliveryStatus.Delivered && + current !is DeliveryStatus.Read + current is DeliveryStatus.Failed -> true + else -> statusPriority(status) >= statusPriority(current) + } + if (mayReplace) { + list[idx] = list[idx].copy(deliveryStatus = status) + map[peer] = list + changed = true + } + } + } + if (changed) { + _privateMessages.value = map + } + // Full histories are unloaded after a chat closes, so the message may only exist in + // SQLite. Always offer the update to the repository; it safely ignores unknown IDs + // and enforces the same monotonic status rules as the in-memory path. + conversationRepository?.updateDeliveryStatus(messageID, status) + } + } + + fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List) { + if (keysToMerge.isEmpty()) return + synchronized(this) { + if (privateConversationWritesSuspended) return + val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID) + val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID) + .flatMap { key -> + runCatching { + ContactDirectory.aliasesForConversation(key).toList() + }.getOrDefault(listOf(key)) + } + .toSet() + conversationRepository?.mergeAliases(targetConversationID, persistenceAliases) + val map = _privateMessages.value.toMutableMap() + val targetList = (map[targetConversationID] ?: emptyList()).toMutableList() + val targetIds = targetList.map { it.id }.toMutableSet() + var changed = false + + keysToMerge.distinct().forEach { key -> + val canonicalKey = ContactDirectory.canonicalConversationId(key) + if (canonicalKey == targetConversationID) { + val messages = map.remove(key) + if (messages != null) { + changed = true + messages.forEach { message -> + if (targetIds.add(message.id)) targetList.add(message) + } + } + return@forEach + } + if (key == targetConversationID) return@forEach + val messages = map.remove(key) ?: return@forEach + changed = true + messages.forEach { message -> + if (targetIds.add(message.id)) { + targetList.add(message) + } + } + } + + if (changed) { + if (targetList.isEmpty()) { + map.remove(targetConversationID) + } else { + map[targetConversationID] = targetList + } + _privateMessages.value = map + } + canonicalizePrivateConversationStateLocked() + } + } + + fun canonicalizePrivateChats() { + synchronized(this) { + canonicalizePrivateConversationStateLocked() + } + } + + /** + * Applies current peer announcements to retained conversations and persists the latest name + * independently of message history. A nickname change must not require another message to + * survive process death. + */ + fun updatePrivateConversationDisplayNames(peerNicknames: Map) { + if (peerNicknames.isEmpty()) return + synchronized(this) { + if (privateConversationWritesSuspended || _privateMessages.value.isEmpty()) return + canonicalizePrivateConversationStateLocked() + val conversationIDs = _privateMessages.value.keys + .mapTo(mutableSetOf()) { it.lowercase() } + peerNicknames.forEach { (peerID, nickname) -> + val usableName = nickname.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } ?: return@forEach + val canonicalID = ContactDirectory.canonicalConversationId(peerID) + if (canonicalID.lowercase() !in conversationIDs) { + return@forEach + } + updateConversationDisplayNameLocked(canonicalID, usableName) + val aliases = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.aliasesForConversation(canonicalID) + }.getOrDefault(setOf(peerID, canonicalID)) + conversationRepository?.updateConversationIdentity( + conversationID = canonicalID, + aliases = aliases, + displayName = usableName + ) + } + } + } + + fun markPrivateMessageRead(messageID: String) { + synchronized(this) { + if (privateConversationWritesSuspended) return + if (messageID in _readPrivateMessageIDs.value) return + canonicalizePrivateConversationStateLocked() + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID + val conversationID = _privateMessages.value.entries + .firstOrNull { (_, messages) -> messages.any { it.id == messageID } } + ?.key + if (conversationID != null) { + val counts = _unreadPrivateMessageCounts.value.toMutableMap() + val remaining = ((counts[conversationID] ?: 0) - 1).coerceAtLeast(0) + if (remaining == 0) counts.remove(conversationID) else { + counts[conversationID] = remaining + } + _unreadPrivateMessageCounts.value = counts + } + conversationRepository?.markRead(messageID) + } + } + + fun isPrivateMessageRead(messageID: String): Boolean = + messageID in _readPrivateMessageIDs.value + + suspend fun setPrivateConversationRead( + conversationID: String, + isRead: Boolean + ): Boolean { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val repository = conversationRepository ?: return false + val result = repository.setConversationReadAndWait(canonicalID, isRead) + if (!result.success) return false + synchronized(this) { + val messageIDs = _privateMessages.value + .filterKeys { key -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + .values + .flatten() + .mapTo(linkedSetOf(), BitchatMessage::id) + if (isRead) { + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageIDs + _unreadPrivateMessageCounts.value = + _unreadPrivateMessageCounts.value.filterKeys { key -> + !ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + } else { + result.affectedMessageID?.let { latestMessageID -> + _readPrivateMessageIDs.value = + _readPrivateMessageIDs.value - latestMessageID + _unreadPrivateMessageCounts.value = + _unreadPrivateMessageCounts.value + (canonicalID to 1) + } + } + } + return true + } + + fun deletePrivateConversation(peerOrConversationID: String): Set { + synchronized(this) { + val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) + val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + val aliases = (matchingKeys + peerOrConversationID + canonicalID) + .flatMap { key -> + runCatching { + ContactDirectory.aliasesForConversation(key).toList() + }.getOrDefault(listOf(key)) + } + .toSet() + val messageIDs = matchingKeys + .flatMapTo(linkedSetOf()) { _privateMessages.value[it].orEmpty().map { it.id } } + + // Queue the database deletion while holding the same lock used by addPrivateMessage. + // A genuinely new arrival is therefore queued after the delete and starts a fresh chat. + conversationRepository?.deleteConversation(canonicalID, aliases) + + val updated = _privateMessages.value.toMutableMap() + matchingKeys.forEach(updated::remove) + _privateMessages.value = updated + removeConversationDisplayNamesLocked(canonicalID) + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs + _unreadPrivateMessageCounts.value = + _unreadPrivateMessageCounts.value - matchingKeys - canonicalID + if ( + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(canonicalID, ignoreCase = true) == true + ) { + _selectedPrivateChatPeer.value = null + } + return messageIDs + } + } + + internal suspend fun deletePrivateConversationAndWait( + peerOrConversationID: String + ): DeletedPrivateConversation? { + loadPrivateConversationHistory(peerOrConversationID) + val deletion = synchronized(this) { + if (privateConversationWritesSuspended) return null + buildDeletedConversationLocked(peerOrConversationID) + } + val repository = conversationRepository ?: return null + if (!repository.deleteConversationAndWait(deletion.conversationID, deletion.aliases)) { + return null + } + synchronized(this) { + val updated = _privateMessages.value.toMutableMap() + updated.keys.toList().forEach { key -> + if ( + ContactDirectory.canonicalConversationId(key) + .equals(deletion.conversationID, ignoreCase = true) + ) { + val remaining = updated[key].orEmpty().filterNot { + it.id in deletion.messageIDs + } + if (remaining.isEmpty()) updated.remove(key) else updated[key] = remaining + } + } + _privateMessages.value = updated + removeConversationDisplayNamesLocked(deletion.conversationID) + _readPrivateMessageIDs.value = + _readPrivateMessageIDs.value - deletion.messageIDs + canonicalizePrivateConversationStateLocked() + val counts = _unreadPrivateMessageCounts.value.toMutableMap() + val currentCount = counts[deletion.conversationID] ?: 0 + val remainingUnread = (currentCount - deletion.unreadMessageCount).coerceAtLeast(0) + if (remainingUnread == 0) counts.remove(deletion.conversationID) else { + counts[deletion.conversationID] = remainingUnread + } + _unreadPrivateMessageCounts.value = counts + if ( + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(deletion.conversationID, ignoreCase = true) == true + ) { + _selectedPrivateChatPeer.value = null + } + } + return deletion + } + + internal suspend fun restoreDeletedConversation( + deletion: DeletedPrivateConversation + ): Boolean { + val repository = conversationRepository ?: return false + val restoredDisplayName = + ContactDirectory.resolve(deletion.conversationID).displayName + ?: deletion.displayName + if ( + !repository.restoreConversationAndWait( + conversationID = deletion.conversationID, + aliases = deletion.aliases, + displayName = restoredDisplayName, + messages = deletion.messages, + readMessageIDs = deletion.readMessageIDs + ) + ) { + return false + } + synchronized(this) { + seenMessageIds.removeAll(deletion.messageIDs) + deletion.messages.forEach { message -> + addPrivateMessageLocked( + peerID = deletion.conversationID, + msg = message, + forceRead = message.id in deletion.readMessageIDs, + persistAsynchronously = false + ) + } + restoredDisplayName?.let { + updateConversationDisplayNameLocked(deletion.conversationID, it) + } + } + return true + } + + private fun buildDeletedConversationLocked( + peerOrConversationID: String + ): DeletedPrivateConversation { + val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) + val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + val aliases = (matchingKeys + peerOrConversationID + canonicalID) + .flatMap { key -> + runCatching { + ContactDirectory.aliasesForConversation(key).toList() + }.getOrDefault(listOf(key)) + } + .toSet() + val messages = matchingKeys + .flatMap { _privateMessages.value[it].orEmpty() } + .distinctBy(BitchatMessage::id) + val messageIDs = messages.mapTo(linkedSetOf(), BitchatMessage::id) + val readIDs = _readPrivateMessageIDs.value.intersect(messageIDs) + return DeletedPrivateConversation( + conversationID = canonicalID, + aliases = aliases, + displayName = _privateConversationDisplayNames.value.entries + .firstOrNull { (key, _) -> + ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + ?.value + ?: ContactDirectory.resolve(canonicalID).displayName, + messages = messages, + readMessageIDs = readIDs, + unreadMessageCount = messages.count { message -> + message.id !in readIDs && + message.sender != "system" && + message.sender != _nickname.value + } + ) + } + + fun removePrivateMessage(messageID: String) { + synchronized(this) { + val updated = _privateMessages.value.toMutableMap() + var changed = false + updated.keys.toList().forEach { conversationID -> + val messages = updated[conversationID].orEmpty() + if (messages.any { it.id == messageID }) { + val remaining = messages.filterNot { it.id == messageID } + if (remaining.isEmpty()) { + updated.remove(conversationID) + } else { + updated[conversationID] = remaining + } + changed = true + } + } + if (!changed) return + conversationRepository?.deleteMessage(messageID) + _privateMessages.value = updated + val retainedConversationIDs = updated.keys + .mapTo(mutableSetOf()) { + ContactDirectory.canonicalConversationId(it).lowercase() + } + _privateConversationDisplayNames.value = + _privateConversationDisplayNames.value.filterKeys { + ContactDirectory.canonicalConversationId(it).lowercase() in + retainedConversationIDs + } + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID + } + } + + /** + * Atomically hides all conversations, rejects in-flight transport deliveries, then waits for + * every earlier database write and the panic wipe itself to finish. + */ + suspend fun panicClearPrivateConversations(): Boolean { + val repository = synchronized(this) { + privateConversationWritesSuspended = true + privateConversationGeneration += 1 + _privateMessages.value = emptyMap() + _readPrivateMessageIDs.value = emptySet() + _unreadPrivateMessageCounts.value = emptyMap() + _privateConversationDisplayNames.value = emptyMap() + _selectedPrivateChatPeer.value = null + conversationRepository + } + return repository?.clearAllAndWait() ?: true + } + + fun resumePrivateConversationsAfterPanic() { + synchronized(this) { + privateConversationWritesSuspended = false + } + } + + fun addChannelMessage(channel: String, msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + val map = _channelMessages.value.toMutableMap() + val list = (map[channel] ?: emptyList()) + msg + map[channel] = list + _channelMessages.value = map + } + } + + // Clear all in-memory state (used for full app shutdown) + fun clear() { + synchronized(this) { + seenMessageIds.clear() + reservedPrivateMessageIds.clear() + privateConversationGeneration += 1 + seenPublicMessageKeys.clear() + PrivateMessageArrivalOrder.clear() + privateWritesSinceGlobalPrune = 0 + peerIdsByTransport.clear() + directPeerIdsByTransport.clear() + _peers.value = emptyList() + _directPeers.value = emptySet() + _publicMessages.value = emptyList() + _privateMessages.value = emptyMap() + _readPrivateMessageIDs.value = emptySet() + _unreadPrivateMessageCounts.value = emptyMap() + _privateConversationDisplayNames.value = emptyMap() + _channelMessages.value = emptyMap() + _nickname.value = "" + _selectedPrivateChatPeer.value = null + } + } + + private fun publicMessageKey(msg: BitchatMessage): String { + val sender = msg.senderPeerID ?: msg.sender + return listOf( + sender, + msg.timestamp.time.toString(), + msg.type.name, + msg.channel ?: "", + msg.content + ).joinToString("\u001F") + } + + internal fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) { + synchronized(this) { + if (privateConversationWritesSuspended) return + val liveChats = _privateMessages.value + val liveMessageIDs = liveChats.values.flatten().map { it.id } + PrivateMessageArrivalOrder.restore( + snapshot.arrivalOrder, + liveMessageIDs, + snapshot.receivedAtByMessageID, + snapshot.arrivalSequenceByMessageID + ) + + val merged = linkedMapOf>() + snapshot.chats.forEach { (conversationID, messages) -> + merged.getOrPut(conversationID) { mutableListOf() }.addAll(messages) + } + liveChats.forEach { (conversationID, messages) -> + val target = merged.getOrPut(conversationID) { mutableListOf() } + messages + .filterNot { it.id in snapshot.deletedMessageIDs } + .forEach { live -> + val existingIndex = target.indexOfFirst { it.id == live.id } + if (existingIndex >= 0) { + target[existingIndex] = live + } else { + target.add(live) + } + } + } + + merged.values.flatten().forEach { seenMessageIds.add(it.id) } + seenMessageIds.addAll(snapshot.deletedMessageIDs) + _readPrivateMessageIDs.value = + (snapshot.readMessageIDs + _readPrivateMessageIDs.value) - + snapshot.deletedMessageIDs + val unreadCounts = _unreadPrivateMessageCounts.value.toMutableMap() + snapshot.unreadCounts.forEach { (conversationID, count) -> + if (count > 0) unreadCounts[conversationID] = count + else unreadCounts.remove(conversationID) + } + _unreadPrivateMessageCounts.value = unreadCounts + _privateConversationDisplayNames.value = + snapshot.displayNames + _privateConversationDisplayNames.value + _privateMessages.value = ContactDirectory.canonicalizePrivateChats( + merged.mapValues { (_, messages) -> + PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) + } + ) + canonicalizePrivateConversationStateLocked() + } + } + + private fun updateConversationDisplayNameLocked( + conversationID: String, + displayName: String + ) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val updated = _privateConversationDisplayNames.value + .filterKeys { key -> + !ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + .toMutableMap() + updated[canonicalID] = displayName + _privateConversationDisplayNames.value = updated + } + + private fun removeConversationDisplayNamesLocked(conversationID: String) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + _privateConversationDisplayNames.value = + _privateConversationDisplayNames.value.filterKeys { key -> + !ContactDirectory.canonicalConversationId(key) + .equals(canonicalID, ignoreCase = true) + } + } + + /** + * Identity mappings can become richer after a Noise handshake or favorite/Nostr update. + * Keep every process-wide projection on the same canonical key so unread/read/delete updates + * cannot leave a stale alias behind. + */ + private fun canonicalizePrivateConversationStateLocked() { + val canonicalChats = ContactDirectory.canonicalizePrivateChats(_privateMessages.value) + if (canonicalChats != _privateMessages.value) { + _privateMessages.value = canonicalChats + } + + val canonicalUnreadCounts = linkedMapOf() + _unreadPrivateMessageCounts.value.forEach { (conversationID, count) -> + if (count <= 0) return@forEach + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + canonicalUnreadCounts[canonicalID] = + (canonicalUnreadCounts[canonicalID] ?: 0) + count + } + if (canonicalUnreadCounts != _unreadPrivateMessageCounts.value) { + _unreadPrivateMessageCounts.value = canonicalUnreadCounts + } + + val canonicalDisplayNames = linkedMapOf() + _privateConversationDisplayNames.value.forEach { (conversationID, displayName) -> + if (displayName.isBlank()) return@forEach + canonicalDisplayNames[ + ContactDirectory.canonicalConversationId(conversationID) + ] = displayName + } + if (canonicalDisplayNames != _privateConversationDisplayNames.value) { + _privateConversationDisplayNames.value = canonicalDisplayNames + } + + _selectedPrivateChatPeer.value?.let { selected -> + val canonicalSelected = ContactDirectory.canonicalConversationId(selected) + if (canonicalSelected != selected) { + _selectedPrivateChatPeer.value = canonicalSelected + } + } + } + + private fun prunePrivateMessagesLocked(recentConversationID: String) { + val chats = _privateMessages.value.toMutableMap() + val removedIDs = linkedSetOf() + val recentKey = chats.keys.firstOrNull { key -> + ContactDirectory.canonicalConversationId(key) + .equals(recentConversationID, ignoreCase = true) + } + if (recentKey != null) { + val messages = chats[recentKey].orEmpty() + val excess = + messages.size - ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION + if (excess > 0) { + val removable = messages + .dropLast(1) + .sortedWith(privateMessagePruneComparator()) + .take(excess) + .mapTo(linkedSetOf()) { it.id } + removedIDs.addAll(removable) + chats[recentKey] = messages.filterNot { it.id in removable } + } + } + + privateWritesSinceGlobalPrune += 1 + if (privateWritesSinceGlobalPrune >= 64) { + privateWritesSinceGlobalPrune = 0 + var totalMessages = chats.values.sumOf { it.size } + var totalPayloadBytes = chats.values + .asSequence() + .flatten() + .sumOf(::privateMessagePayloadBytes) + if ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) { + val candidates = chats.values + .asSequence() + .flatMap { messages -> messages.dropLast(1).asSequence() } + .sortedWith(privateMessagePruneComparator()) + .iterator() + while ( + candidates.hasNext() && + ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) + ) { + val candidate = candidates.next() + if (!removedIDs.add(candidate.id)) continue + totalMessages -= 1 + totalPayloadBytes -= privateMessagePayloadBytes(candidate) + } + if ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) { + // Enforce the hard bound even when every conversation contains only its + // newest message. Read conversations still sort ahead of unread ones. + val latestCandidates = chats.values + .asSequence() + .flatten() + .filterNot { it.id in removedIDs } + .sortedWith(privateMessagePruneComparator()) + .iterator() + while ( + latestCandidates.hasNext() && + ( + totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL || + totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES + ) + ) { + val candidate = latestCandidates.next() + if (!removedIDs.add(candidate.id)) continue + totalMessages -= 1 + totalPayloadBytes -= privateMessagePayloadBytes(candidate) + } + } + if (removedIDs.isNotEmpty()) { + chats.keys.toList().forEach { key -> + chats[key] = chats[key].orEmpty().filterNot { + it.id in removedIDs + } + } + } + } + } + + if (removedIDs.isNotEmpty()) { + _privateMessages.value = chats.filterValues { it.isNotEmpty() } + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value - removedIDs + } + } + + private fun privateMessagePruneComparator(): Comparator = + compareByDescending { + it.id in _readPrivateMessageIDs.value + }.thenBy { + PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MAX_VALUE + } + + private fun privateMessagePayloadBytes(message: BitchatMessage): Long = + message.content.toByteArray(Charsets.UTF_8).size.toLong() + + (message.encryptedContent?.size ?: 0) + + message.mentions.orEmpty().sumOf { + it.toByteArray(Charsets.UTF_8).size + } +} + +private data class PendingPrivateMessagePersistence( + val repository: ConversationRepository?, + val conversationID: String, + val aliases: Set, + val displayName: String?, + val isRead: Boolean, + val generation: Long +) + +internal data class DeletedPrivateConversation( + val conversationID: String, + val aliases: Set, + val displayName: String?, + val messages: List, + val readMessageIDs: Set, + val unreadMessageCount: Int, + val wasPinned: Boolean = false, + val wasMuted: Boolean = false, + val draft: String? = null +) { + val messageIDs: Set = messages.mapTo(linkedSetOf(), BitchatMessage::id) +} + +private val EMPTY_CONVERSATION_STORE_STATE = + MutableStateFlow(ConversationStoreState.Ready).asStateFlow() diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt new file mode 100644 index 00000000..7c733958 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -0,0 +1,228 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.favorites.FavoriteRelationship +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.nostr.GeohashAliasRegistry + +object ContactDirectory { + data class ContactResolution( + val conversationID: String, + val meshPeerID: String?, + val noisePublicKey: ByteArray?, + val nostrPubkey: String?, + val displayName: String?, + val isMutualFavorite: Boolean + ) { + val noiseKeyHex: String? get() = noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) } + } + + @Volatile + private var appContext: Context? = null + + @Volatile + private var meshProvider: (() -> MeshService?)? = null + + @Volatile + internal var identityManagerProvider: (Context) -> SecureIdentityStateManager = + { SecureIdentityStateManager(it) } + + fun initialize(context: Context, meshProvider: () -> MeshService?) { + appContext = context.applicationContext + this.meshProvider = meshProvider + } + + fun isContactConversationID(value: String): Boolean = + ContactIdentityResolver.isContactConversationId(value) + + fun canonicalConversationId(peerOrConversationID: String): String { + val value = peerOrConversationID.trim() + if (ContactIdentityResolver.isContactConversationId(value)) return value.lowercase() + + noiseKeyForAlias(value)?.let { + return ContactIdentityResolver.contactConversationIdForNoiseKey(it) + } + + if (ContactIdentityResolver.isMeshPeerId(value)) { + favoriteForMeshPeerID(value)?.peerNoisePublicKey?.let { + return ContactIdentityResolver.contactConversationIdForNoiseKey(it) + } + } + + if (ContactIdentityResolver.isNostrAlias(value)) { + nostrPubkeyHexForAlias(value)?.let { pubHex -> + findFavoriteByNostrHex(pubHex)?.peerNoisePublicKey?.let { + return ContactIdentityResolver.contactConversationIdForNoiseKey(it) + } + } + } + + return value + } + + fun resolve(peerOrConversationID: String): ContactResolution { + val conversationID = canonicalConversationId(peerOrConversationID) + val contactFingerprint = ContactIdentityResolver.fingerprintFromContactConversationId(conversationID) + + val favorite = contactFingerprint?.let { findFavoriteByFingerprint(it) } + val noiseKey = when { + favorite != null -> favorite.peerNoisePublicKey + ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) -> + ContactIdentityResolver.bytesFromHex(peerOrConversationID) + else -> null + } + val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) } + ?: peerOrConversationID.takeIf { ContactIdentityResolver.isMeshPeerId(it) && isMeshPeerConnected(it) } + + return ContactResolution( + conversationID = conversationID, + meshPeerID = liveMeshPeerID, + noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey }, + nostrPubkey = favorite?.peerNostrPublicKey + ?: liveMeshPeerID?.let { + runCatching { + FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(it) + }.getOrNull() + }, + // A connected peer's current announcement is authoritative. Favorite and fingerprint + // records are offline fallbacks and can legitimately contain an older nickname. + displayName = liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + ?: favorite?.peerNickname?.takeIf { + it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) + } + ?: contactFingerprint?.let { cachedFingerprintNickname(it) }, + isMutualFavorite = favorite?.isMutual == true + ) + } + + fun canonicalizePrivateChats( + chats: Map> + ): Map> { + if (chats.isEmpty()) return chats + + val merged = linkedMapOf>() + chats.forEach { (key, messages) -> + val canonical = canonicalConversationId(key) + val list = merged.getOrPut(canonical) { mutableListOf() } + list.addAll(messages) + } + + return merged.mapValues { (_, messages) -> + // A private message's timestamp comes from the sender and is not a reliable ordering + // signal when peers' clocks differ. Use the local receipt sequence so interleaved + // alias lists can be merged back into their global arrival order. + PrivateMessageArrivalOrder.order(messages.distinctBy { it.id }) + } + } + + fun aliasesForConversation(peerOrConversationID: String): Set { + val resolution = resolve(peerOrConversationID) + val aliases = mutableSetOf() + aliases.add(peerOrConversationID) + aliases.add(resolution.conversationID) + resolution.meshPeerID?.let { aliases.add(it) } + resolution.noiseKeyHex?.let { aliases.add(it) } + resolution.nostrPubkey + ?.let { ContactIdentityResolver.nostrAliasForPubkey(it) } + ?.let { aliases.add(it) } + return aliases + } + + private fun noiseKeyForAlias(value: String): ByteArray? { + if (ContactIdentityResolver.isNoiseKeyHex(value)) { + return ContactIdentityResolver.bytesFromHex(value) + } + + if (ContactIdentityResolver.isMeshPeerId(value)) { + meshProvider?.invoke()?.getPeerInfo(value)?.noisePublicKey?.let { return it } + cachedNoiseKey(value)?.let { return it } + } + + return null + } + + private fun cachedNoiseKey(peerID: String): ByteArray? { + val context = appContext ?: return null + return try { + identityManagerProvider(context) + .getCachedNoiseKey(peerID) + ?.let { ContactIdentityResolver.bytesFromHex(it) } + } catch (_: Exception) { + null + } + } + + private fun cachedFingerprintNickname(fingerprint: String): String? { + val context = appContext ?: return null + return try { + identityManagerProvider(context) + .getCachedFingerprintNickname(fingerprint) + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } catch (_: Exception) { + null + } + } + + private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? = + try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + + private fun findFavoriteByFingerprint(fingerprint: String): FavoriteRelationship? = + try { + FavoritesPersistenceService.shared.getAllRelationships().firstOrNull { + ContactIdentityResolver.fingerprintHex(it.peerNoisePublicKey).equals(fingerprint, ignoreCase = true) + } + } catch (_: Exception) { + null + } + + private fun findFavoriteByNostrHex(pubHex: String): FavoriteRelationship? = + try { + FavoritesPersistenceService.shared.getAllRelationships().firstOrNull { + it.peerNostrPublicKey + ?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) } + ?.equals(pubHex, ignoreCase = true) == true + } + } catch (_: Exception) { + null + } + + private fun nostrPubkeyHexForAlias(alias: String): String? { + GeohashAliasRegistry.get(alias)?.let { return it.lowercase() } + val prefix = alias.removePrefix("nostr_").removePrefix("nostr:") + if (prefix.isBlank()) return null + return try { + FavoritesPersistenceService.shared.getAllRelationships() + .asSequence() + .mapNotNull { it.peerNostrPublicKey?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) } } + .firstOrNull { it.startsWith(prefix, ignoreCase = true) } + } catch (_: Exception) { + null + } + } + + private fun findLiveMeshPeerForFingerprint(fingerprint: String): String? { + val mesh = meshProvider?.invoke() ?: return null + return mesh.getPeerNicknames().keys.firstOrNull { peerID -> + val info = mesh.getPeerInfo(peerID) + val noiseKey = info?.noisePublicKey ?: cachedNoiseKey(peerID) + noiseKey != null && + ContactIdentityResolver.fingerprintHex(noiseKey).equals(fingerprint, ignoreCase = true) && + info?.isConnected == true + } + } + + private fun isMeshPeerConnected(peerID: String): Boolean = + try { + meshProvider?.invoke()?.getPeerInfo(peerID)?.isConnected == true + } catch (_: Exception) { + false + } +} diff --git a/app/src/main/java/com/bitchat/android/services/ContactIdentityResolver.kt b/app/src/main/java/com/bitchat/android/services/ContactIdentityResolver.kt new file mode 100644 index 00000000..1af66393 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ContactIdentityResolver.kt @@ -0,0 +1,83 @@ +package com.bitchat.android.services + +import com.bitchat.android.nostr.Bech32 +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.security.MessageDigest + +object ContactIdentityResolver { + private const val CONTACT_PREFIX = "contact_" + private val meshPeerIdRegex = Regex("^[0-9a-fA-F]{16}$") + private val noiseKeyRegex = Regex("^[0-9a-fA-F]{64}$") + private val fingerprintRegex = Regex("^[0-9a-fA-F]{64}$") + + fun isMeshPeerId(value: String): Boolean = meshPeerIdRegex.matches(value) + + fun isNoiseKeyHex(value: String): Boolean = noiseKeyRegex.matches(value) + + fun isNostrAlias(value: String): Boolean = + value.startsWith("nostr_") || value.startsWith("nostr:") + + fun noiseKeyHex(noisePublicKey: ByteArray): String = noisePublicKey.hexEncodedString() + + fun fingerprintHex(noisePublicKey: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(noisePublicKey) + return digest.hexEncodedString() + } + + fun contactConversationIdForNoiseKey(noisePublicKey: ByteArray): String = + CONTACT_PREFIX + fingerprintHex(noisePublicKey) + + fun contactConversationIdForFingerprint(fingerprint: String): String? = + fingerprint + .takeIf { fingerprintRegex.matches(it) } + ?.let { CONTACT_PREFIX + it.lowercase() } + + fun isContactConversationId(value: String): Boolean = + value.startsWith(CONTACT_PREFIX) && + fingerprintRegex.matches(value.removePrefix(CONTACT_PREFIX)) + + fun fingerprintFromContactConversationId(value: String): String? = + value + .takeIf { isContactConversationId(it) } + ?.removePrefix(CONTACT_PREFIX) + ?.lowercase() + + fun peerIdForNoiseKey(noisePublicKey: ByteArray): String = + fingerprintHex(noisePublicKey).take(16) + + fun peerIdForNoiseKeyHex(noiseKeyHex: String): String? = + bytesFromHex(noiseKeyHex) + ?.takeIf { it.size == 32 } + ?.let { peerIdForNoiseKey(it) } + + fun bytesFromHex(hex: String): ByteArray? { + val clean = hex.trim() + if (clean.length % 2 != 0) return null + if (!clean.matches(Regex("^[0-9a-fA-F]+$"))) return null + return clean.dataFromHexString() + } + + fun nostrPubkeyHex(value: String): String? { + val clean = value.trim() + if (clean.startsWith("npub1", ignoreCase = true)) { + return try { + val (hrp, data) = Bech32.decode(clean.lowercase()) + if (hrp == "npub" && data.size == 32) data.hexEncodedString() else null + } catch (_: Exception) { + null + } + } + return clean + .takeIf { noiseKeyRegex.matches(it) } + ?.lowercase() + } + + fun npubFromHex(hex: String): String? = + bytesFromHex(hex) + ?.takeIf { it.size == 32 } + ?.let { Bech32.encode("npub", it) } + + fun nostrAliasForPubkey(value: String): String? = + nostrPubkeyHex(value)?.let { "nostr_${it.take(16)}" } +} diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index 2c80d17e..96fa15b8 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -8,28 +8,25 @@ object ConversationAliasResolver { selectedPeerID: String, connectedPeers: List, meshNoiseKeyForPeer: (String) -> ByteArray?, - meshHasPeer: (String) -> Boolean, nostrPubHexForAlias: (String) -> String?, findNoiseKeyForNostr: (String) -> ByteArray? ): String { var peer = selectedPeerID try { - if (peer.startsWith("nostr_")) { + if (ContactIdentityResolver.isNostrAlias(peer)) { val pubHex = nostrPubHexForAlias(peer) if (pubHex != null) { val noiseKey = findNoiseKeyForNostr(pubHex) if (noiseKey != null) { - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } - // Prefer a connected mesh peer that matches this noise key + val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey) val meshPeer = connectedPeers.firstOrNull { pid -> meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true } peer = meshPeer ?: noiseHex } } - } else if (peer.length == 64 && peer.matches(Regex("^[0-9a-fA-F]+$"))) { - // Peer is full noise key hex: upgrade to active mesh peer if available - val noiseKey = peer.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } else if (ContactIdentityResolver.isNoiseKeyHex(peer)) { + val noiseKey = ContactIdentityResolver.bytesFromHex(peer) ?: return peer val meshPeer = connectedPeers.firstOrNull { pid -> meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true } @@ -38,7 +35,7 @@ object ConversationAliasResolver { } } } catch (_: Exception) { /* no-op */ } - return peer + return ContactDirectory.canonicalConversationId(peer) } fun unifyChatsIntoPeer( @@ -47,11 +44,17 @@ object ConversationAliasResolver { keysToMerge: List ) { if (keysToMerge.isEmpty()) return + val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID) + val mergeKeys = (keysToMerge + targetPeerID) + .flatMap { ContactDirectory.aliasesForConversation(it) } + .distinct() + AppStateStore.unifyPrivateChatsIntoPeer(targetConversationID, mergeKeys) + val currentChats = state.getPrivateChatsValue().toMutableMap() - val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf() + val targetList = currentChats[targetConversationID]?.toMutableList() ?: mutableListOf() var didMerge = false - keysToMerge.distinct().forEach { key -> - if (key == targetPeerID) return@forEach + mergeKeys.forEach { key -> + if (key == targetConversationID) return@forEach val list = currentChats[key] if (!list.isNullOrEmpty()) { targetList.addAll(list) @@ -60,23 +63,28 @@ object ConversationAliasResolver { } } if (didMerge) { - // Preserve arrival order; do not sort by timestamp - currentChats[targetPeerID] = targetList - state.setPrivateChats(currentChats) + currentChats[targetConversationID] = targetList + .distinctBy { it.id } + state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentChats)) // Move unread flags val unread = state.getUnreadPrivateMessagesValue().toMutableSet() var hadUnread = false - keysToMerge.forEach { key -> if (unread.remove(key)) hadUnread = true } - if (hadUnread) unread.add(targetPeerID) + mergeKeys.forEach { key -> if (unread.remove(key)) hadUnread = true } + if (hadUnread) unread.add(targetConversationID) state.setUnreadPrivateMessages(unread) // Switch selection if currently viewing an alias that got merged val selected = state.getSelectedPrivateChatPeerValue() - if (selected != null && keysToMerge.contains(selected)) { - state.setSelectedPrivateChatPeer(targetPeerID) + if (selected != null && mergeKeys.contains(selected)) { + state.setSelectedPrivateChatPeer(targetConversationID) + } + + // Switch sheet peer if currently viewing an alias that got merged + val sheetPeer = state.getPrivateChatSheetPeerValue() + if (sheetPeer != null && mergeKeys.contains(sheetPeer)) { + state.setPrivateChatSheetPeer(targetConversationID) } } } } - diff --git a/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt new file mode 100644 index 00000000..658fa9fc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt @@ -0,0 +1,194 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject + +/** + * Small encrypted, immediately observable preferences for conversation-list organization. + * + * Message history remains in SQLite; these compact sets and drafts belong in the app's existing + * Keystore-backed preference store. Panic clearing the identity store also removes these values. + */ +internal class ConversationListPreferences private constructor( + private val stateManager: SecureIdentityStateManager, + private val canonicalize: (String) -> String +) { + private constructor(context: Context) : this( + SecureIdentityStateManager(context.applicationContext), + ContactDirectory::canonicalConversationId + ) + + internal constructor( + stateManager: SecureIdentityStateManager, + testOnly: Boolean, + canonicalize: (String) -> String = ContactDirectory::canonicalConversationId + ) : this(stateManager, canonicalize) { + require(testOnly) { "Injected conversation preferences are test-only" } + } + + companion object { + private const val PINNED_KEY = "conversation_pinned_v1" + private const val MUTED_KEY = "conversation_muted_v1" + private const val DRAFTS_KEY = "conversation_drafts_v1" + private const val MAX_DRAFT_CHARS = 8_000 + private const val MAX_DRAFTS = 50 + private const val MAX_DRAFT_CHARS_TOTAL = 128_000 + + @Volatile + private var instance: ConversationListPreferences? = null + + fun getInstance(context: Context): ConversationListPreferences = + instance ?: synchronized(this) { + instance ?: ConversationListPreferences(context.applicationContext).also { + instance = it + } + } + } + + private val _pinned = MutableStateFlow(loadSet(PINNED_KEY)) + val pinned: StateFlow> = _pinned.asStateFlow() + private val _muted = MutableStateFlow(loadSet(MUTED_KEY)) + val muted: StateFlow> = _muted.asStateFlow() + private val _drafts = MutableStateFlow(loadDrafts()) + val drafts: StateFlow> = _drafts.asStateFlow() + + fun togglePinned(conversationID: String) { + _pinned.value = _pinned.value.toggle(normalize(conversationID)) + saveSet(PINNED_KEY, _pinned.value) + } + + fun toggleMuted(conversationID: String) { + _muted.value = _muted.value.toggle(normalize(conversationID)) + saveSet(MUTED_KEY, _muted.value) + } + + fun isMuted(conversationID: String): Boolean = + normalize(conversationID) in _muted.value + + fun isPinned(conversationID: String): Boolean = + normalize(conversationID) in _pinned.value + + fun draftFor(conversationID: String): String? = + _drafts.value[normalize(conversationID)] + + fun setDraft(conversationID: String, text: String) { + val key = normalize(conversationID) + val updated = _drafts.value.toMutableMap() + // Reinsert edited drafts at the end so bounded eviction approximates least-recently-used. + updated.remove(key) + val bounded = text.take(MAX_DRAFT_CHARS) + if (bounded.isNotBlank()) updated[key] = bounded + val retained = boundDrafts(updated) + _drafts.value = retained + saveDrafts(retained) + } + + fun removeConversation(conversationID: String) { + val key = normalize(conversationID) + _pinned.value = _pinned.value - key + _muted.value = _muted.value - key + _drafts.value = _drafts.value - key + saveSet(PINNED_KEY, _pinned.value) + saveSet(MUTED_KEY, _muted.value) + saveDrafts(_drafts.value) + } + + /** + * Re-key list preferences when a transient mesh ID becomes a stable contact identity. + * Without this, pin, mute, and draft state appears to disappear after a Noise/favorite update. + */ + fun canonicalizeAliases() { + val canonicalPinned = _pinned.value.mapTo(linkedSetOf(), ::normalize) + val canonicalMuted = _muted.value.mapTo(linkedSetOf(), ::normalize) + val canonicalDrafts = linkedMapOf() + _drafts.value.forEach { (conversationID, draft) -> + canonicalDrafts[normalize(conversationID)] = draft + } + + if (canonicalPinned != _pinned.value) { + _pinned.value = canonicalPinned + saveSet(PINNED_KEY, canonicalPinned) + } + if (canonicalMuted != _muted.value) { + _muted.value = canonicalMuted + saveSet(MUTED_KEY, canonicalMuted) + } + if (canonicalDrafts != _drafts.value) { + val bounded = boundDrafts(canonicalDrafts) + _drafts.value = bounded + saveDrafts(bounded) + } + } + + fun clearInMemory() { + _pinned.value = emptySet() + _muted.value = emptySet() + _drafts.value = emptyMap() + } + + fun clearAll(): Boolean { + val cleared = stateManager.clearSecureValuesSynchronously( + PINNED_KEY, + MUTED_KEY, + DRAFTS_KEY + ) + clearInMemory() + return cleared + } + + private fun loadSet(key: String): Set = runCatching { + val array = JSONArray(stateManager.getSecureValue(key) ?: return emptySet()) + buildSet { + for (index in 0 until array.length()) add(normalize(array.getString(index))) + } + }.getOrDefault(emptySet()) + + private fun saveSet(key: String, values: Set) { + stateManager.storeSecureValue(key, JSONArray(values.sorted()).toString()) + } + + private fun loadDrafts(): Map = runCatching { + val json = JSONObject(stateManager.getSecureValue(DRAFTS_KEY) ?: return emptyMap()) + val loaded = buildMap { + json.keys().forEach { key -> + json.optString(key).takeIf(String::isNotBlank)?.let { + put(normalize(key), it.take(MAX_DRAFT_CHARS)) + } + } + } + boundDrafts(loaded) + }.getOrDefault(emptyMap()) + + private fun saveDrafts(values: Map) { + stateManager.storeSecureValue( + DRAFTS_KEY, + JSONObject().apply { + values.forEach { (key, value) -> put(key, value) } + }.toString() + ) + } + + private fun Set.toggle(value: String): Set = + if (value in this) this - value else this + value + + private fun normalize(value: String): String = + canonicalize(value).lowercase() + + private fun boundDrafts(values: Map): Map { + val retained = LinkedHashMap(values) + while ( + retained.size > MAX_DRAFTS || + retained.values.sumOf(String::length) > MAX_DRAFT_CHARS_TOTAL + ) { + val oldest = retained.keys.firstOrNull() ?: break + retained.remove(oldest) + } + return retained + } + +} diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt new file mode 100644 index 00000000..bc474158 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -0,0 +1,1945 @@ +package com.bitchat.android.services + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.util.Base64 +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest +import java.util.Date +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Process-wide, serialized persistence for private conversations. + * + * Android's SQLite database is deliberately kept behind this small repository instead of leaking + * cursors or database threading into the mesh and UI layers. Every mutation is queued on one + * writer so a delete/merge followed by a newly arriving message is applied in the same order that + * [AppStateStore] publishes it. + */ +class ConversationRepository internal constructor( + context: Context, + private val dispatcher: CoroutineDispatcher = Executors + .newSingleThreadExecutor { runnable -> + Thread(runnable, "conversation-store").apply { isDaemon = true } + } + .asCoroutineDispatcher(), + databaseName: String = ConversationDatabase.DEFAULT_DATABASE_NAME, + storageCipher: ConversationStorageCipher = AndroidConversationStorageCipher() +) { + companion object { + private const val TAG = "ConversationRepository" + + @Volatile + private var instance: ConversationRepository? = null + + fun getInstance(context: Context): ConversationRepository = + instance ?: synchronized(this) { + instance ?: ConversationRepository(context.applicationContext).also { + instance = it + } + } + + fun tryGetInstance(): ConversationRepository? = instance + } + + private val applicationContext = context.applicationContext + private val database = ConversationDatabase( + context = applicationContext, + databaseName = databaseName, + storageCipher = storageCipher + ) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val initialized = AtomicBoolean(false) + private val _storeState = + MutableStateFlow(ConversationStoreState.Loading) + val storeState: StateFlow = _storeState.asStateFlow() + + internal fun initialize(onLoaded: (PersistedConversationSnapshot) -> Unit) { + if (!initialized.compareAndSet(false, true)) return + enqueueSnapshotLoad(pruneFirst = true, onLoaded = onLoaded) + } + + /** + * Re-reads persisted history even when this process already initialized the repository. + * + * This is required when the app's controlled shutdown cleared [AppStateStore], but Android + * reused the still-running process when the user immediately reopened the UI. + */ + internal fun reload(onLoaded: (PersistedConversationSnapshot) -> Unit) { + enqueueSnapshotLoad(pruneFirst = false, onLoaded = onLoaded) + } + + private fun enqueueSnapshotLoad( + pruneFirst: Boolean, + onLoaded: (PersistedConversationSnapshot) -> Unit + ) { + scope.launch { + try { + if (pruneFirst) { + deleteStoredMedia(database.pruneToRetentionLimits()) + } + onLoaded(database.loadInitialSnapshot()) + _storeState.value = ConversationStoreState.Ready + } catch (error: Exception) { + Log.e(TAG, "Unable to restore private conversations", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to restore conversations" + ) + } + } + } + + /** + * Suspends until all persistence work queued before this call has finished. + */ + suspend fun awaitPendingWrites() { + withContext(dispatcher) { Unit } + } + + internal suspend fun loadConversationAndWait( + conversationID: String + ): PersistedConversationSnapshot? = withContext(dispatcher) { + try { + database.loadConversation(conversationID) + } catch (error: Exception) { + Log.e(TAG, "Unable to load private conversation", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to load conversation" + ) + null + } + } + + fun upsertMessage( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ) { + scope.launch { + upsertMessageLocked(conversationID, aliases, displayName, message, isRead) + } + } + + suspend fun upsertMessageAndWait( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ): Boolean = withContext(dispatcher) { + upsertMessageLocked(conversationID, aliases, displayName, message, isRead) + } + + private fun upsertMessageLocked( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ): Boolean = try { + val result = database.upsertMessage( + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + message = message, + isRead = isRead + ) + deleteStoredMedia(result.orphanedMediaPaths) + _storeState.value = ConversationStoreState.Ready + result.inserted + } catch (error: Exception) { + Log.e(TAG, "Unable to persist private message", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to save conversation" + ) + false + } + + fun updateDeliveryStatus(messageID: String, status: DeliveryStatus) { + scope.launch { + try { + database.updateDeliveryStatus(messageID, status) + } catch (error: Exception) { + Log.e(TAG, "Unable to persist delivery status: ${error.message}") + } + } + } + + fun markRead(messageID: String) { + scope.launch { + try { + database.markRead(messageID) + } catch (error: Exception) { + Log.e(TAG, "Unable to persist local read state: ${error.message}") + } + } + } + + internal suspend fun setConversationReadAndWait( + conversationID: String, + isRead: Boolean + ): ConversationReadResult = withContext(dispatcher) { + try { + ConversationReadResult( + success = true, + affectedMessageID = database.setConversationRead(conversationID, isRead) + ) + } catch (error: Exception) { + Log.e(TAG, "Unable to update conversation read state", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to update conversation" + ) + ConversationReadResult(success = false) + } + } + + fun mergeAliases(targetConversationID: String, aliases: Set) { + scope.launch { + try { + database.mergeAliases(targetConversationID, aliases) + } catch (error: Exception) { + Log.e(TAG, "Unable to merge conversation aliases: ${error.message}") + } + } + } + + fun updateConversationIdentity( + conversationID: String, + aliases: Set, + displayName: String + ) { + scope.launch { + try { + database.updateConversationIdentity(conversationID, aliases, displayName) + } catch (error: Exception) { + Log.e(TAG, "Unable to persist conversation identity: ${error.message}") + } + } + } + + fun deleteConversation(conversationID: String, aliases: Set) { + scope.launch { + deleteConversationLocked(conversationID, aliases) + } + } + + suspend fun deleteConversationAndWait( + conversationID: String, + aliases: Set + ): Boolean = withContext(dispatcher) { + deleteConversationLocked(conversationID, aliases) + } + + suspend fun restoreConversationAndWait( + conversationID: String, + aliases: Set, + displayName: String?, + messages: List, + readMessageIDs: Set + ): Boolean = withContext(dispatcher) { + try { + deleteStoredMedia( + database.restoreConversation( + conversationID, + aliases, + displayName, + messages, + readMessageIDs + ) + ) + _storeState.value = ConversationStoreState.Ready + true + } catch (error: Exception) { + Log.e(TAG, "Unable to restore deleted conversation", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to restore conversation" + ) + false + } + } + + private fun deleteConversationLocked( + conversationID: String, + aliases: Set + ): Boolean = try { + deleteStoredMedia(database.deleteConversation(conversationID, aliases)) + _storeState.value = ConversationStoreState.Ready + true + } catch (error: Exception) { + Log.e(TAG, "Unable to delete private conversation", error) + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to delete conversation" + ) + false + } + + fun deleteMessage(messageID: String) { + scope.launch { + try { + deleteStoredMedia(database.deleteMessage(messageID)) + } catch (error: Exception) { + Log.e(TAG, "Unable to delete private message: ${error.message}") + } + } + } + + /** + * Drains earlier writes and completes the database wipe before returning. + * + * Panic mode uses this stronger variant so identity regeneration and transport restart cannot + * race an outstanding message insert or an unfinished conversation deletion. + */ + suspend fun clearAllAndWait(): Boolean = withContext(dispatcher) { + try { + database.clearAll() + _storeState.value = ConversationStoreState.Ready + true + } catch (error: Exception) { + Log.e(TAG, "Unable to synchronously clear private conversations", error) + database.destroyStorage() + _storeState.value = ConversationStoreState.Error( + error.message ?: "Unable to erase conversations" + ) + false + } + } + + private fun deleteStoredMedia(paths: Collection) { + if (paths.isEmpty()) return + com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths( + applicationContext, + paths + ) + } + + internal fun closeForTest() { + database.close() + } +} + +sealed interface ConversationStoreState { + data object Loading : ConversationStoreState + data object Ready : ConversationStoreState + data class Error(val message: String) : ConversationStoreState +} + +internal data class ConversationReadResult( + val success: Boolean, + val affectedMessageID: String? = null +) + +internal data class ConversationUpsertResult( + val inserted: Boolean, + val orphanedMediaPaths: Set +) + +internal data class PersistedConversationSnapshot( + val chats: Map>, + val readMessageIDs: Set, + val arrivalOrder: List, + val deletedMessageIDs: Set, + val displayNames: Map = emptyMap(), + val unreadCounts: Map = emptyMap(), + val receivedAtByMessageID: Map = emptyMap(), + val arrivalSequenceByMessageID: Map = emptyMap() +) + +/** + * Versioned SQLite schema for bounded private-message history. + * + * Conversation rows are intentionally tiny and remain until explicit deletion. Message rows are + * bounded by per-conversation, global-row, and payload-byte limits. Media bytes stay in the app's + * existing media storage; SQLite only stores the message metadata/path already present in content. + */ +internal class ConversationDatabase( + context: Context, + databaseName: String = DEFAULT_DATABASE_NAME, + private val maxMessagesPerConversation: Int = MAX_MESSAGES_PER_CONVERSATION, + private val maxMessagesTotal: Int = MAX_MESSAGES_TOTAL, + private val maxPayloadBytes: Long = MAX_PAYLOAD_BYTES, + private val maxMediaBytes: Long = MAX_MEDIA_BYTES, + private val storageCipher: ConversationStorageCipher = + AndroidConversationStorageCipher() +) : SQLiteOpenHelper(context, databaseName, null, DATABASE_VERSION) { + + companion object { + private const val TAG = "ConversationDatabase" + const val MAX_MESSAGES_PER_CONVERSATION = 1_000 + const val MAX_MESSAGES_TOTAL = 20_000 + const val MAX_PAYLOAD_BYTES = 32L * 1024L * 1024L + const val MAX_MEDIA_BYTES = 256L * 1024L * 1024L + + internal const val DEFAULT_DATABASE_NAME = "private_conversations.db" + internal const val DATABASE_VERSION = 4 + private const val PRUNE_INTERVAL = 64 + private const val PRUNE_BATCH_SIZE = 256 + } + + private val applicationContext = context.applicationContext + private var writesSinceGlobalPrune = 0 + + init { + setWriteAheadLoggingEnabled(true) + } + + override fun onConfigure(db: SQLiteDatabase) { + super.onConfigure(db) + db.setForeignKeyConstraintsEnabled(true) + db.execSQL("PRAGMA auto_vacuum = INCREMENTAL") + // Overwrite cells removed while migrating legacy plaintext payload columns. + db.rawQuery("PRAGMA secure_delete = ON", null).use { cursor -> + if (cursor.moveToFirst()) Unit + } + } + + override fun onOpen(db: SQLiteDatabase) { + super.onOpen(db) + // A v1 database may have written plaintext pages to its WAL before the encrypted migration. + // Truncating it after SQLite's upgrade transaction prevents those historical frames from + // lingering after the scrubbed rows are committed. + runCatching { + db.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { cursor -> + if (cursor.moveToFirst()) Unit + } + }.onFailure { error -> + Log.w(TAG, "Unable to truncate conversation WAL: ${error.message}") + } + } + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE conversations ( + conversation_id TEXT COLLATE NOCASE PRIMARY KEY NOT NULL, + display_name TEXT, + display_name_ciphertext BLOB, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE conversation_aliases ( + alias TEXT COLLATE NOCASE PRIMARY KEY NOT NULL, + conversation_id TEXT COLLATE NOCASE NOT NULL, + FOREIGN KEY(conversation_id) REFERENCES conversations(conversation_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE private_messages ( + arrival_sequence INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE NOT NULL, + conversation_id TEXT COLLATE NOCASE NOT NULL, + sender TEXT NOT NULL, + content TEXT NOT NULL, + message_type INTEGER NOT NULL, + sent_at INTEGER NOT NULL, + received_at INTEGER NOT NULL, + is_relay INTEGER NOT NULL, + original_sender TEXT, + is_private INTEGER NOT NULL, + recipient_nickname TEXT, + sender_peer_id TEXT, + mentions_json TEXT, + channel_name TEXT, + encrypted_content BLOB, + is_encrypted INTEGER NOT NULL, + delivery_type INTEGER NOT NULL, + delivery_text TEXT, + delivery_at INTEGER, + delivery_reached INTEGER, + delivery_total INTEGER, + sender_nostr_pubkey TEXT, + payload_ciphertext BLOB NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(conversation_id) REFERENCES conversations(conversation_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX idx_private_messages_conversation_arrival " + + "ON private_messages(conversation_id, arrival_sequence)" + ) + db.execSQL( + "CREATE INDEX idx_private_messages_read_arrival " + + "ON private_messages(is_read, arrival_sequence)" + ) + createAttachmentsTable(db) + db.execSQL( + "CREATE INDEX idx_conversation_aliases_conversation " + + "ON conversation_aliases(conversation_id)" + ) + db.execSQL( + """ + CREATE TABLE deleted_private_messages ( + message_id TEXT PRIMARY KEY NOT NULL, + deleted_at INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX idx_deleted_private_messages_time " + + "ON deleted_private_messages(deleted_at)" + ) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + var version = oldVersion + if (version == 1) { + migrateVersion1To2(db) + version = 2 + } + if (version == 2) { + db.execSQL( + "ALTER TABLE private_messages " + + "ADD COLUMN received_at INTEGER NOT NULL DEFAULT 0" + ) + db.execSQL( + "UPDATE private_messages SET received_at = sent_at WHERE received_at = 0" + ) + version = 3 + } + if (version == 3) { + createAttachmentsTable(db) + db.query( + "private_messages", + MESSAGE_COLUMNS, + null, + null, + null, + null, + null + ).use { cursor -> + while (cursor.moveToNext()) { + registerAttachmentLocked(db, cursor.toMessage()) + } + } + version = 4 + } + check(version == newVersion) { + "Missing conversation database migration from $version to $newVersion" + } + } + + private fun migrateVersion1To2(db: SQLiteDatabase) { + db.execSQL("ALTER TABLE conversations ADD COLUMN display_name_ciphertext BLOB") + db.execSQL("ALTER TABLE private_messages ADD COLUMN payload_ciphertext BLOB") + + db.query( + "conversations", + arrayOf("conversation_id", "display_name"), + "display_name IS NOT NULL AND display_name != ''", + null, + null, + null, + null + ).use { cursor -> + while (cursor.moveToNext()) { + val conversationID = cursor.getString(0) + val displayName = cursor.getString(1) + db.update( + "conversations", + ContentValues().apply { + put( + "display_name_ciphertext", + encryptText(displayName, conversationDisplayNameAad(conversationID)) + ) + putNull("display_name") + }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID) + ) + } + } + + db.query( + "private_messages", + LEGACY_MESSAGE_COLUMNS, + null, + null, + null, + null, + "arrival_sequence ASC" + ).use { cursor -> + while (cursor.moveToNext()) { + val message = cursor.toLegacyMessage() + db.update( + "private_messages", + ContentValues().apply { + put( + "payload_ciphertext", + encryptMessagePayload(message) + ) + scrubLegacyPayloadColumns(this) + }, + "message_id = ?", + arrayOf(message.id) + ) + } + } + } + + private fun createAttachmentsTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS message_attachments ( + message_id TEXT PRIMARY KEY NOT NULL, + path_hash BLOB NOT NULL, + path_ciphertext BLOB NOT NULL, + byte_size INTEGER NOT NULL, + FOREIGN KEY(message_id) REFERENCES private_messages(message_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS idx_message_attachments_path_hash " + + "ON message_attachments(path_hash)" + ) + } + + fun loadSnapshot(): PersistedConversationSnapshot { + return loadMessages( + selection = null, + selectionArgs = null, + orderBy = "arrival_sequence ASC", + displayNameConversationID = null + ) + } + + /** + * Loads one decrypted row per conversation for startup. Full histories are fetched only when + * the user opens a chat, keeping cold-start work proportional to conversation count rather + * than retained-message count. + */ + fun loadInitialSnapshot(): PersistedConversationSnapshot { + return loadMessages( + selection = """ + arrival_sequence IN ( + SELECT MAX(arrival_sequence) + FROM private_messages + GROUP BY conversation_id + ) + """.trimIndent(), + selectionArgs = null, + orderBy = "arrival_sequence ASC", + displayNameConversationID = null + ) + } + + fun loadConversation(conversationID: String): PersistedConversationSnapshot { + val resolved = resolveStoredConversationLocked(readableDatabase, conversationID) + return loadMessages( + selection = "conversation_id = ? COLLATE NOCASE", + selectionArgs = arrayOf(resolved), + orderBy = "arrival_sequence ASC", + displayNameConversationID = resolved + ) + } + + private fun loadMessages( + selection: String?, + selectionArgs: Array?, + orderBy: String, + displayNameConversationID: String? + ): PersistedConversationSnapshot { + val chats = linkedMapOf>() + val readIDs = linkedSetOf() + val arrivalOrder = mutableListOf() + val receivedAtByMessageID = linkedMapOf() + val arrivalSequenceByMessageID = linkedMapOf() + readableDatabase.query( + "private_messages", + MESSAGE_COLUMNS, + selection, + selectionArgs, + null, + null, + orderBy + ).use { cursor -> + while (cursor.moveToNext()) { + val conversationID = cursor.string("conversation_id") + val message = cursor.toMessage() + chats.getOrPut(conversationID) { mutableListOf() }.add(message) + arrivalOrder.add(message.id) + receivedAtByMessageID[message.id] = cursor.long("received_at") + arrivalSequenceByMessageID[message.id] = cursor.long("arrival_sequence") + if (cursor.boolean("is_read")) readIDs.add(message.id) + } + } + return PersistedConversationSnapshot( + chats = chats.mapValues { it.value.toList() }, + readMessageIDs = readIDs, + arrivalOrder = arrivalOrder, + deletedMessageIDs = loadDeletedMessageIDs(), + displayNames = loadConversationDisplayNames(displayNameConversationID), + unreadCounts = loadUnreadCounts(), + receivedAtByMessageID = receivedAtByMessageID, + arrivalSequenceByMessageID = arrivalSequenceByMessageID + ) + } + + private fun loadConversationDisplayNames( + conversationID: String? + ): Map { + val selection = conversationID?.let { "conversation_id = ? COLLATE NOCASE" } + val selectionArgs = conversationID?.let { arrayOf(it) } + return readableDatabase.query( + "conversations", + arrayOf("conversation_id", "display_name", "display_name_ciphertext"), + selection, + selectionArgs, + null, + null, + null + ).use { cursor -> + buildMap { + while (cursor.moveToNext()) { + val storedConversationID = cursor.string("conversation_id") + val encryptedColumn = + cursor.getColumnIndexOrThrow("display_name_ciphertext") + val encryptedName = if (cursor.isNull(encryptedColumn)) { + null + } else { + cursor.getBlob(encryptedColumn) + } + val displayName = encryptedName?.let { + storageCipher.decrypt( + it, + conversationDisplayNameAad(storedConversationID) + ).toString(Charsets.UTF_8) + } ?: cursor.nullableString("display_name") + displayName + ?.takeIf(String::isNotBlank) + ?.let { put(storedConversationID, it) } + } + } + } + } + + private fun loadDeletedMessageIDs(): Set { + readableDatabase.query( + "deleted_private_messages", + arrayOf("message_id"), + null, + null, + null, + null, + "deleted_at ASC" + ).use { cursor -> + return buildSet { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + } + + private fun loadUnreadCounts(): Map = + readableDatabase.rawQuery( + """ + SELECT conversation_id, COUNT(*) + FROM private_messages + WHERE is_read = 0 + GROUP BY conversation_id + """.trimIndent(), + null + ).use { cursor -> + buildMap { + while (cursor.moveToNext()) put(cursor.getString(0), cursor.getInt(1)) + } + } + + fun upsertMessage( + conversationID: String, + aliases: Set, + displayName: String?, + message: BitchatMessage, + isRead: Boolean + ): ConversationUpsertResult { + val normalizedID = conversationID.trim() + if (normalizedID.isBlank()) { + return ConversationUpsertResult(inserted = false, orphanedMediaPaths = emptySet()) + } + val now = System.currentTimeMillis() + val orphanedMediaPaths = linkedSetOf() + var messageInserted = false + writableDatabase.inTransaction { + if (isDeletedMessageLocked(this, message.id)) return@inTransaction + mergeAliasesLocked( + db = this, + targetConversationID = normalizedID, + aliases = aliases + normalizedID, + displayName = displayName, + now = now + ) + val values = message.toContentValues(normalizedID, isRead) + val inserted = insertWithOnConflict( + "private_messages", + null, + values, + SQLiteDatabase.CONFLICT_IGNORE + ) + messageInserted = inserted != -1L + if (inserted != -1L) { + registerAttachmentLocked(this, message) + } + if (inserted == -1L) { + val existingConversation = rawQuery( + "SELECT conversation_id, is_read FROM private_messages WHERE message_id = ?", + arrayOf(message.id) + ).use { cursor -> + if (cursor.moveToFirst()) { + cursor.string("conversation_id") to cursor.boolean("is_read") + } else { + null + } + } + if (existingConversation != null) { + val canonicalExisting = resolveStoredConversationLocked( + this, + existingConversation.first + ) + if (!canonicalExisting.equals(normalizedID, ignoreCase = true)) { + mergeAliasesLocked( + db = this, + targetConversationID = normalizedID, + aliases = aliases + canonicalExisting, + displayName = displayName, + now = now + ) + } + if (isRead && !existingConversation.second) { + update( + "private_messages", + ContentValues().apply { put("is_read", 1) }, + "message_id = ?", + arrayOf(message.id) + ) + } + } + } + updateConversationMetadataLocked(this, normalizedID, displayName, now) + orphanedMediaPaths += pruneConversationLocked(this, normalizedID) + } + + writesSinceGlobalPrune += 1 + if (writesSinceGlobalPrune >= PRUNE_INTERVAL) { + writesSinceGlobalPrune = 0 + orphanedMediaPaths += pruneToRetentionLimits() + } + return ConversationUpsertResult( + inserted = messageInserted, + orphanedMediaPaths = orphanedMediaPaths + ) + } + + fun updateDeliveryStatus(messageID: String, status: DeliveryStatus) { + val db = writableDatabase + var found = false + val existing = db.rawQuery( + """ + SELECT ${MESSAGE_COLUMNS.joinToString(",")} + FROM private_messages WHERE message_id = ? + """.trimIndent(), + arrayOf(messageID) + ).use { cursor -> + if (cursor.moveToFirst()) { + found = true + cursor.toMessage().deliveryStatus + } else { + null + } + } + + if (!found) return + val mayReplace = when { + status is DeliveryStatus.Failed -> + existing !is DeliveryStatus.Delivered && existing !is DeliveryStatus.Read + existing is DeliveryStatus.Failed -> true + else -> statusPriority(status) >= statusPriority(existing) + } + if (!mayReplace) return + db.inTransaction { + val current = rawQuery( + "SELECT ${MESSAGE_COLUMNS.joinToString(",")} " + + "FROM private_messages WHERE message_id = ?", + arrayOf(messageID) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.toMessage() else null + } ?: return@inTransaction + update( + "private_messages", + ContentValues().apply { + putAll(deliveryValues(status, includeSensitiveText = false)) + put( + "payload_ciphertext", + encryptMessagePayload(current.copy(deliveryStatus = status)) + ) + }, + "message_id = ?", + arrayOf(messageID) + ) + } + } + + fun markRead(messageID: String) { + writableDatabase.update( + "private_messages", + ContentValues().apply { put("is_read", 1) }, + "message_id = ?", + arrayOf(messageID) + ) + } + + fun setConversationRead(conversationID: String, isRead: Boolean): String? { + val resolved = resolveStoredConversationLocked(writableDatabase, conversationID) + return writableDatabase.inTransaction { + if (isRead) { + update( + "private_messages", + ContentValues().apply { put("is_read", 1) }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(resolved) + ) + null + } else { + val latestMessageID = rawQuery( + """ + SELECT message_id + FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + ORDER BY arrival_sequence DESC + LIMIT 1 + """.trimIndent(), + arrayOf(resolved) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + if (latestMessageID != null) { + update( + "private_messages", + ContentValues().apply { put("is_read", 0) }, + "message_id = ?", + arrayOf(latestMessageID) + ) + } + latestMessageID + } + } + } + + fun mergeAliases(targetConversationID: String, aliases: Set) { + if (targetConversationID.isBlank()) return + writableDatabase.inTransaction { + mergeAliasesLocked( + db = this, + targetConversationID = targetConversationID, + aliases = aliases + targetConversationID, + displayName = null, + now = System.currentTimeMillis() + ) + } + } + + fun updateConversationIdentity( + conversationID: String, + aliases: Set, + displayName: String + ) { + if (conversationID.isBlank() || displayName.isBlank()) return + writableDatabase.inTransaction { + mergeAliasesLocked( + db = this, + targetConversationID = conversationID, + aliases = aliases + conversationID, + displayName = displayName, + now = System.currentTimeMillis() + ) + } + } + + fun deleteConversation(conversationID: String, aliases: Set): Set = + writableDatabase.inTransaction { + val ids = linkedSetOf() + (aliases + conversationID).forEach { value -> + ids.add(resolveStoredConversationLocked(this, value)) + ids.add(value) + } + ids.filter { it.isNotBlank() }.forEach { id -> + execSQL( + """ + INSERT OR REPLACE INTO deleted_private_messages(message_id, deleted_at) + SELECT message_id, ? FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + """.trimIndent(), + arrayOf(System.currentTimeMillis(), id) + ) + } + val messageIDs = ids + .filter(String::isNotBlank) + .flatMapTo(linkedSetOf()) { id -> + queryMessageIDsLocked(this, id) + } + val attachmentCandidates = attachmentCandidatesLocked(this, messageIDs) + ids.filter { it.isNotBlank() }.forEach { id -> + delete( + "conversations", + "conversation_id = ? COLLATE NOCASE", + arrayOf(id) + ) + } + pruneDeletedMessageIDsLocked(this) + unreferencedAttachmentPathsLocked(this, attachmentCandidates) + } + + fun deleteMessage(messageID: String): Set = + writableDatabase.inTransaction { + val attachmentCandidates = attachmentCandidatesLocked(this, listOf(messageID)) + insertWithOnConflict( + "deleted_private_messages", + null, + ContentValues().apply { + put("message_id", messageID) + put("deleted_at", System.currentTimeMillis()) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + delete("private_messages", "message_id = ?", arrayOf(messageID)) + delete( + "conversations", + """ + NOT EXISTS ( + SELECT 1 FROM private_messages + WHERE private_messages.conversation_id = conversations.conversation_id + ) + """.trimIndent(), + null + ) + pruneDeletedMessageIDsLocked(this) + unreferencedAttachmentPathsLocked(this, attachmentCandidates) + } + + fun restoreConversation( + conversationID: String, + aliases: Set, + displayName: String?, + messages: List, + readMessageIDs: Set + ): Set { + if (messages.isEmpty()) return emptySet() + val now = System.currentTimeMillis() + writableDatabase.inTransaction { + mergeAliasesLocked( + db = this, + targetConversationID = conversationID, + aliases = aliases + conversationID, + displayName = displayName, + now = now + ) + messages.forEach { message -> + delete( + "deleted_private_messages", + "message_id = ?", + arrayOf(message.id) + ) + val inserted = insertWithOnConflict( + "private_messages", + null, + message.toContentValues( + conversationID = conversationID, + isRead = message.id in readMessageIDs + ), + SQLiteDatabase.CONFLICT_IGNORE + ) + if (inserted != -1L) registerAttachmentLocked(this, message) + } + updateConversationMetadataLocked(this, conversationID, displayName, now) + } + return pruneToRetentionLimits() + } + + fun clearAll() { + // Destroy the only usable copy of the history key before attempting filesystem cleanup. + storageCipher.destroyKey() + writableDatabase.inTransaction { + delete("conversation_aliases", null, null) + delete("message_attachments", null, null) + delete("private_messages", null, null) + delete("conversations", null, null) + delete("deleted_private_messages", null, null) + execSQL("DELETE FROM sqlite_sequence WHERE name = 'private_messages'") + } + writableDatabase.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { } + writableDatabase.rawQuery("PRAGMA incremental_vacuum", null).use { } + } + + /** + * Last-resort panic cleanup when [clearAll] throws: drop the encrypted database + * files so a later reload cannot resurrect erased conversations (#699). + */ + fun destroyStorage() { + try { + clearAll() + } catch (error: Exception) { + Log.e(TAG, "clearAll failed during destroyStorage; deleting database files", error) + } + try { + close() + } catch (_: Exception) { } + applicationContext.deleteDatabase(databaseName) + } + + fun pruneToRetentionLimits(): Set { + val db = writableDatabase + val orphanedMediaPaths = linkedSetOf() + db.inTransaction { + rawQuery( + "SELECT conversation_id FROM conversations", + null + ).use { cursor -> + while (cursor.moveToNext()) { + orphanedMediaPaths += pruneConversationLocked(this, cursor.getString(0)) + } + } + + var stats = storageStatsLocked(this) + while ( + stats.messageCount > maxMessagesTotal || + stats.payloadBytes > maxPayloadBytes || + stats.mediaBytes > maxMediaBytes + ) { + val candidateLimit = if (stats.messageCount > maxMessagesTotal) { + (stats.messageCount - maxMessagesTotal) + .coerceAtMost(PRUNE_BATCH_SIZE.toLong()) + .toInt() + } else { + // Payload sizes vary, so recalculate after each removal instead of + // discarding an entire batch unnecessarily. + 1 + } + val candidates = pruneCandidatesLocked( + db = this, + readOnly = true, + preserveLatest = true, + limit = candidateLimit + ).ifEmpty { + pruneCandidatesLocked( + db = this, + readOnly = false, + preserveLatest = true, + limit = candidateLimit + ) + }.ifEmpty { + // A store with one message in every conversation still needs a hard bound. + // Prefer retiring read conversations before unread ones. + pruneCandidatesLocked( + db = this, + readOnly = true, + preserveLatest = false, + limit = candidateLimit + ) + }.ifEmpty { + pruneCandidatesLocked( + db = this, + readOnly = false, + preserveLatest = false, + limit = candidateLimit + ) + } + if (candidates.isEmpty()) break + tombstoneMessagesLocked(this, candidates) + val attachmentCandidates = attachmentCandidatesLocked(this, candidates) + candidates.forEach { messageID -> + delete("private_messages", "message_id = ?", arrayOf(messageID)) + } + orphanedMediaPaths += + unreferencedAttachmentPathsLocked(this, attachmentCandidates) + deleteEmptyConversationsLocked(this) + pruneDeletedMessageIDsLocked(this) + stats = storageStatsLocked(this) + } + } + db.rawQuery("PRAGMA incremental_vacuum(128)", null).use { } + return orphanedMediaPaths + } + + private fun mergeAliasesLocked( + db: SQLiteDatabase, + targetConversationID: String, + aliases: Set, + displayName: String?, + now: Long + ) { + val normalizedAliases = aliases + .map(String::trim) + .filter(String::isNotBlank) + .toSet() + val sourceIDs = normalizedAliases + .mapTo(linkedSetOf()) { resolveStoredConversationLocked(db, it) } + .plus( + db.queryValues( + table = "conversation_aliases", + resultColumn = "conversation_id", + selectionValues = normalizedAliases + ) + ) + val effectiveDisplayName = displayName + ?: (listOf(targetConversationID) + sourceIDs + normalizedAliases) + .asSequence() + .mapNotNull { storedConversationDisplayNameLocked(db, it) } + .firstOrNull() + ensureConversationLocked(db, targetConversationID, effectiveDisplayName, now) + + sourceIDs + .filterNot { it.equals(targetConversationID, ignoreCase = true) } + .forEach { sourceID -> + db.update( + "private_messages", + ContentValues().apply { put("conversation_id", targetConversationID) }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(sourceID) + ) + db.update( + "conversation_aliases", + ContentValues().apply { put("conversation_id", targetConversationID) }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(sourceID) + ) + db.delete( + "conversations", + "conversation_id = ? COLLATE NOCASE", + arrayOf(sourceID) + ) + } + + (normalizedAliases + sourceIDs + targetConversationID).forEach { alias -> + db.insertWithOnConflict( + "conversation_aliases", + null, + ContentValues().apply { + put("alias", alias) + put("conversation_id", targetConversationID) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + updateConversationMetadataLocked( + db, + targetConversationID, + effectiveDisplayName, + now + ) + } + + private fun storedConversationDisplayNameLocked( + db: SQLiteDatabase, + conversationID: String + ): String? = db.query( + "conversations", + arrayOf("conversation_id", "display_name", "display_name_ciphertext"), + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID), + null, + null, + null, + "1" + ).use { cursor -> + if (!cursor.moveToFirst()) return@use null + val storedConversationID = cursor.string("conversation_id") + cursor.blobOrNull("display_name_ciphertext")?.let { + storageCipher.decrypt( + it, + conversationDisplayNameAad(storedConversationID) + ).toString(Charsets.UTF_8) + } ?: cursor.nullableString("display_name") + } + + private fun ensureConversationLocked( + db: SQLiteDatabase, + conversationID: String, + displayName: String?, + now: Long + ) { + db.insertWithOnConflict( + "conversations", + null, + ContentValues().apply { + put("conversation_id", conversationID) + putNull("display_name") + if (!displayName.isNullOrBlank()) { + put( + "display_name_ciphertext", + encryptText(displayName, conversationDisplayNameAad(conversationID)) + ) + } + put("created_at", now) + put("updated_at", now) + }, + SQLiteDatabase.CONFLICT_IGNORE + ) + } + + private fun updateConversationMetadataLocked( + db: SQLiteDatabase, + conversationID: String, + displayName: String?, + now: Long + ) { + db.update( + "conversations", + ContentValues().apply { + if (!displayName.isNullOrBlank()) { + putNull("display_name") + put( + "display_name_ciphertext", + encryptText(displayName, conversationDisplayNameAad(conversationID)) + ) + } + put("updated_at", now) + }, + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID) + ) + } + + private fun resolveStoredConversationLocked(db: SQLiteDatabase, value: String): String { + if (value.isBlank()) return value + return db.rawQuery( + "SELECT conversation_id FROM conversation_aliases WHERE alias = ? COLLATE NOCASE", + arrayOf(value) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else value + } + } + + private fun isDeletedMessageLocked(db: SQLiteDatabase, messageID: String): Boolean = + db.longForQuery( + "SELECT COUNT(*) FROM deleted_private_messages WHERE message_id = ?", + arrayOf(messageID) + ) > 0L + + private fun pruneDeletedMessageIDsLocked(db: SQLiteDatabase) { + val excess = ( + db.longForQuery( + "SELECT COUNT(*) FROM deleted_private_messages", + emptyArray() + ) - maxMessagesTotal + ).coerceAtLeast(0L) + if (excess == 0L) return + db.execSQL( + """ + DELETE FROM deleted_private_messages + WHERE message_id IN ( + SELECT message_id FROM deleted_private_messages + ORDER BY deleted_at ASC + LIMIT ? + ) + """.trimIndent(), + arrayOf(excess) + ) + } + + private fun pruneConversationLocked( + db: SQLiteDatabase, + conversationID: String + ): Set { + val count = db.longForQuery( + "SELECT COUNT(*) FROM private_messages WHERE conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID) + ) + val excess = (count - maxMessagesPerConversation).coerceAtLeast(0L) + if (excess == 0L) return emptySet() + return db.rawQuery( + """ + SELECT message_id + FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + AND arrival_sequence < ( + SELECT MAX(arrival_sequence) + FROM private_messages + WHERE conversation_id = ? COLLATE NOCASE + ) + ORDER BY is_read DESC, arrival_sequence ASC + LIMIT ? + """.trimIndent(), + arrayOf(conversationID, conversationID, excess.toString()) + ).use { cursor -> + val ids = mutableListOf() + while (cursor.moveToNext()) ids.add(cursor.getString(0)) + val attachmentCandidates = attachmentCandidatesLocked(db, ids) + tombstoneMessagesLocked(db, ids) + ids.forEach { db.delete("private_messages", "message_id = ?", arrayOf(it)) } + pruneDeletedMessageIDsLocked(db) + unreferencedAttachmentPathsLocked(db, attachmentCandidates) + } + } + + private fun storageStatsLocked(db: SQLiteDatabase): ConversationStorageStats = + db.rawQuery( + """ + SELECT COUNT(*), + COALESCE(SUM( + COALESCE(length(payload_ciphertext), 0) + ), 0), + COALESCE(( + SELECT SUM(unique_attachment.byte_size) + FROM ( + SELECT MAX(byte_size) AS byte_size + FROM message_attachments + GROUP BY path_hash + ) AS unique_attachment + ), 0) + FROM private_messages + """.trimIndent(), + null + ).use { cursor -> + cursor.moveToFirst() + ConversationStorageStats( + messageCount = cursor.getLong(0), + payloadBytes = cursor.getLong(1), + mediaBytes = cursor.getLong(2) + ) + } + + private fun registerAttachmentLocked(db: SQLiteDatabase, message: BitchatMessage) { + if (message.type !in MEDIA_MESSAGE_TYPES) return + val path = appOwnedCanonicalPath(message.content) ?: return + val pathBytes = path.toByteArray(Charsets.UTF_8) + db.insertWithOnConflict( + "message_attachments", + null, + ContentValues().apply { + put("message_id", message.id) + put("path_hash", MessageDigest.getInstance("SHA-256").digest(pathBytes)) + put( + "path_ciphertext", + storageCipher.encrypt(pathBytes, attachmentPathAad(message.id)) + ) + put("byte_size", File(path).takeIf(File::isFile)?.length() ?: 0L) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + + private fun queryMessageIDsLocked( + db: SQLiteDatabase, + conversationID: String + ): List = + db.query( + "private_messages", + arrayOf("message_id"), + "conversation_id = ? COLLATE NOCASE", + arrayOf(conversationID), + null, + null, + null + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + + private fun attachmentCandidatesLocked( + db: SQLiteDatabase, + messageIDs: Collection + ): List { + if (messageIDs.isEmpty()) return emptyList() + return buildList { + messageIDs.toList().chunked(400).forEach { chunk -> + val placeholders = chunk.joinToString(",") { "?" } + db.rawQuery( + """ + SELECT message_id, path_hash, path_ciphertext + FROM message_attachments + WHERE message_id IN ($placeholders) + """.trimIndent(), + chunk.toTypedArray() + ).use { cursor -> + while (cursor.moveToNext()) { + val messageID = cursor.getString(0) + val path = storageCipher.decrypt( + cursor.getBlob(2), + attachmentPathAad(messageID) + ).toString(Charsets.UTF_8) + add( + AttachmentCandidate( + pathHash = cursor.getBlob(1), + canonicalPath = path + ) + ) + } + } + } + } + } + + private fun unreferencedAttachmentPathsLocked( + db: SQLiteDatabase, + candidates: Collection + ): Set = candidates + .filter { candidate -> + db.rawQuery( + "SELECT COUNT(*) FROM message_attachments WHERE hex(path_hash) = ?", + arrayOf(candidate.pathHash.toHex().uppercase()) + ).use { cursor -> + cursor.moveToFirst() + cursor.getLong(0) == 0L + } + } + .mapTo(linkedSetOf(), AttachmentCandidate::canonicalPath) + + private fun appOwnedCanonicalPath(value: String): String? { + val file = runCatching { File(value.trim()).canonicalFile }.getOrNull() ?: return null + val roots = listOf(applicationContext.filesDir, applicationContext.cacheDir) + .mapNotNull { runCatching { it.canonicalFile }.getOrNull() } + return file.path.takeIf { path -> + roots.any { root -> + path == root.path || path.startsWith(root.path + File.separator) + } + } + } + + private fun attachmentPathAad(messageID: String): ByteArray = + "attachment:$messageID:path".toByteArray(Charsets.UTF_8) + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private data class ConversationStorageStats( + val messageCount: Long, + val payloadBytes: Long, + val mediaBytes: Long + ) + + private data class AttachmentCandidate( + val pathHash: ByteArray, + val canonicalPath: String + ) + + private val MEDIA_MESSAGE_TYPES = setOf( + BitchatMessageType.Audio, + BitchatMessageType.Image, + BitchatMessageType.File + ) + + private fun pruneCandidatesLocked( + db: SQLiteDatabase, + readOnly: Boolean, + preserveLatest: Boolean, + limit: Int + ): List { + val readClause = if (readOnly) "AND candidate.is_read = 1" else "" + val newerMessageClause = if (preserveLatest) { + """ + AND EXISTS ( + SELECT 1 + FROM private_messages AS newer + WHERE newer.conversation_id = candidate.conversation_id + AND newer.arrival_sequence > candidate.arrival_sequence + ) + """.trimIndent() + } else { + "" + } + return db.rawQuery( + """ + SELECT candidate.message_id + FROM private_messages AS candidate + WHERE 1 = 1 + $newerMessageClause + $readClause + ORDER BY candidate.arrival_sequence ASC + LIMIT $limit + """.trimIndent(), + null + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + } + + private fun deleteEmptyConversationsLocked(db: SQLiteDatabase) { + db.delete( + "conversations", + """ + NOT EXISTS ( + SELECT 1 FROM private_messages + WHERE private_messages.conversation_id = conversations.conversation_id + ) + """.trimIndent(), + null + ) + } + + private fun tombstoneMessagesLocked( + db: SQLiteDatabase, + messageIDs: Collection + ) { + val deletedAt = System.currentTimeMillis() + messageIDs.forEach { messageID -> + db.insertWithOnConflict( + "deleted_private_messages", + null, + ContentValues().apply { + put("message_id", messageID) + put("deleted_at", deletedAt) + }, + SQLiteDatabase.CONFLICT_REPLACE + ) + } + } + + private fun SQLiteDatabase.queryValues( + table: String, + resultColumn: String, + selectionValues: Set + ): Set { + if (selectionValues.isEmpty()) return emptySet() + val placeholders = selectionValues.joinToString(",") { "?" } + return rawQuery( + "SELECT $resultColumn FROM $table WHERE alias IN ($placeholders)", + selectionValues.toTypedArray() + ).use { cursor -> + buildSet { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + } + + private fun SQLiteDatabase.longForQuery(sql: String, args: Array): Long = + rawQuery(sql, args).use { cursor -> + cursor.moveToFirst() + cursor.getLong(0) + } + + private inline fun SQLiteDatabase.inTransaction(block: SQLiteDatabase.() -> T): T { + beginTransaction() + return try { + val result = block() + setTransactionSuccessful() + result + } finally { + endTransaction() + } + } + + private fun BitchatMessage.toContentValues( + conversationID: String, + isRead: Boolean + ): ContentValues = ContentValues().apply { + put("message_id", id) + put("conversation_id", conversationID) + // Version 1 columns remain for lossless migration compatibility, but sensitive values are + // scrubbed and only the authenticated encrypted payload is populated for new writes. + put("sender", "") + put("content", "") + put("message_type", type.ordinal) + put("sent_at", timestamp.time) + put("received_at", System.currentTimeMillis()) + put("is_relay", isRelay.asInt()) + putNull("original_sender") + put("is_private", isPrivate.asInt()) + putNull("recipient_nickname") + putNull("sender_peer_id") + putNull("mentions_json") + putNull("channel_name") + putNull("encrypted_content") + put("is_encrypted", isEncrypted.asInt()) + putAll(deliveryValues(deliveryStatus, includeSensitiveText = false)) + putNull("sender_nostr_pubkey") + put("payload_ciphertext", encryptMessagePayload(this@toContentValues)) + put("is_read", isRead.asInt()) + } + + private fun deliveryValues( + status: DeliveryStatus?, + includeSensitiveText: Boolean = true + ): ContentValues = ContentValues().apply { + when (status) { + null -> put("delivery_type", 0) + DeliveryStatus.Sending -> put("delivery_type", 1) + DeliveryStatus.Sent -> put("delivery_type", 2) + is DeliveryStatus.Delivered -> { + put("delivery_type", 3) + if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text") + put("delivery_at", status.at.time) + } + is DeliveryStatus.Read -> { + put("delivery_type", 4) + if (includeSensitiveText) put("delivery_text", status.by) else putNull("delivery_text") + put("delivery_at", status.at.time) + } + is DeliveryStatus.Failed -> { + put("delivery_type", 5) + if (includeSensitiveText) put("delivery_text", status.reason) else putNull("delivery_text") + } + is DeliveryStatus.PartiallyDelivered -> { + put("delivery_type", 6) + put("delivery_reached", status.reached) + put("delivery_total", status.total) + } + } + } + + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Failed -> 0 + DeliveryStatus.Sending -> 1 + DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + } + + private fun Cursor.toMessage(): BitchatMessage { + val messageID = string("message_id") + val encryptedPayload = blobOrNull("payload_ciphertext") + ?: error("Conversation message $messageID has no encrypted payload") + val payload = decryptMessagePayload(messageID, encryptedPayload) + return BitchatMessage( + id = messageID, + sender = payload.sender, + content = payload.content, + type = BitchatMessageType.entries.getOrElse(int("message_type")) { + BitchatMessageType.Message + }, + timestamp = Date(long("sent_at")), + isRelay = boolean("is_relay"), + originalSender = payload.originalSender, + isPrivate = boolean("is_private"), + recipientNickname = payload.recipientNickname, + senderPeerID = payload.senderPeerID, + mentions = payload.mentions, + channel = payload.channel, + encryptedContent = payload.encryptedContent, + isEncrypted = boolean("is_encrypted"), + deliveryStatus = toDeliveryStatus(payload.deliveryText), + senderNostrPubkey = payload.senderNostrPubkey + ) + } + + private fun Cursor.toLegacyMessage(): BitchatMessage = BitchatMessage( + id = string("message_id"), + sender = string("sender"), + content = string("content"), + type = BitchatMessageType.entries.getOrElse(int("message_type")) { + BitchatMessageType.Message + }, + timestamp = Date(long("sent_at")), + isRelay = boolean("is_relay"), + originalSender = nullableString("original_sender"), + isPrivate = boolean("is_private"), + recipientNickname = nullableString("recipient_nickname"), + senderPeerID = nullableString("sender_peer_id"), + mentions = nullableString("mentions_json")?.let(::jsonStringList), + channel = nullableString("channel_name"), + encryptedContent = blobOrNull("encrypted_content"), + isEncrypted = boolean("is_encrypted"), + deliveryStatus = toDeliveryStatus(), + senderNostrPubkey = nullableString("sender_nostr_pubkey") + ) + + private fun Cursor.toDeliveryStatus(deliveryText: String? = nullableString("delivery_text")): + DeliveryStatus? = when (int("delivery_type")) { + 1 -> DeliveryStatus.Sending + 2 -> DeliveryStatus.Sent + 3 -> DeliveryStatus.Delivered( + to = deliveryText.orEmpty(), + at = Date(nullableLong("delivery_at") ?: 0L) + ) + 4 -> DeliveryStatus.Read( + by = deliveryText.orEmpty(), + at = Date(nullableLong("delivery_at") ?: 0L) + ) + 5 -> DeliveryStatus.Failed(deliveryText.orEmpty()) + 6 -> DeliveryStatus.PartiallyDelivered( + reached = nullableInt("delivery_reached") ?: 0, + total = nullableInt("delivery_total") ?: 0 + ) + else -> null + } + + private fun jsonStringList(json: String): List { + val array = JSONArray(json) + return buildList(array.length()) { + for (index in 0 until array.length()) add(array.getString(index)) + } + } + + private fun Cursor.index(column: String): Int = getColumnIndexOrThrow(column) + private fun Cursor.string(column: String): String = getString(index(column)) + private fun Cursor.nullableString(column: String): String? = + index(column).let { if (isNull(it)) null else getString(it) } + private fun Cursor.int(column: String): Int = getInt(index(column)) + private fun Cursor.nullableInt(column: String): Int? = + index(column).let { if (isNull(it)) null else getInt(it) } + private fun Cursor.long(column: String): Long = getLong(index(column)) + private fun Cursor.nullableLong(column: String): Long? = + index(column).let { if (isNull(it)) null else getLong(it) } + private fun Cursor.boolean(column: String): Boolean = int(column) != 0 + private fun Cursor.blobOrNull(column: String): ByteArray? = + index(column).let { if (isNull(it)) null else getBlob(it) } + private fun Boolean.asInt(): Int = if (this) 1 else 0 + + private fun encryptMessagePayload(message: BitchatMessage): ByteArray { + val json = JSONObject().apply { + put("sender", message.sender) + put("content", message.content) + putNullable("original_sender", message.originalSender) + putNullable("recipient_nickname", message.recipientNickname) + putNullable("sender_peer_id", message.senderPeerID) + putNullable("mentions_json", message.mentions?.let(::JSONArray)) + putNullable("channel_name", message.channel) + putNullable( + "encrypted_content", + message.encryptedContent?.let { + Base64.encodeToString(it, Base64.NO_WRAP) + } + ) + putNullable("delivery_text", message.deliveryStatus.sensitiveText()) + putNullable("sender_nostr_pubkey", message.senderNostrPubkey) + } + return storageCipher.encrypt( + json.toString().toByteArray(Charsets.UTF_8), + messagePayloadAad(message.id) + ) + } + + private fun decryptMessagePayload( + messageID: String, + encryptedPayload: ByteArray + ): StoredMessagePayload { + val json = JSONObject( + storageCipher.decrypt(encryptedPayload, messagePayloadAad(messageID)) + .toString(Charsets.UTF_8) + ) + return StoredMessagePayload( + sender = json.getString("sender"), + content = json.getString("content"), + originalSender = json.optionalString("original_sender"), + recipientNickname = json.optionalString("recipient_nickname"), + senderPeerID = json.optionalString("sender_peer_id"), + mentions = json.optionalArray("mentions_json")?.let(::jsonStringList), + channel = json.optionalString("channel_name"), + encryptedContent = json.optionalString("encrypted_content")?.let { + Base64.decode(it, Base64.NO_WRAP) + }, + deliveryText = json.optionalString("delivery_text"), + senderNostrPubkey = json.optionalString("sender_nostr_pubkey") + ) + } + + private fun encryptText(value: String, associatedData: ByteArray): ByteArray = + storageCipher.encrypt(value.toByteArray(Charsets.UTF_8), associatedData) + + private fun messagePayloadAad(messageID: String): ByteArray = + "message:$messageID".toByteArray(Charsets.UTF_8) + + private fun conversationDisplayNameAad(conversationID: String): ByteArray = + "conversation:$conversationID:display-name".toByteArray(Charsets.UTF_8) + + private fun scrubLegacyPayloadColumns(values: ContentValues) { + values.put("sender", "") + values.put("content", "") + values.putNull("original_sender") + values.putNull("recipient_nickname") + values.putNull("sender_peer_id") + values.putNull("mentions_json") + values.putNull("channel_name") + values.putNull("encrypted_content") + values.putNull("delivery_text") + values.putNull("sender_nostr_pubkey") + } + + private fun JSONObject.putNullable(key: String, value: Any?) { + put(key, value ?: JSONObject.NULL) + } + + private fun JSONObject.optionalString(key: String): String? = + if (!has(key) || isNull(key)) null else getString(key) + + private fun JSONObject.optionalArray(key: String): JSONArray? = + if (!has(key) || isNull(key)) null else getJSONArray(key) + + private fun jsonStringList(array: JSONArray): List = + buildList(array.length()) { + for (index in 0 until array.length()) add(array.getString(index)) + } + + private fun DeliveryStatus?.sensitiveText(): String? = when (this) { + is DeliveryStatus.Delivered -> to + is DeliveryStatus.Read -> by + is DeliveryStatus.Failed -> reason + else -> null + } + + private data class StoredMessagePayload( + val sender: String, + val content: String, + val originalSender: String?, + val recipientNickname: String?, + val senderPeerID: String?, + val mentions: List?, + val channel: String?, + val encryptedContent: ByteArray?, + val deliveryText: String?, + val senderNostrPubkey: String? + ) + + private val MESSAGE_COLUMNS = arrayOf( + "arrival_sequence", + "message_id", + "conversation_id", + "sender", + "content", + "message_type", + "sent_at", + "received_at", + "is_relay", + "original_sender", + "is_private", + "recipient_nickname", + "sender_peer_id", + "mentions_json", + "channel_name", + "encrypted_content", + "is_encrypted", + "delivery_type", + "delivery_text", + "delivery_at", + "delivery_reached", + "delivery_total", + "sender_nostr_pubkey", + "payload_ciphertext", + "is_read" + ) + + private val LEGACY_MESSAGE_COLUMNS = MESSAGE_COLUMNS.filterNot { + it == "payload_ciphertext" || it == "received_at" + }.toTypedArray() +} diff --git a/app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt b/app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt new file mode 100644 index 00000000..12acb417 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ConversationStorageCipher.kt @@ -0,0 +1,98 @@ +package com.bitchat.android.services + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Encrypts private-conversation payloads with a key that never leaves Android Keystore. + * + * Every value is bound to its database identity through AES-GCM associated data. This prevents an + * encrypted payload copied from one message or conversation row from being accepted in another. + * Deleting the dedicated alias provides practical cryptographic erasure before SQLite pages, WAL + * records, and filesystem blocks are reclaimed. + */ +internal interface ConversationStorageCipher { + fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray + fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray + fun destroyKey() +} +internal class AndroidConversationStorageCipher( + private val keyAlias: String = DEFAULT_KEY_ALIAS +) : ConversationStorageCipher { + companion object { + internal const val DEFAULT_KEY_ALIAS = "bitchat_conversation_storage_v1" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val ENVELOPE_VERSION: Byte = 1 + private const val GCM_TAG_BITS = 128 + private const val IV_BYTES = 12 + } + + private val keyLock = Any() + @Volatile + private var cachedKey: SecretKey? = null + + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + cipher.updateAAD(associatedData) + val ciphertext = cipher.doFinal(plaintext) + check(cipher.iv.size == IV_BYTES) { "Unexpected AES-GCM IV length" } + return byteArrayOf(ENVELOPE_VERSION) + cipher.iv + ciphertext + } + + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray { + require(envelope.size > 1 + IV_BYTES) { "Conversation payload envelope is truncated" } + require(envelope[0] == ENVELOPE_VERSION) { + "Unsupported conversation payload envelope version" + } + val iv = envelope.copyOfRange(1, 1 + IV_BYTES) + val ciphertext = envelope.copyOfRange(1 + IV_BYTES, envelope.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + cipher.updateAAD(associatedData) + return cipher.doFinal(ciphertext) + } + + override fun destroyKey() { + synchronized(keyLock) { + cachedKey = null + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + if (keyStore.containsAlias(keyAlias)) { + keyStore.deleteEntry(keyAlias) + } + } + } + + private fun getOrCreateKey(): SecretKey = + cachedKey ?: synchronized(keyLock) { + cachedKey ?: run { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + (keyStore.getKey(keyAlias, null) as? SecretKey) ?: generateKey() + }.also { cachedKey = it } + } + + private fun generateKey(): SecretKey { + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + KEYSTORE_PROVIDER + ) + generator.init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build() + ) + return generator.generateKey() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt new file mode 100644 index 00000000..dc76a57d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.runBlocking + +/** + * Reflects an incoming transport message into process-wide state before any downstream effects. + * + * Private-message admission is authoritative: a duplicate or a message rejected while panic mode + * is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications. + * Public and channel messages retain their existing best-effort behavior if state reflection fails. + */ +internal object IncomingMessageAdmission { + fun admitToAppState(message: BitchatMessage): Boolean = try { + when { + message.isPrivate -> { + val peerID = message.senderPeerID?.takeIf(String::isNotBlank) + ?: return false + // Mesh transport callbacks run on their background service workers. Wait for the + // serialized SQLite transaction so a notification can never advertise a message + // that an immediate process death would lose. + runBlocking { + AppStateStore.addPrivateMessageDurably(peerID, message) + } + } + + message.channel != null -> { + AppStateStore.addChannelMessage(message.channel, message) + true + } + + else -> { + AppStateStore.addPublicMessage(message) + true + } + } + } catch (_: Exception) { + // Preserve the pre-existing best-effort dispatch for public/channel messages, but never + // bypass private-message admission when persistence or canonicalization fails. + !message.isPrivate + } +} diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index bdf3e126..dc6ad4d2 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -2,134 +2,226 @@ package com.bitchat.android.services import android.content.Context import android.util.Log -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.favorites.FavoriteControlMessage +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.ReadReceipt import com.bitchat.android.nostr.NostrTransport +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** - * Routes messages between BLE mesh and Nostr transports, matching iOS behavior. + * Routes messages between local mesh transports and Nostr, matching iOS behavior. */ class MessageRouter private constructor( private val context: Context, - private val mesh: BluetoothMeshService, + private var mesh: MeshService, private val nostr: NostrTransport ) { + enum class RouteResult { + MESH, + NOSTR, + QUEUED, + DROPPED + } + + private data class QueuedMessage( + val content: String, + val nickname: String, + val messageID: String, + val enqueuedAtMs: Long + ) + + private data class ConversationRetry( + val handshakeAttempts: Int, + val nextHandshakeAttemptAtMs: Long + ) + companion object { private const val TAG = "MessageRouter" + private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS + private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS + private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER + private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS + @Volatile private var INSTANCE: MessageRouter? = null + internal var disableSchedulerForTesting = false fun tryGetInstance(): MessageRouter? = INSTANCE - fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter { - return INSTANCE ?: synchronized(this) { - val nostr = NostrTransport.getInstance(context) - INSTANCE?.also { - // Update mesh reference if needed and keep senderPeerID in sync - it.nostr.senderPeerID = mesh.myPeerID - return it - } - MessageRouter(context.applicationContext, mesh, nostr).also { instance -> - instance.nostr.senderPeerID = mesh.myPeerID - // Register for favorites changes to flush outbox - try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) - } catch (_: Exception) {} - INSTANCE = instance + fun getInstance(context: Context, mesh: MeshService): MessageRouter { + val instance = INSTANCE ?: synchronized(this) { + INSTANCE ?: run { + val nostr = NostrTransport.getInstance(context) + MessageRouter(context.applicationContext, mesh, nostr).also { instance -> + // Register for favorites changes to flush outbox + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) + } catch (_: Exception) {} + INSTANCE = instance + } } } + // Always update mesh reference and sync peer ID, and make sure the retry + // scheduler is running (it is stopped together with MeshForegroundService). + instance.mesh = mesh + instance.nostr.senderPeerID = mesh.myPeerID + instance.startOutboxScheduler() + return instance + } + + internal fun resetForTesting() { + INSTANCE?.schedulerScope?.cancel() + INSTANCE = null } } - // Outbox: peerID -> queued (content, nickname, messageID) - private val outbox = mutableMapOf>>() + // Outbox: conversationID -> queued messages, oldest first + private val outbox = ConcurrentHashMap>() + + // Per-conversation handshake retry state for queued messages + private val retryState = ConcurrentHashMap() + + private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var schedulerJob: kotlinx.coroutines.Job? = null + + // Injectable clock for tests + internal var clock: () -> Long = { System.currentTimeMillis() } + + // Called with the messageID of queued messages that expired or were evicted + var onMessageExpired: ((String) -> Unit)? = null + + init { + startOutboxScheduler() + } + + fun clearAll() { + outbox.clear() + retryState.clear() + Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state") + } // Listener for favorites changes to flush outbox when npub mapping appears/changes private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) { flushOutboxFor(noiseKeyHex) - // Also try 16-hex short id commonly used in UI if any client used that - val shortId = noiseKeyHex.take(16) - flushOutboxFor(shortId) + ContactIdentityResolver.peerIdForNoiseKeyHex(noiseKeyHex)?.let { flushOutboxFor(it) } } override fun onAllCleared() { - // Nothing special; leave queued items until routing becomes possible } } - fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) { - val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true - val hasEstablished = mesh.hasEstablishedSession(toPeerID) - if (hasMesh && hasEstablished) { - Log.d(TAG, "Routing PM via mesh to ${toPeerID.take(8)}… id=${messageID.take(8)}…") - mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) - } else if (canSendViaNostr(toPeerID)) { - Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(8)}… id=${messageID.take(8)}…") - nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) + fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String): RouteResult { + val resolution = ContactDirectory.resolve(toPeerID) + val conversationID = resolution.conversationID + val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } + val nostrTarget = resolution.noiseKeyHex ?: toPeerID + + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { + Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}…") + val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID) + if (recipientHex != null) { + val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID) + nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash) + return RouteResult.NOSTR + } + return RouteResult.DROPPED + } + + val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true + if (meshTarget != null && isReady(mesh, meshTarget)) { + Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…") + mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID) + return RouteResult.MESH + } else if (canSendViaNostr(nostrTarget)) { + Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}…") + nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID) + return RouteResult.NOSTR } else { - Log.d(TAG, "Queued PM for ${toPeerID.take(8)}… (no mesh, no Nostr mapping) id=${messageID.take(8)}…") - val q = outbox.getOrPut(toPeerID) { mutableListOf() } - q.add(Triple(content, recipientNickname, messageID)) + Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") + enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock())) + Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…") + if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) } + return RouteResult.QUEUED } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) { - Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") - mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID) + val resolution = ContactDirectory.resolve(toPeerID) + val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } + val nostrTarget = resolution.noiseKeyHex ?: toPeerID + if (meshTarget != null && isReady(mesh, meshTarget)) { + Log.d(TAG, "Routing READ via mesh to ${meshTarget.take(8)}… id=${receipt.originalMessageID.take(8)}…") + mesh.sendReadReceipt(receipt.originalMessageID, meshTarget, mesh.getPeerNicknames()[meshTarget] ?: mesh.myPeerID) } else { Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") - nostr.sendReadReceipt(receipt, toPeerID) + nostr.sendReadReceipt(receipt, nostrTarget) } } fun sendDeliveryAck(messageID: String, toPeerID: String) { // Mesh delivery ACKs are sent by the receiver automatically. - // Only route via Nostr when mesh path isn't available. - if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) { - nostr.sendDeliveryAck(messageID, toPeerID) + // Only route via Nostr when mesh path isn't available or when this is a geohash alias + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { + val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID) + if (recipientHex != null) { + nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return }) + return + } + } + val resolution = ContactDirectory.resolve(toPeerID) + val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } + if (!(meshTarget != null && (mesh.getPeerInfo(meshTarget)?.isConnected == true) && mesh.hasEstablishedSession(meshTarget))) { + nostr.sendDeliveryAck(messageID, resolution.noiseKeyHex ?: toPeerID) } } fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) { - if (mesh.getPeerInfo(toPeerID)?.isConnected == true) { - val myNpub = try { - com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub - } catch (_: Exception) { null } - val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}" - val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID - mesh.sendPrivateMessage(content, toPeerID, nickname) + val resolution = ContactDirectory.resolve(toPeerID) + val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } + if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) { + val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null } + val content = FavoriteControlMessage.encode(isFavorite, myNpub) + val nickname = mesh.getPeerNicknames()[meshTarget] ?: meshTarget + mesh.sendPrivateMessage(content, meshTarget, nickname, null) } else { - nostr.sendFavoriteNotification(toPeerID, isFavorite) + nostr.sendFavoriteNotification(resolution.noiseKeyHex ?: toPeerID, isFavorite) } } - // Flush any queued messages for a specific peerID + // Flush any queued messages for a specific peerID. + // All outbox mutations happen under the router monitor so a concurrent enqueue cannot + // be lost between the empty check and the map removal. + @Synchronized fun flushOutboxFor(peerID: String) { - val queued = outbox[peerID] ?: return + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val queued = outbox[conversationID] ?: outbox[peerID] ?: return if (queued.isEmpty()) return - Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}") + Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}") val iterator = queued.iterator() while (iterator.hasNext()) { - val (content, nickname, messageID) = iterator.next() - var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) - // If this is a noiseHex key, see if there is a connected mesh peer for this identity - if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val meshPeer = resolveMeshPeerForNoiseHex(peerID) - if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) { - mesh.sendPrivateMessage(content, meshPeer, nickname, messageID) - iterator.remove() - continue - } - } - val canNostr = canSendViaNostr(peerID) - if (hasMesh) { - mesh.sendPrivateMessage(content, peerID, nickname, messageID) + val entry = iterator.next() + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID + val nostrTarget = resolution.noiseKeyHex ?: conversationID + if (meshTarget != null && isReady(mesh, meshTarget)) { + mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) iterator.remove() - } else if (canNostr) { - nostr.sendPrivateMessage(content, peerID, nickname, messageID) + } else if (canSendViaNostr(nostrTarget)) { + nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) iterator.remove() } } if (queued.isEmpty()) { - outbox.remove(peerID) + outbox.remove(conversationID, queued) + outbox.remove(peerID, queued) + retryState.remove(conversationID) + retryState.remove(peerID) } } @@ -138,16 +230,127 @@ class MessageRouter private constructor( outbox.keys.toList().forEach { flushOutboxFor(it) } } + @Synchronized + private fun enqueue(conversationID: String, entry: QueuedMessage) { + val queue = outbox.getOrPut(conversationID) { mutableListOf() } + queue.add(entry) + while (queue.size > OUTBOX_MAX_PER_PEER) { + val evicted = queue.removeAt(0) + Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…") + notifyExpired(evicted.messageID) + } + } + + private fun notifyExpired(messageID: String) { + try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { } + } + + /** + * Initiate a Noise handshake for a conversation with queued messages, applying + * exponential backoff between attempts. [immediate] resets the backoff (peer just + * appeared or a new message was queued). Kicks are suppressed while a previous + * attempt is still inside its backoff window, so alias duplicates and frequent + * peer-list updates cannot spam handshakes. + */ + @Synchronized + private fun kickHandshake(conversationID: String, meshTarget: String, immediate: Boolean) { + val now = clock() + val current = retryState[conversationID] + if (current != null && now < current.nextHandshakeAttemptAtMs) return + val attempts = if (immediate) 0 else (current?.handshakeAttempts ?: 0) + try { mesh.initiateNoiseHandshake(meshTarget) } catch (_: Exception) { } + val backoff = HANDSHAKE_RETRY_BACKOFF_MS[attempts.coerceAtMost(HANDSHAKE_RETRY_BACKOFF_MS.size - 1)] + retryState[conversationID] = ConversationRetry( + handshakeAttempts = attempts + 1, + nextHandshakeAttemptAtMs = now + backoff + ) + Log.d(TAG, "Handshake attempt ${attempts + 1} for ${conversationID.take(16)}…, next retry in ${backoff}ms") + } + + @Synchronized + private fun startOutboxScheduler() { + if (disableSchedulerForTesting) return + if (schedulerJob?.isActive == true) return + schedulerJob = schedulerScope.launch { + while (isActive) { + delay(OUTBOX_TICK_MS) + try { tickOutbox() } catch (e: Exception) { + Log.w(TAG, "Outbox scheduler tick failed: ${e.message}") + } + } + } + } + + /** + * Stop retrying while the mesh transports are down. Persistent network work must + * follow the MeshForegroundService lifecycle; getInstance restarts the scheduler + * and rebinds the mesh reference when the service comes back. + */ + fun stopOutboxScheduler() { + schedulerJob?.cancel() + schedulerJob = null + } + + internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true + + /** + * One scheduler pass over the outbox: expire old entries, flush what can be sent, + * and re-initiate handshakes (with backoff) for peers that are connected but have + * no established session yet. + */ + @Synchronized + internal fun tickOutbox(nowMs: Long = clock()) { + outbox.keys.toList().forEach { conversationID -> + expireOldEntries(conversationID, nowMs) + val queued = outbox[conversationID] ?: return@forEach + if (queued.isEmpty()) return@forEach + + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID + + if (meshTarget != null && isReady(mesh, meshTarget)) { + flushOutboxFor(conversationID) + return@forEach + } + if (canSendViaNostr(resolution.noiseKeyHex ?: conversationID)) { + flushOutboxFor(conversationID) + return@forEach + } + // Peer visible but no session: retry the handshake with backoff. + if (meshTarget != null && isConnected(mesh, meshTarget)) { + kickHandshake(conversationID, meshTarget, immediate = false) + } + } + } + + private fun expireOldEntries(conversationID: String, nowMs: Long) { + val queued = outbox[conversationID] ?: return + val iterator = queued.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) { + Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…") + iterator.remove() + notifyExpired(entry.messageID) + } + } + if (queued.isEmpty()) { + outbox.remove(conversationID, queued) + retryState.remove(conversationID) + } + } + private fun canSendViaNostr(peerID: String): Boolean { return try { - // Full Noise key hex - if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val noiseKey = hexToBytes(peerID) + val resolution = ContactDirectory.resolve(peerID) + if (resolution.isMutualFavorite && resolution.nostrPubkey != null) return true + val target = resolution.noiseKeyHex ?: peerID + if (ContactIdentityResolver.isNoiseKeyHex(target)) { + val noiseKey = ContactIdentityResolver.bytesFromHex(target) ?: return false val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) fav?.isMutual == true && fav.peerNostrPublicKey != null - } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - // Ephemeral 16-hex mesh ID: resolve via prefix match in favorites - val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } else if (ContactIdentityResolver.isMeshPeerId(target)) { + val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(target) fav?.isMutual == true && fav.peerNostrPublicKey != null } else { false @@ -155,38 +358,71 @@ class MessageRouter private constructor( } catch (_: Exception) { false } } - private fun hexToBytes(hex: String): ByteArray { - val clean = if (hex.length % 2 == 0) hex else "0$hex" - return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun isConnected(service: MeshService, peerID: String): Boolean { + return try { + service.getPeerInfo(peerID)?.isConnected == true + } catch (_: Exception) { + false + } } - private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? { + private fun isReady(service: MeshService, peerID: String): Boolean { return try { - mesh.getPeerNicknames().keys.firstOrNull { pid -> - val info = mesh.getPeerInfo(pid) - val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } - keyHex != null && keyHex.equals(noiseHex, ignoreCase = true) - } - } catch (_: Exception) { null } + service.getPeerInfo(peerID)?.isConnected == true && + service.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } } // Called when mesh peer list changes; attempt to flush any matching outbox entries fun onPeersUpdated(peers: List) { peers.forEach { pid -> + kickHandshakeIfPending(pid) flushOutboxFor(pid) val noiseHex = try { - mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) } } catch (_: Exception) { null } - noiseHex?.let { flushOutboxFor(it) } + noiseHex?.let { + kickHandshakeIfPending(it) + flushOutboxFor(it) + } } } // Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias fun onSessionEstablished(peerID: String) { + resetRetry(peerID) flushOutboxFor(peerID) val noiseHex = try { - mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) } } catch (_: Exception) { null } - noiseHex?.let { flushOutboxFor(it) } + noiseHex?.let { + resetRetry(it) + flushOutboxFor(it) + } + } + + /** Reset handshake backoff for a conversation whose session just came up. */ + private fun resetRetry(peerID: String) { + retryState.remove(ContactDirectory.canonicalConversationId(peerID)) + retryState.remove(peerID) + } + + /** + * A peer (re)appeared: if we still owe them queued messages and there is no working + * session yet, restart the handshake immediately instead of waiting for the backoff. + */ + @Synchronized + private fun kickHandshakeIfPending(peerID: String) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val queued = outbox[conversationID] ?: outbox[peerID] ?: return + if (queued.isEmpty()) return + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID ?: return + if (isReady(mesh, meshTarget)) return + if (!isConnected(mesh, meshTarget)) return + Log.d(TAG, "Peer ${meshTarget.take(8)}… reappeared with ${queued.size} queued PM(s); re-initiating handshake") + kickHandshake(conversationID, meshTarget, immediate = true) } } diff --git a/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt new file mode 100644 index 00000000..c7cc601f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.ui.DataManager + +/** + * Provides current user's nickname for announcements and leave messages. + * If no nickname saved, falls back to the provided peerID. + */ +object NicknameProvider { + fun getNickname(context: Context, myPeerID: String): String { + return try { + val dm = DataManager(context.applicationContext) + val nick = dm.loadNickname() + if (nick.isNullOrBlank()) myPeerID else nick + } catch (_: Exception) { + myPeerID + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt new file mode 100644 index 00000000..f42a8dff --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt @@ -0,0 +1,80 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage + +/** + * Process-local receipt sequence for private messages. + * + * Peer-provided timestamps cannot safely order a conversation because clocks can differ. This + * registry follows the lifetime of [AppStateStore] and lets alias merges reconstruct the order in + * which messages entered the app, even when they were initially stored under different keys. + */ +internal object PrivateMessageArrivalOrder { + private val sequenceByMessageID = mutableMapOf() + private val receivedAtByMessageID = mutableMapOf() + private var nextSequence = 0L + + fun record(messageID: String, receivedAt: Long = System.currentTimeMillis()) { + synchronized(this) { + if (messageID !in sequenceByMessageID) { + sequenceByMessageID[messageID] = nextSequence++ + receivedAtByMessageID[messageID] = receivedAt + } + } + } + + fun restore( + persistedOrder: List, + liveMessageIDs: List, + persistedReceivedAt: Map = emptyMap(), + persistedSequences: Map = emptyMap() + ) { + synchronized(this) { + val previousSequences = sequenceByMessageID.toMap() + val previousReceivedAt = receivedAtByMessageID.toMap() + sequenceByMessageID.clear() + receivedAtByMessageID.clear() + nextSequence = (persistedSequences.values.maxOrNull() ?: -1L) + 1L + (persistedOrder + liveMessageIDs).forEach { messageID -> + if (messageID !in sequenceByMessageID) { + val sequence = persistedSequences[messageID] + ?: previousSequences[messageID] + ?: nextSequence++ + sequenceByMessageID[messageID] = sequence + (persistedReceivedAt[messageID] ?: previousReceivedAt[messageID])?.let { + receivedAtByMessageID[messageID] = it + } + } + } + nextSequence = maxOf( + nextSequence, + (sequenceByMessageID.values.maxOrNull() ?: -1L) + 1L + ) + } + } + + fun sequenceOf(messageID: String): Long? = synchronized(this) { + sequenceByMessageID[messageID] + } + + fun receivedAtOf(messageID: String): Long? = synchronized(this) { + receivedAtByMessageID[messageID] + } + + fun order(messages: List): List { + synchronized(this) { + if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) { + return messages + } + return messages.sortedBy { sequenceByMessageID.getValue(it.id) } + } + } + + fun clear() { + synchronized(this) { + sequenceByMessageID.clear() + receivedAtByMessageID.clear() + nextSequence = 0L + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index 469525d7..0701645a 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -1,20 +1,28 @@ package com.bitchat.android.services +import android.annotation.SuppressLint import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager import com.google.gson.Gson +import com.google.gson.annotations.SerializedName /** - * Persistent store for message IDs we've already acknowledged (DELIVERED) or READ. + * Persistent store for message IDs we've already acknowledged as delivered, read locally, or + * admitted to a completed read-receipt send window. + * + * Local read state must not be used as proof that a read-receipt packet reached the sender. + * Transport delivery is best-effort and retryable, while local read state drives unread UI. * Limits to last MAX_IDS entries per set to avoid memory bloat. */ class SeenMessageStore private constructor(private val context: Context) { companion object { private const val TAG = "SeenMessageStore" private const val STORAGE_KEY = "seen_message_store_v1" - private const val MAX_IDS = 10_000 + private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS + // The constructor always receives applicationContext, so process lifetime is intentional. + @SuppressLint("StaticFieldLeak") @Volatile private var INSTANCE: SeenMessageStore? = null fun getInstance(appContext: Context): SeenMessageStore { return INSTANCE ?: synchronized(this) { @@ -27,12 +35,14 @@ class SeenMessageStore private constructor(private val context: Context) { private val secure = SecureIdentityStateManager(context) private val delivered = LinkedHashSet(MAX_IDS) - private val read = LinkedHashSet(MAX_IDS) + private val locallyRead = LinkedHashSet(MAX_IDS) + private val readReceiptsSent = LinkedHashSet(MAX_IDS) init { load() } @Synchronized fun hasDelivered(id: String) = delivered.contains(id) - @Synchronized fun hasRead(id: String) = read.contains(id) + @Synchronized fun hasBeenReadLocally(id: String) = locallyRead.contains(id) + @Synchronized fun hasReadReceiptBeenSent(id: String) = readReceiptsSent.contains(id) @Synchronized fun markDelivered(id: String) { if (delivered.remove(id)) delivered.add(id) else { @@ -42,11 +52,35 @@ class SeenMessageStore private constructor(private val context: Context) { persist() } - @Synchronized fun markRead(id: String) { - if (read.remove(id)) read.add(id) else { - read.add(id) - trim(read) + @Synchronized fun markReadLocally(id: String) { + if (locallyRead.remove(id)) locallyRead.add(id) else { + locallyRead.add(id) + trim(locallyRead) } + AppStateStore.markPrivateMessageRead(id) + persist() + } + + @Synchronized fun markReadReceiptSent(id: String) { + if (readReceiptsSent.remove(id)) readReceiptsSent.add(id) else { + readReceiptsSent.add(id) + trim(readReceiptsSent) + } + persist() + } + + @Synchronized fun remove(ids: Set) { + if (ids.isEmpty()) return + delivered.removeAll(ids) + locallyRead.removeAll(ids) + readReceiptsSent.removeAll(ids) + persist() + } + + @Synchronized fun clear() { + delivered.clear() + locallyRead.clear() + readReceiptsSent.clear() persist() } @@ -62,10 +96,19 @@ class SeenMessageStore private constructor(private val context: Context) { try { val json = secure.getSecureValue(STORAGE_KEY) ?: return val data = gson.fromJson(json, StorePayload::class.java) ?: return - delivered.clear(); read.clear() + delivered.clear(); locallyRead.clear(); readReceiptsSent.clear() data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } - data.read.takeLast(MAX_IDS).forEach { read.add(it) } - Log.d(TAG, "Loaded delivered=${delivered.size}, read=${read.size}") + data.locallyRead.takeLast(MAX_IDS).forEach { locallyRead.add(it) } + // Older payloads used the local-read set to suppress receipt sends. Seed the new + // explicit set once during migration to avoid replaying an entire chat history. + (data.readReceiptsSent ?: data.locallyRead) + .takeLast(MAX_IDS) + .forEach { readReceiptsSent.add(it) } + Log.d( + TAG, + "Loaded delivered=${delivered.size}, locallyRead=${locallyRead.size}, " + + "readReceiptsSent=${readReceiptsSent.size}" + ) } catch (e: Exception) { Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}") } @@ -73,7 +116,11 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized private fun persist() { try { - val payload = StorePayload(delivered.toList(), read.toList()) + val payload = StorePayload( + delivered = delivered.toList(), + locallyRead = locallyRead.toList(), + readReceiptsSent = readReceiptsSent.toList() + ) val json = gson.toJson(payload) secure.storeSecureValue(STORAGE_KEY, json) } catch (e: Exception) { @@ -83,7 +130,10 @@ class SeenMessageStore private constructor(private val context: Context) { private data class StorePayload( val delivered: List = emptyList(), - val read: List = emptyList() + // Keep the existing JSON field name for backward-compatible secure-store migration. + @SerializedName("read") + val locallyRead: List = emptyList(), + @SerializedName("read_receipts_sent") + val readReceiptsSent: List? = null ) } - diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt new file mode 100644 index 00000000..06dcd6f3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt @@ -0,0 +1,294 @@ +package com.bitchat.android.services + +import android.net.Uri +import android.util.Base64 +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.util.AppConstants +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.io.ByteArrayOutputStream +import java.security.SecureRandom +import androidx.core.net.toUri +import java.lang.ref.WeakReference + +/** + * QR verification helpers: schema, signing, and basic challenge/response helpers. + */ +object VerificationService { + private const val CONTEXT = "bitchat-verify-v1" + private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1" + + private var encryptionServiceRef: WeakReference? = null + + fun configure(encryptionService: EncryptionService) { + this.encryptionServiceRef = WeakReference(encryptionService) + } + + data class VerificationQR( + val v: Int, + val noiseKeyHex: String, + val signKeyHex: String, + val npub: String?, + val nickname: String, + val ts: Long, + val nonceB64: String, + val sigHex: String + ) { + fun canonicalBytes(): ByteArray { + val out = ByteArrayOutputStream() + + fun appendField(value: String) { + val data = value.toByteArray(Charsets.UTF_8) + val len = minOf(data.size, 255) + out.write(len) + out.write(data, 0, len) + } + + appendField(CONTEXT) + appendField(v.toString()) + appendField(noiseKeyHex.lowercase()) + appendField(signKeyHex.lowercase()) + appendField(npub ?: "") + appendField(nickname) + appendField(ts.toString()) + appendField(nonceB64) + return out.toByteArray() + } + + fun toUrlString(): String { + val builder = Uri.Builder() + .scheme("bitchat") + .authority("verify") + .appendQueryParameter("v", v.toString()) + .appendQueryParameter("noise", noiseKeyHex) + .appendQueryParameter("sign", signKeyHex) + .appendQueryParameter("nick", nickname) + .appendQueryParameter("ts", ts.toString()) + .appendQueryParameter("nonce", nonceB64) + .appendQueryParameter("sig", sigHex) + if (npub != null) { + builder.appendQueryParameter("npub", npub) + } + return builder.build().toString() + } + + companion object { + fun fromUrlString(urlString: String): VerificationQR? { + val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null + if (uri.scheme != "bitchat" || uri.host != "verify") return null + + val vStr = uri.getQueryParameter("v") ?: return null + val v = vStr.toIntOrNull() ?: return null + val noise = uri.getQueryParameter("noise") ?: return null + val sign = uri.getQueryParameter("sign") ?: return null + val nick = uri.getQueryParameter("nick") ?: return null + val tsStr = uri.getQueryParameter("ts") ?: return null + val ts = tsStr.toLongOrNull() ?: return null + val nonce = uri.getQueryParameter("nonce") ?: return null + val sig = uri.getQueryParameter("sig") ?: return null + val npub = uri.getQueryParameter("npub") + + return VerificationQR( + v = v, + noiseKeyHex = noise, + signKeyHex = sign, + npub = npub, + nickname = nick, + ts = ts, + nonceB64 = nonce, + sigHex = sig + ) + } + } + } + + fun buildMyQRString(nickname: String, npub: String?): String? { + val service = encryptionServiceRef?.get() ?: return null + val cache = Cache.last + if (cache != null && cache.nickname == nickname && cache.npub == npub) { + if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) { + return cache.value + } + } + + val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null + val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null + val ts = System.currentTimeMillis() / 1000L + val nonce = ByteArray(16) + SecureRandom().nextBytes(nonce) + val nonceB64 = Base64.encodeToString( + nonce, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) + + val payload = VerificationQR( + v = 1, + noiseKeyHex = noiseKey, + signKeyHex = signKey, + npub = npub, + nickname = nickname, + ts = ts, + nonceB64 = nonceB64, + sigHex = "" + ) + + val signature = service.signData(payload.canonicalBytes()) ?: return null + val signed = payload.copy(sigHex = signature.hexEncodedString()) + val out = signed.toUrlString() + Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out) + return out + } + + fun verifyScannedQR( + urlString: String, + maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS + ): VerificationQR? { + val service = encryptionServiceRef?.get() ?: return null + val qr = VerificationQR.fromUrlString(urlString) ?: return null + val now = System.currentTimeMillis() / 1000L + if (now - qr.ts > maxAgeSeconds) return null + + val sig = qr.sigHex.dataFromHexString() ?: return null + val signKey = qr.signKeyHex.dataFromHexString() ?: return null + val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey) + return if (ok) qr else null + } + + fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray { + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + return out.toByteArray() + } + + fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? { + val service = encryptionServiceRef?.get() ?: return null + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val sig = service.signData(msg.toByteArray()) ?: return null + + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + out.write(0x03) + out.write(minOf(sig.size, 255)) + out.write(sig, 0, minOf(sig.size, 255)) + return out.toByteArray() + } + + fun parseVerifyChallenge(data: ByteArray): Pair? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + return noise to nonce + } + + data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VerifyResponse + + if (noiseKeyHex != other.noiseKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + if (!signature.contentEquals(other.signature)) return false + + return true + } + + override fun hashCode(): Int { + var result = noiseKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + result = 31 * result + signature.contentHashCode() + return result + } + } + + fun parseVerifyResponse(data: ByteArray): VerifyResponse? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + val t3 = take(1) ?: return null + if (t3[0].toInt() != 0x03) return null + val l3 = take(1)?.get(0)?.toInt() ?: return null + val sig = take(l3) ?: return null + + return VerifyResponse(noise, nonce, sig) + } + + fun verifyResponseSignature( + noiseKeyHex: String, + nonceA: ByteArray, + signature: ByteArray, + signerPublicKeyHex: String + ): Boolean { + val service = encryptionServiceRef?.get() ?: return false + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false + return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey) + } + + private data class CacheEntry( + val nickname: String, + val npub: String?, + val builtAtMs: Long, + val value: String + ) + + private object Cache { + var last: CacheEntry? = null + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt new file mode 100644 index 00000000..85ea6c9c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt @@ -0,0 +1,77 @@ +package com.bitchat.android.services.meshgraph + +import android.util.Log + +/** + * Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads. + * Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs] + */ +object GossipTLV { + // TLV type for a compact list of direct neighbor peerIDs (each 8 bytes) + const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u + + /** + * Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value. + */ + fun encodeNeighbors(peerIDs: List): ByteArray { + val unique = peerIDs.distinct().take(10) + val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray() + if (valueBytes.size > 255) { + // Safety check, though 10*8 = 80 bytes, so well under 255 + Log.w("GossipTLV", "Neighbors value exceeds 255, truncating") + } + return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes + } + + /** + * Scan a TLV-encoded announce payload and extract neighbor peerIDs. + * Returns null if the TLV is not present at all; returns an empty list if present with length 0. + */ + fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List? { + val result = mutableListOf() + var offset = 0 + while (offset + 2 <= payload.size) { + val type = payload[offset].toUByte() + val len = payload[offset + 1].toUByte().toInt() + offset += 2 + if (offset + len > payload.size) break + val value = payload.sliceArray(offset until offset + len) + offset += len + + if (type == DIRECT_NEIGHBORS_TYPE) { + // Value is N*8 bytes of peer IDs + var pos = 0 + while (pos + 8 <= value.size) { + val idBytes = value.sliceArray(pos until pos + 8) + result.add(bytesToPeerIdHex(idBytes)) + pos += 8 + } + return result // present (possibly empty) + } + } + // Not present + return null + } + + private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray { + val clean = hexString.lowercase().take(16) + val result = ByteArray(8) { 0 } + var idx = 0 + var out = 0 + while (idx + 1 < clean.length && out < 8) { + val byteStr = clean.substring(idx, idx + 2) + val b = byteStr.toIntOrNull(16)?.toByte() ?: 0 + result[out++] = b + idx += 2 + } + return result + } + + private fun bytesToPeerIdHex(bytes: ByteArray): String { + val sb = StringBuilder() + for (b in bytes.take(8)) { + sb.append(String.format("%02x", b)) + } + return sb.toString() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt new file mode 100644 index 00000000..785c249d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -0,0 +1,132 @@ +package com.bitchat.android.services.meshgraph + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Maintains an internal graph of the mesh based on gossip. + * Nodes are peers (peerID), edges are direct connections. + */ +class MeshGraphService private constructor() { + data class GraphNode(val peerID: String, val nickname: String?) + data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null) + data class GraphSnapshot(val nodes: List, val edges: List) + + // Map peerID -> nickname (may be null if unknown) + private val nicknames = ConcurrentHashMap() + // Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see + private val announcements = ConcurrentHashMap>() + // Latest announcement timestamp per peer (ULong from packet) + private val lastUpdate = ConcurrentHashMap() + + private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList())) + val graphState: StateFlow = _graphState.asStateFlow() + + /** + * Update graph from a verified announcement. + * Replaces previous neighbors for origin if this is newer (by timestamp). + */ + fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighborsOrNull: List?, timestamp: ULong) { + synchronized(this) { + // Always update nickname if provided + if (originNickname != null) nicknames[originPeerID] = originNickname + + // 1. Check timestamp first to ensure this is the latest word from the peer + val prevTs = lastUpdate[originPeerID] + if (prevTs != null && prevTs >= timestamp) { + // Older or equal update: ignore + return + } + lastUpdate[originPeerID] = timestamp + + // 2. Latest announcement determines state. + // If neighborsOrNull is null (TLV omitted), it means the peer is not reporting any neighbors (empty list). + val neighbors = neighborsOrNull ?: emptyList() + + // Filter out self-loops just in case + val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toSet() + announcements[originPeerID] = newSet + + publishSnapshot() + } + } + + fun updateNickname(peerID: String, nickname: String?) { + if (nickname == null) return + nicknames[peerID] = nickname + publishSnapshot() + } + + /** + * Remove a peer from the graph completely (e.g. when stale/offline). + */ + fun removePeer(peerID: String) { + synchronized(this) { + nicknames.remove(peerID) + announcements.remove(peerID) + lastUpdate.remove(peerID) + announcements.keys.toList().forEach { originPeerID -> + announcements.computeIfPresent(originPeerID) { _, neighbors -> + neighbors - peerID + } + } + publishSnapshot() + } + } + + private fun publishSnapshot() { + // Collect all known nodes from nicknames and announcements + val allNodes = mutableSetOf() + allNodes.addAll(nicknames.keys) + announcements.forEach { (origin, neighbors) -> + allNodes.add(origin) + allNodes.addAll(neighbors) + } + + val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID } + + val edges = mutableListOf() + val processedPairs = mutableSetOf>() + + // We only care about connections that exist in at least one direction. + // So iterating through all entries in `announcements` covers every declared edge. + announcements.forEach { (source, targets) -> + targets.forEach { target -> + val pair = if (source <= target) source to target else target to source + if (processedPairs.add(pair)) { + // This is a new pair we haven't evaluated yet + val (a, b) = pair + val aAnnouncesB = announcements[a]?.contains(b) == true + val bAnnouncesA = announcements[b]?.contains(a) == true + + if (aAnnouncesB && bAnnouncesA) { + edges.add(GraphEdge(a, b, isConfirmed = true)) + } else if (aAnnouncesB) { + edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a)) + } else if (bAnnouncesA) { + edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b)) + } + } + } + } + + val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b })) + _graphState.value = GraphSnapshot(nodeList, sortedEdges) + } + + companion object { + @Volatile private var INSTANCE: MeshGraphService? = null + fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) { + INSTANCE ?: MeshGraphService().also { INSTANCE = it } + } + + @org.jetbrains.annotations.TestOnly + fun resetForTesting() { + synchronized(this) { + INSTANCE = null + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt new file mode 100644 index 00000000..51814848 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.services.meshgraph + +import android.util.Log +import java.util.PriorityQueue + +/** + * Computes shortest paths on the current mesh graph snapshot using Dijkstra. + * Assumes unit edge weights. + */ +object RoutePlanner { + private const val TAG = "RoutePlanner" + + /** + * Return full path [src, ..., dst] if reachable, else null. + */ + fun shortestPath(src: String, dst: String): List? { + if (src == dst) return listOf(src) + val snapshot = MeshGraphService.getInstance().graphState.value + val neighbors = mutableMapOf>() + + // Only consider confirmed edges for routing + snapshot.edges.filter { it.isConfirmed }.forEach { e -> + neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b) + neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a) + } + // Ensure nodes known even if isolated + snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) } + + if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null + + val dist = mutableMapOf() + val prev = mutableMapOf() + val pq = PriorityQueue>(compareBy { it.second }) + + neighbors.keys.forEach { v -> + dist[v] = if (v == src) 0 else Int.MAX_VALUE + prev[v] = null + } + pq.add(src to 0) + + while (pq.isNotEmpty()) { + val top = pq.poll() ?: break + val (u, d) = top + if (d > (dist[u] ?: Int.MAX_VALUE)) continue + if (u == dst) break + neighbors[u]?.forEach { v -> + val alt = d + 1 + if (alt < (dist[v] ?: Int.MAX_VALUE)) { + dist[v] = alt + prev[v] = u + pq.add(v to alt) + } + } + } + + if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null + + val path = mutableListOf() + var cur: String? = dst + while (cur != null) { + path.add(cur) + cur = prev[cur] + } + path.reverse() + Log.d(TAG, "Computed path $path") + return path + } +} diff --git a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt new file mode 100644 index 00000000..212def6a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt @@ -0,0 +1,199 @@ +package com.bitchat.android.sync + +import java.security.MessageDigest +import kotlin.math.ceil +import kotlin.math.ln + +/** + * Golomb-Coded Set (GCS) filter implementation for sync. + * + * Hashing: + * - h64(id) = first 8 bytes of SHA-256 over the 16-byte PacketId (big-endian unsigned) + * - Map to range [0, M) via (h64 % M) + * + * Encoding (v1): + * - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers + * - For each delta x >= 1, write Golomb-Rice code with parameter P: + * q = (x - 1) >> P (unary q ones followed by a zero), then P low bits r = (x - 1) & ((1<= 1) + val m: Long, // Range M = N * 2^P + val data: ByteArray // Encoded GR bitstream + ) + + // Derive P from target FPR; FPR ~= 1 / 2^P + fun deriveP(targetFpr: Double): Int { + val f = targetFpr.coerceIn(0.000001, 0.25) + return ceil(ln(1.0 / f) / ln(2.0)).toInt().coerceAtLeast(1) + } + + // Rough capacity estimate: expected bits per element ~= P + 2 (quotient unary ~ around 2 bits) + fun estimateMaxElementsForSize(bytes: Int, p: Int): Int { + val bits = (bytes * 8).coerceAtLeast(8) + val per = (p + 2).coerceAtLeast(3) + return (bits / per).coerceAtLeast(1) + } + + fun buildFilter( + ids: List, // 16-byte PacketId bytes + maxBytes: Int, + targetFpr: Double + ): Params { + val p = deriveP(targetFpr) + val nCap = estimateMaxElementsForSize(maxBytes, p) + var trimmedN = ids.size.coerceAtMost(nCap) + + var finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L) + var selected = ids.take(trimmedN) + var mapped = selected.map { id -> + val v = h64(id) % finalM + if (v == 0L) 1L else v + }.distinct().sorted() + var encoded = encode(mapped, p) + + // If estimate was too optimistic, trim until it fits + while (encoded.size > maxBytes && trimmedN > 0) { + trimmedN = (trimmedN * 9) / 10 // drop 10% + finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L) + selected = ids.take(trimmedN) + mapped = selected.map { id -> + val v = h64(id) % finalM + if (v == 0L) 1L else v + }.distinct().sorted() + encoded = encode(mapped, p) + } + + return Params(p = p, m = finalM, data = encoded) + } + + fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray { + val values = ArrayList() + val reader = BitReader(data) + var acc = 0L + val mask = (1L shl p) - 1L + while (!reader.eof()) { + // Read unary quotient (q ones terminated by zero) + var q = 0L + while (true) { + val b = reader.readBit() ?: break + if (b == 1) q++ else break + } + if (reader.lastWasEOF) break + // Read remainder + val r = reader.readBits(p) ?: break + val x = (q shl p) + r + 1 + acc += x + if (acc >= m) break // out of range safeguard + values.add(acc) + } + return values.toLongArray() + } + + fun contains(sortedValues: LongArray, candidate: Long): Boolean { + var lo = 0 + var hi = sortedValues.size - 1 + while (lo <= hi) { + val mid = (lo + hi) ushr 1 + val v = sortedValues[mid] + if (v == candidate) return true + if (v < candidate) lo = mid + 1 else hi = mid - 1 + } + return false + } + + internal fun h64(id16: ByteArray): Long { + val md = MessageDigest.getInstance("SHA-256") + md.update(id16) + val d = md.digest() + var x = 0L + for (i in 0 until 8) { + x = (x shl 8) or ((d[i].toLong() and 0xFF)) + } + return x and 0x7fff_ffff_ffff_ffffL // positive + } + + private fun encode(sorted: List, p: Int): ByteArray { + val bw = BitWriter() + var prev = 0L + val mask = (1L shl p) - 1L + for (v in sorted) { + val delta = v - prev + prev = v + val x = delta + val q = (x - 1) ushr p + val r = (x - 1) and mask + // unary q ones then a zero + repeat(q.toInt()) { bw.writeBit(1) } + bw.writeBit(0) + // then P bits of r (MSB-first) + bw.writeBits(r, p) + } + return bw.toByteArray() + } + + // Simple MSB-first bit writer + private class BitWriter { + private val buf = ArrayList() + private var cur = 0 + private var nbits = 0 + fun writeBit(bit: Int) { + cur = (cur shl 1) or (bit and 1) + nbits++ + if (nbits == 8) { + buf.add(cur.toByte()) + cur = 0; nbits = 0 + } + } + fun writeBits(value: Long, count: Int) { + if (count <= 0) return + for (i in count - 1 downTo 0) { + val bit = ((value ushr i) and 1L).toInt() + writeBit(bit) + } + } + fun toByteArray(): ByteArray { + if (nbits > 0) { + val rem = cur shl (8 - nbits) + buf.add(rem.toByte()) + cur = 0; nbits = 0 + } + return buf.toByteArray() + } + } + + // Simple MSB-first bit reader + private class BitReader(private val data: ByteArray) { + private var i = 0 + private var nleft = 8 + private var cur = if (data.isNotEmpty()) (data[0].toInt() and 0xFF) else 0 + var lastWasEOF: Boolean = false + private set + fun eof() = i >= data.size + fun readBit(): Int? { + if (i >= data.size) { lastWasEOF = true; return null } + val bit = (cur ushr 7) and 1 + cur = (cur shl 1) and 0xFF + nleft-- + if (nleft == 0) { + i++ + if (i < data.size) { + cur = data[i].toInt() and 0xFF + nleft = 8 + } + } + return bit + } + fun readBits(count: Int): Long? { + var v = 0L + for (k in 0 until count) { + val b = readBit() ?: return null + v = (v shl 1) or b.toLong() + } + return v + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt new file mode 100644 index 00000000..42a10ceb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -0,0 +1,330 @@ +package com.bitchat.android.sync + +import android.util.Log +import com.bitchat.android.mesh.BluetoothPacketBroadcaster +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Gossip-based synchronization manager using on-demand GCS filters. + * Tracks seen public packets (ANNOUNCE, broadcast MESSAGE) and periodically requests sync + * from neighbors. Responds to REQUEST_SYNC by sending missing packets. + */ +class GossipSyncManager( + private val myPeerID: String, + private val scope: CoroutineScope, + private val configProvider: ConfigProvider +) { + interface Delegate { + fun sendPacket(packet: BitchatPacket) + fun sendPacketToPeer(peerID: String, packet: BitchatPacket) + fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket + } + + interface ConfigProvider { + fun seenCapacity(): Int // max packets we sync per request (cap across types) + fun gcsMaxBytes(): Int + fun gcsTargetFpr(): Double // percent -> 0.0..1.0 + } + + companion object { + private const val TAG = "GossipSyncManager" + } + + var delegate: Delegate? = null + + // Defaults (configurable constants) + private val defaultMaxBytes = SyncDefaults.DEFAULT_FILTER_BYTES + private val defaultFpr = SyncDefaults.DEFAULT_FPR_PERCENT + + // Stored packets for sync: + // - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId + private val messages = LinkedHashMap() + // - announcements: only keep latest per sender peerID + private val latestAnnouncementByPeer = ConcurrentHashMap>() + + private var periodicJob: Job? = null + private var cleanupJob: Job? = null + fun start() { + periodicJob?.cancel() + periodicJob = scope.launch(Dispatchers.IO) { + while (isActive) { + try { + delay(30_000) + sendRequestSync() + } catch (e: CancellationException) { throw e } + catch (e: Exception) { Log.e(TAG, "Periodic sync error: ${e.message}") } + } + } + + // Start periodic cleanup of stale announcements and messages + cleanupJob?.cancel() + cleanupJob = scope.launch(Dispatchers.IO) { + while (isActive) { + try { + delay(com.bitchat.android.util.AppConstants.Sync.CLEANUP_INTERVAL_MS) + pruneStaleAnnouncements() + } catch (e: CancellationException) { throw e } + catch (e: Exception) { Log.e(TAG, "Periodic cleanup error: ${e.message}") } + } + } + } + + fun stop() { + periodicJob?.cancel(); periodicJob = null + cleanupJob?.cancel(); cleanupJob = null + } + + @Synchronized + fun clear() { + synchronized(messages) { + messages.clear() + } + latestAnnouncementByPeer.clear() + Log.d(TAG, "Cleared all gossip sync messages and announcements") + } + + fun scheduleInitialSync(delayMs: Long = 5_000L) { + scope.launch(Dispatchers.IO) { + delay(delayMs) + sendRequestSync() + } + } + + fun scheduleInitialSyncToPeer(peerID: String, delayMs: Long = 5_000L) { + scope.launch(Dispatchers.IO) { + delay(delayMs) + sendRequestSyncToPeer(peerID) + } + } + + fun onPublicPacketSeen(packet: BitchatPacket) { + // Only ANNOUNCE or broadcast MESSAGE + val mt = MessageType.fromValue(packet.type) + val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))) + val isAnnouncement = (mt == MessageType.ANNOUNCE) + if (!isBroadcastMessage && !isAnnouncement) return + + val idBytes = PacketIdUtil.computeIdBytes(packet) + val id = idBytes.joinToString("") { b -> "%02x".format(b) } + + if (isBroadcastMessage) { + synchronized(messages) { + messages[id] = packet + // Enforce capacity (remove oldest when exceeded) + val cap = configProvider.seenCapacity().coerceAtLeast(1) + while (messages.size > cap) { + val it = messages.entries.iterator() + if (it.hasNext()) { it.next(); it.remove() } else break + } + } + } else if (isAnnouncement) { + // Ignore stale announcements older than STALE_PEER_TIMEOUT + val now = System.currentTimeMillis() + val age = now - packet.timestamp.toLong() + if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { + Log.d(TAG, "Ignoring stale ANNOUNCE (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)") + return + } + // senderID is fixed-size 8 bytes; map to hex string for key + val sender = packet.senderID.joinToString("") { b -> "%02x".format(b) } + latestAnnouncementByPeer[sender] = id to packet + // Enforce capacity (remove oldest when exceeded) + val cap = configProvider.seenCapacity().coerceAtLeast(1) + while (latestAnnouncementByPeer.size > cap) { + val it = latestAnnouncementByPeer.entries.iterator() + if (it.hasNext()) { it.next(); it.remove() } else break + } + } + } + + private fun sendRequestSync() { + val payload = buildGcsPayload() + + val packet = BitchatPacket( + type = MessageType.REQUEST_SYNC.value, + senderID = hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only + ) + // Sign and broadcast + val signed = delegate?.signPacketForBroadcast(packet) ?: packet + delegate?.sendPacket(signed) + } + + private fun sendRequestSyncToPeer(peerID: String) { + val payload = buildGcsPayload() + + val packet = BitchatPacket( + type = MessageType.REQUEST_SYNC.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbor only + ) + Log.d(TAG, "Sending sync request to $peerID (${payload.size} bytes)") + // Sign and send directly to peer + val signed = delegate?.signPacketForBroadcast(packet) ?: packet + delegate?.sendPacketToPeer(peerID, signed) + } + + fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { + // Decode GCS into sorted set for membership checks + val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) + fun mightContain(id: ByteArray): Boolean { + val v = GCSFilter.h64(id) % request.m + val nonZeroV = if (v == 0L) 1L else v + return GCSFilter.contains(sorted, nonZeroV) + } + + // 1) Announcements: send latest per peerID if remote doesn't have them + for ((_, pair) in latestAnnouncementByPeer.entries) { + val (id, pkt) = pair + val idBytes = hexToBytes(id) + if (!mightContain(idBytes)) { + // Send original packet unchanged to requester only (keep local TTL) + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + } + } + + // 2) Broadcast messages: send all they lack + val toSendMsgs = synchronized(messages) { messages.values.toList() } + for (pkt in toSendMsgs) { + val idBytes = PacketIdUtil.computeIdBytes(pkt) + if (!mightContain(idBytes)) { + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + } + } + } + + private fun hexStringToByteArray(hexString: String): ByteArray { + val result = ByteArray(8) { 0 } + var tempID = hexString + var index = 0 + while (tempID.length >= 2 && index < 8) { + val hexByte = tempID.substring(0, 2) + val byte = hexByte.toIntOrNull(16)?.toByte() + if (byte != null) result[index] = byte + tempID = tempID.substring(2) + index++ + } + return result + } + + private fun hexToBytes(hex: String): ByteArray { + val clean = if (hex.length % 2 == 0) hex else "0$hex" + val out = ByteArray(clean.length / 2) + var i = 0 + while (i < clean.length) { + out[i/2] = clean.substring(i, i+2).toInt(16).toByte() + i += 2 + } + return out + } + + private fun buildGcsPayload(): ByteArray { + // Collect candidates: latest announcement per peer + recent broadcast messages + val list = ArrayList() + // announcements + for ((_, pair) in latestAnnouncementByPeer) { + list.add(pair.second) + } + // messages + synchronized(messages) { + list.addAll(messages.values) + } + // sort by timestamp desc, then take up to min(seenCapacity, fit capacity) + list.sortByDescending { it.timestamp.toLong() } + + val maxBytes = try { configProvider.gcsMaxBytes() } catch (_: Exception) { defaultMaxBytes } + val fpr = try { configProvider.gcsTargetFpr() } catch (_: Exception) { defaultFpr } + val p = GCSFilter.deriveP(fpr) + val nMax = GCSFilter.estimateMaxElementsForSize(maxBytes, p) + val cap = configProvider.seenCapacity().coerceAtLeast(1) + val takeN = minOf(nMax, cap, list.size) + if (takeN <= 0) { + val p0 = GCSFilter.deriveP(fpr) + return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode() + } + val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) } + val params = GCSFilter.buildFilter(ids, maxBytes, fpr) + val mVal = if (params.m <= 0L) 1 else params.m + return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode() + } + + // Periodically remove stale announcements and all their messages + private fun pruneStaleAnnouncements() { + val now = System.currentTimeMillis() + val stalePeers = mutableListOf() + + // Identify stale announcements by age + for ((peerID, pair) in latestAnnouncementByPeer.entries) { + val pkt = pair.second + val age = now - pkt.timestamp.toLong() + if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { + stalePeers.add(peerID) + } + } + + if (stalePeers.isEmpty()) return + + // Remove announcements and their messages + var totalPrunedMsgs = 0 + for (peerID in stalePeers) { + // Count messages to be pruned for logging + val toRemove = mutableListOf() + synchronized(messages) { + for ((id, message) in messages) { + val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } + if (sender == peerID) toRemove.add(id) + } + } + totalPrunedMsgs += toRemove.size + + // Reuse existing removal which also clears announcement entry + removeAnnouncementForPeer(peerID) + } + + Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages") + } + + // Explicitly remove stored announcement for a given peer (hex ID) + fun removeAnnouncementForPeer(peerID: String) { + val key = peerID.lowercase() + if (latestAnnouncementByPeer.remove(key) != null) { + Log.d(TAG, "Removed stored announcement for peer $peerID") + } + + // Collect IDs to remove first to avoid modifying collection while iterating + val idsToRemove = mutableListOf() + synchronized(messages) { + for ((id, message) in messages) { + val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } + if (sender == key) { + idsToRemove.add(id) + } + } + } + + // Now remove the collected IDs + synchronized(messages) { + for (id in idsToRemove) { + messages.remove(id) + } + } + + if (idsToRemove.isNotEmpty()) { + Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements") + } + } +} diff --git a/app/src/main/java/com/bitchat/android/sync/PacketIdUtil.kt b/app/src/main/java/com/bitchat/android/sync/PacketIdUtil.kt new file mode 100644 index 00000000..4396ff5a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/sync/PacketIdUtil.kt @@ -0,0 +1,31 @@ +package com.bitchat.android.sync + +import com.bitchat.android.protocol.BitchatPacket +import java.security.MessageDigest + +/** + * Deterministic packet ID helper for sync purposes. + * Uses SHA-256 over a canonical subset of packet fields: + * [type | senderID | timestamp | payload] to generate a stable ID. + * Returns a 16-byte (128-bit) truncated hash for compactness. + */ +object PacketIdUtil { + fun computeIdBytes(packet: BitchatPacket): ByteArray { + val md = MessageDigest.getInstance("SHA-256") + md.update(packet.type.toByte()) + md.update(packet.senderID) + // Timestamp as 8 bytes big-endian + val ts = packet.timestamp.toLong() + for (i in 7 downTo 0) { + md.update(((ts ushr (i * 8)) and 0xFF).toByte()) + } + md.update(packet.payload) + val digest = md.digest() + return digest.copyOf(16) // 128-bit ID + } + + fun computeIdHex(packet: BitchatPacket): String { + return computeIdBytes(packet).joinToString("") { b -> "%02x".format(b) } + } +} + diff --git a/app/src/main/java/com/bitchat/android/sync/SyncDefaults.kt b/app/src/main/java/com/bitchat/android/sync/SyncDefaults.kt new file mode 100644 index 00000000..970c8afb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/sync/SyncDefaults.kt @@ -0,0 +1,11 @@ +package com.bitchat.android.sync + +object SyncDefaults { + // Default values used when debug prefs are unavailable + const val DEFAULT_FILTER_BYTES: Int = 256 + const val DEFAULT_FPR_PERCENT: Double = 1.0 + + // Receiver-side hard cap to avoid DoS (also enforced in RequestSyncPacket) + const val MAX_ACCEPT_FILTER_BYTES: Int = 1024 +} + diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt new file mode 100644 index 00000000..e36ee7be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -0,0 +1,605 @@ +package com.bitchat.android.ui + +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette + +/** + * Building blocks for the redesigned About sheet. + * + * Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for + * preferences, whereas these are pure presentation. + */ + +/** Horizontal inset shared by every About section, so cards and labels align to one grid. */ +internal val AboutHorizontalPadding = 20.dp + +/** Card corner radius for grouped rows. */ +internal val AboutCardShape = RoundedCornerShape(16.dp) + +/** Leading icon column in settings-style sheet rows. */ +internal val SheetRowLeadingSlot = 22.dp +internal val SheetRowLeadingGutter = 16.dp +internal val SheetRowHorizontal = 16.dp +internal val SheetRowVertical = 13.dp + +/** + * Exact height of a people-list row. + * + * Fixed rather than derived from content: these lists reorder themselves constantly, and a row + * whose height depends on its content makes the whole card change height every time the order + * changes. Equals the leading glyph plus [SheetRowVertical] above and below. + */ +internal val SheetRowHeight = SheetRowLeadingSlot + SheetRowVertical * 2 +internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter +/** Selection indicator sized for [SheetRowLeadingSlot]. */ +internal val SheetRowSelectedDot = 12.dp + +/** + * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. + */ +enum class AboutTab { + Info, + Settings, +} + +/** + * Small uppercase section label, e.g. `SETTINGS`. + * + * Uppercasing happens here rather than in the string resource so translators supply natural + * sentence case and locales without a case distinction are unaffected. + */ +@Composable +internal fun AboutSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp) + ) +} + +/** + * Icon + title on one line, optional short subtitle beneath. Used by location / network sheets. + */ +@Composable +internal fun SheetIconSectionHeader( + @DrawableRes iconRes: Int, + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ +@Composable +internal fun SheetCardDivider() { + val colorScheme = MaterialTheme.colorScheme + HorizontalDivider( + modifier = Modifier.padding(start = SheetRowDividerInset), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) +} + +/** + * Centered app identity block: logo, wordmark, tagline, version. + */ +@Composable +internal fun AboutHero( + versionName: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = BitChatIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.app_name), + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 40.sp, + // Monospace at display size leaves too much air between glyphs; pull it in slightly + // so the wordmark reads as a single unit. + letterSpacing = (-0.5).sp, + color = colorScheme.primary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.about_tagline), + fontFamily = BitchatFontFamily, + fontSize = 16.sp, + color = colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.version_prefix, versionName), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary + ) + } +} + +/** + * Two-up tab bar with a sliding underline indicator. + * + * The indicator animates its offset rather than cross-fading two static bars, which is what + * makes the switch feel physically connected to the tap. + */ +@Composable +internal fun AboutTabBar( + selected: AboutTab, + onSelect: (AboutTab) -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val density = LocalDensity.current + + var rowWidth by remember { mutableStateOf(0.dp) } + val tabWidth = rowWidth / 2 + val indicatorOffset by animateDpAsState( + targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "aboutTabIndicator" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .onSizeChanged { size -> + rowWidth = with(density) { size.width.toDp() } + } + ) { + Row(modifier = Modifier.fillMaxWidth()) { + AboutTabLabel( + text = stringResource(R.string.about_tab_info), + isSelected = selected == AboutTab.Info, + onClick = { onSelect(AboutTab.Info) }, + modifier = Modifier.weight(1f) + ) + AboutTabLabel( + text = stringResource(R.string.about_tab_settings), + isSelected = selected == AboutTab.Settings, + onClick = { onSelect(AboutTab.Settings) }, + modifier = Modifier.weight(1f) + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + Box( + modifier = Modifier + // Lambda overload: the offset is animated every frame, and the non-lambda + // version would invalidate composition rather than just layout. + .offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) } + .width(tabWidth) + .height(2.dp) + .background(colorScheme.primary) + ) + } + } +} + +@Composable +private fun AboutTabLabel( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + val color by animateColorAsState( + targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "aboutTabLabelColor" + ) + + Box( + modifier = modifier + .height(44.dp) + .clickable(onClickLabel = text) { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = color + ) + } +} + +/** One line of the "How To Use" list: an icon plus a single instruction. */ +@Composable +private fun AboutInstructionRow( + @DrawableRes iconRes: Int, + text: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Text( + text = text, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + } +} + +/** + * The "How To Use" tab: a short, scannable list of the gestures that are not self-evident. + */ +@Composable +internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { + val colorScheme = MaterialTheme.colorScheme + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.about_how_to_use_heading), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + top = 20.dp, + bottom = 8.dp + ) + ) + + AboutInstructionRow( + iconRes = R.drawable.ic_spec_person, + text = stringResource(R.string.about_howto_nickname) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_globe, + text = stringResource(R.string.about_howto_channels) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_people, + text = stringResource(R.string.about_howto_people) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_bookmark_outline, + text = stringResource(R.string.about_howto_bookmark) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_mention, + text = stringResource(R.string.about_howto_mention) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_command, + text = stringResource(R.string.about_howto_commands) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_waveform, + text = stringResource(R.string.about_howto_panic) + ) + } +} + +/** + * Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers. + */ +@Composable +internal fun AboutFeatureCard(modifier: Modifier = Modifier) { + val features = listOf( + Triple( + R.drawable.ic_spec_wifi_off, + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc + ), + Triple( + R.drawable.ic_spec_lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + R.drawable.ic_spec_globe, + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc + ), + Triple( + R.drawable.ic_spec_eye_off, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + ) + + Column(modifier = modifier.fillMaxWidth()) { + features.forEach { (icon, titleRes, descRes) -> + AboutFeatureRow( + iconRes = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) + ) + } + } +} + +@Composable +private fun AboutFeatureRow( + @DrawableRes iconRes: Int, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. + */ +@Composable +internal fun BitchatBadge( + text: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + color = colorScheme.primary + ) + } +} + +// MARK: - Shared bottom-sheet primitives + +/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */ +internal val SheetHorizontalPadding = 16.dp + +/** + * Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`. + */ +@Composable +internal fun SheetSectionLabel( + text: String, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier + .fillMaxWidth() + .padding( + start = SheetHorizontalPadding + 14.dp, + end = SheetHorizontalPadding, + top = 20.dp, + bottom = 6.dp + ) + ) +} + +/** + * Circular tinted badge holding a section's icon, used at the top of the sheets. + */ +@Composable +internal fun SheetHeaderBadge( + icon: ImageVector, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .size(44.dp) + .background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(26.dp) + ) + } +} + +/** + * Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`. + * + * Tinted fill plus an outline rather than a solid red button: the action is legitimate but + * rarely wanted, and a solid red block would dominate the sheet. + */ +@Composable +internal fun SheetDestructiveButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isDestructive: Boolean = true +) { + val colorScheme = MaterialTheme.colorScheme + val accent = if (isDestructive) colorScheme.error else colorScheme.primary + + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(44.dp), + shape = RoundedCornerShape(10.dp), + color = accent.copy(alpha = 0.10f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.30f)) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = accent + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 83edea61..54ab0318 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,13 +1,54 @@ package com.bitchat.android.ui -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape +import android.content.Intent +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Speed +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -15,21 +56,254 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.util.downloadPhaseLabel +import com.bitchat.android.hotspot.HotspotActivity +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.nostr.NostrProofOfWork +import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager /** - * About Sheet for bitchat app information - * Matches the design language of LocationChannelsSheet + * Theme selection chip with Apple-like styling + */ +@Composable +private fun ThemeChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + // Cross-fade the chip so switching theme does not read as two separate flashes (the chip + // recolouring plus the whole app recolouring underneath it). + val containerColor by animateColorAsState( + targetValue = if (selected) colorScheme.primary else colorScheme.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipContainer" + ) + val labelColor by animateColorAsState( + targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipLabel" + ) + + Surface( + modifier = modifier, + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = containerColor + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = labelColor + ) + } + } +} + +@Composable +private fun LanguageSettingsRow( + selectedLanguageName: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.about_app_language), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + text = selectedLanguageName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.UnfoldMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun LanguageMenuItem( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = onClick, + trailingIcon = { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) +} + +/** + * Unified settings toggle row with icon, title, subtitle, and switch + * Apple-like design with proper spacing + */ +@Composable +private fun SettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + + // Colours cross-fade so a row becoming available (Tor finishing bootstrap) eases in rather + // than popping. + val iconTint by animateColorAsState( + targetValue = if (enabled) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowIcon" + ) + val titleColor by animateColorAsState( + targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowTitle" + ) + val subtitleColor by animateColorAsState( + targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowSubtitle" + ) + + Row( + modifier = Modifier + .fillMaxWidth() + // The whole row toggles, not just the switch: a 14.dp-tall switch is a poor target + // when there is a full-width row sitting right next to it. + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = titleColor + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = subtitleColor, + lineHeight = 17.sp + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + interactionSource = interactionSource, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = colorScheme.primary, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +/** + * Apple-like About/Settings Sheet with high-quality design + * Professional UX optimized for checkout scenarios */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutSheet( isPresented: Boolean, onDismiss: () -> Unit, + onShowDebug: (() -> Unit)? = null, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -42,356 +316,978 @@ fun AboutSheet( "1.0.0" // fallback version } } - - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = false + + val lazyListState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "topBarAlpha" ) - - // Color scheme matching LocationChannelsSheet + val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardBlue = Color(0xFF007AFF) // iOS blue - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - + val palette = LocalBitchatPalette.current + var selectedTab by remember { mutableStateOf(AboutTab.Info) } + val supportedLanguages = remember(context) { + LanguagePreferenceManager.supportedLanguages(context) + } + var selectedLanguageTag by remember { + mutableStateOf(LanguagePreferenceManager.currentLanguageTag()) + } + var showLanguagePicker by remember { mutableStateOf(false) } + if (isPresented) { - ModalBottomSheet( + BitchatBottomSheet( + modifier = modifier, onDismissRequest = onDismiss, - sheetState = sheetState, - modifier = modifier ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Header - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Bottom - ) { - Text( - text = "bitchat", - fontSize = 18.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - - Text( - text = "v$versionName", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f), - style = MaterialTheme.typography.bodySmall.copy( - baselineShift = BaselineShift(0.1f) - ) - ) - } - - Text( - text = "decentralized mesh messaging with end-to-end encryption", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + // Header Section - App Identity + item(key = "hero") { + AboutHero(versionName = versionName ?: "") } - } - - // Features section - item { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - FeatureCard( - icon = Icons.Filled.Bluetooth, - iconColor = standardBlue, - title = "offline mesh chat", - description = "communicate directly via bluetooth le without internet or servers. messages relay through nearby devices to extend range.", - modifier = Modifier.fillMaxWidth() - ) - - FeatureCard( - icon = Icons.Filled.Public, - iconColor = standardGreen, - title = "online geohash channels", - description = "connect with people in your area using geohash-based channels. extend the mesh using public internet relays.", - modifier = Modifier.fillMaxWidth() - ) - - FeatureCard( - icon = Icons.Filled.Lock, - iconColor = if (isDark) Color(0xFFFFD60A) else Color(0xFFF5A623), - title = "end-to-end encryption", - description = "private messages are encrypted. channel messages are public.", - modifier = Modifier.fillMaxWidth() - ) - } - } - // Appearance section (theme toggle) - item { - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "appearance", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface.copy(alpha = 0.8f) + item(key = "tabs") { + AboutTabBar( + selected = selectedTab, + onSelect = { selectedTab = it }, + modifier = Modifier.padding(top = 24.dp) ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = themePref == com.bitchat.android.ui.theme.ThemePreference.System, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, - label = { Text("system", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = themePref == com.bitchat.android.ui.theme.ThemePreference.Light, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, - label = { Text("light", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = themePref == com.bitchat.android.ui.theme.ThemePreference.Dark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, - label = { Text("dark", fontFamily = FontFamily.Monospace) } - ) + } + + if (selectedTab == AboutTab.Info) { + // What the app is, then how to drive it. Both are reference material a + // new user reads once, so they belong on the same tab. + item(key = "features") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_about)) + AboutFeatureCard() + } + } + + item(key = "how_to_use") { + AboutHowToUseSection() } } - } - // Network (Tor) section - item { - val ctx = LocalContext.current - val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(ctx)) } - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "network", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface.copy(alpha = 0.8f) - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.OFF, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.OFF - com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value) - }, - label = { Text("tor off", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.ON, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.ON - com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value) - }, - label = { + if (selectedTab == AboutTab.Settings) { + // Appearance Section + item(key = "appearance") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_theme)) + val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState() + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("tor on", fontFamily = FontFamily.Monospace) - // Status indicator (red/orange/green) moved inside the "tor on" button - val statusColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - else -> Color.Red - } - Surface( - color = statusColor, - shape = RoundedCornerShape(50) - ) { Box(Modifier.size(8.dp)) } + ThemeChip( + label = stringResource(R.string.about_system), + selected = themePref.isSystem, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_light), + selected = themePref.isLight, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_dark), + selected = themePref.isDark, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + modifier = Modifier.weight(1f) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.chat_ui_bubbles), + selected = chatUiMode.isBubbles, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.chat_ui_matrix), + selected = chatUiMode.isMatrix, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) }, + modifier = Modifier.weight(1f) + ) } } - ) + } } - Text( - text = "route internet over tor for enhanced privacy.", - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) + } - // Debug status (temporary) - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + item(key = "language") { + val selectedLanguageName = supportedLanguages + .firstOrNull { it.languageTag == selectedLanguageTag } + ?.endonym + ?: stringResource(R.string.about_system_default) + + Column { + AboutSectionLabel(text = stringResource(R.string.about_language)) + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = AboutCardShape, + ) { + LanguageSettingsRow( + selectedLanguageName = selectedLanguageName, + onClick = { showLanguagePicker = true }, + ) + } + DropdownMenu( + expanded = showLanguagePicker, + onDismissRequest = { showLanguagePicker = false }, + modifier = Modifier.width(maxWidth), + ) { + LanguageMenuItem( + label = stringResource(R.string.about_system_default), + selected = selectedLanguageTag.isEmpty(), + onClick = { + showLanguagePicker = false + if (selectedLanguageTag.isNotEmpty()) { + selectedLanguageTag = "" + LanguagePreferenceManager.setLanguage("") + } + }, + ) + HorizontalDivider() + supportedLanguages.forEach { language -> + LanguageMenuItem( + label = language.endonym, + selected = selectedLanguageTag == language.languageTag, + onClick = { + showLanguagePicker = false + if (language.languageTag != selectedLanguageTag) { + selectedLanguageTag = language.languageTag + LanguagePreferenceManager.setLanguage(language.languageTag) + } + }, + ) + } + } + } + } + } + + // Settings Section - Unified Card with Toggles + item(key = "settings") { + LaunchedEffect(Unit) { PoWPreferenceManager.init(context) } + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } + var liveVoiceEnabled by remember { + mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)) + } + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + val torAvailable = remember { torProvider.isTorAvailable() } + + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_settings)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + // Background Mode Toggle + SettingsToggleRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_background_title), + subtitle = stringResource(R.string.about_background_desc), + checked = backgroundEnabled, + onCheckedChange = { enabled -> + backgroundEnabled = enabled + com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled) + if (!enabled) { + com.bitchat.android.service.MeshForegroundService.stop(context) + } else { + com.bitchat.android.service.MeshForegroundService.start(context) + } + } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + + SettingsToggleRow( + icon = Icons.Filled.Mic, + title = "Live push-to-talk", + subtitle = "Play voice bursts live on the mesh; voice notes are always sent on release", + checked = liveVoiceEnabled, + onCheckedChange = { enabled -> + liveVoiceEnabled = enabled + com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(context, enabled) + } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + + // Proof of Work Toggle + SettingsToggleRow( + icon = Icons.Filled.Speed, + title = stringResource(R.string.about_pow), + subtitle = stringResource(R.string.about_pow_tip), + checked = powEnabled, + onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + + // Tor Toggle + SettingsToggleRow( + icon = Icons.Filled.Security, + title = stringResource(R.string.about_tor_title), + subtitle = stringResource(R.string.about_tor_route), + checked = torMode.value == TorMode.ON, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode.value = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode.value) + } + }, + enabled = torAvailable, + statusIndicator = if (torMode.value == TorMode.ON) { + { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error + } + Surface( + color = statusColor, + shape = CircleShape, + modifier = Modifier.size(8.dp) + ) {} + } + } else null + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Prepare App for Sharing Section === + val apkViewModel: ApkDownloadViewModel = viewModel() + val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() + val apkStatus = apkUiState.apkStatus + val releaseStatus = apkUiState.releaseStatus + val downloadProgress = apkUiState.downloadProgress + val shareableApk = when (apkStatus) { + is ApkPreparationStatus.Ready -> apkStatus + is ApkPreparationStatus.Downloading -> + apkStatus.shareableFallback + else -> null + } + val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known) + ?.takeIf { it.isNewerThanSharedApk } + + // Handle one-shot effects (navigation, toasts, share intents) + LaunchedEffect(Unit) { + apkViewModel.onEvent(ApkUiEvent.CheckStatus) + apkViewModel.effect.collect { effect -> + when (effect) { + is ApkUiEffect.NavigateToHotspot -> { + val intent = Intent(context, HotspotActivity::class.java) + intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath) + context.startActivity(intent) + } + is ApkUiEffect.ShareApk -> { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(Intent.EXTRA_STREAM, effect.apkUri) + clipData = android.content.ClipData.newRawUri("", effect.apkUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(intent, effect.chooserTitle).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(chooser) + } + is ApkUiEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + // Prepare App for Sharing Row + Row( + modifier = Modifier + .fillMaxWidth() + // Enabled by the same mapping that decides what the tap + // does, so the row can never look tappable and do + // nothing. + .clickable( + enabled = prepareRowTapAction( + apkStatus, + releaseStatus + ) != null + ) { + apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (shareableApk != null) { + Icons.Default.Share + } else { + Icons.Default.CloudDownload + }, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (shareableApk != null) { + stringResource(R.string.prepare_apk_ready_title) + } else { + stringResource(R.string.prepare_apk_title) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + if (availableUpdate != null) { + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + stringResource( + R.string.prepare_apk_update_available, + availableUpdate.version + ) + ) + } + }, + state = rememberTooltipState() + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource( + R.string.prepare_apk_update_warning + ), + tint = colorScheme.tertiary, + modifier = Modifier + .padding(start = 6.dp) + .size(18.dp) + .clickable { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + } + ) + } + } + } + Text( + text = when (val status = apkStatus) { + is ApkPreparationStatus.Loading -> stringResource(R.string.checking) + is ApkPreparationStatus.NotDownloaded -> + stringResource( + R.string.prepare_apk_status_not_downloaded + ) + is ApkPreparationStatus.Ready -> { + val source = when { + status.source == UniversalApkManager.ApkSource.DOWNLOADED -> + stringResource(R.string.prepare_apk_source_downloaded) + status.variant == ShareableApkVariant.ARM64 -> + stringResource(R.string.prepare_apk_source_installed_arm64) + else -> + stringResource(R.string.prepare_apk_source_installed) + } + stringResource( + R.string.prepare_apk_ready_detail, + status.version, + status.sizeMB, + source + ) + } + is ApkPreparationStatus.Downloading -> + // Only the transfer has a percentage worth + // showing; the other phases are named + // instead of pretending to be at 0%. + if (status.phase.hasMeasurableProgress) { + stringResource(R.string.prepare_apk_status_downloading, downloadProgress) + } else { + stringResource(downloadPhaseLabel(status.phase)) + } + is ApkPreparationStatus.Resumable -> + stringResource( + R.string.prepare_apk_status_resumable, + context.resolveApkFailureMessage( + status.failure + ), + status.progressPercent + ) + is ApkPreparationStatus.Error -> + context.resolveApkFailureMessage( + status.failure + ) + }, + style = MaterialTheme.typography.bodySmall, + color = when (apkStatus) { + is ApkPreparationStatus.Error -> colorScheme.error + is ApkPreparationStatus.Resumable -> colorScheme.primary + else -> colorScheme.onSurface.copy(alpha = 0.6f) + }, + lineHeight = 16.sp + ) + + // Progress lives in the column, not the trailing slot, + // which leaves that slot free for a single control. + ApkDownloadProgressBar( + status = apkStatus, + progressPercent = downloadProgress + ) + } + + // One control, one width, in every state. The progress + // readout moved into the column above, so nothing else + // competes for this slot. + when (apkStatus) { + is ApkPreparationStatus.Downloading -> + ApkPrepareRowIconButton( + icon = Icons.Default.Close, + description = stringResource( + R.string.prepare_apk_stop + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.CancelDownload + ) + } + ) + is ApkPreparationStatus.Ready -> { + if ( + apkStatus.source == + UniversalApkManager.ApkSource.INSTALLED + ) { + ApkPrepareRowIconButton( + icon = Icons.Default.CloudDownload, + description = stringResource( + R.string.prepare_apk_get_universal + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + }, + tint = colorScheme.primary + ) + } else if ( + apkStatus.source == + UniversalApkManager.ApkSource.DOWNLOADED + ) { + ApkPrepareRowIconButton( + icon = Icons.Default.Delete, + description = stringResource( + R.string.prepare_apk_button_delete + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DeleteClicked + ) + }, + tint = colorScheme.error + ) + } + } + is ApkPreparationStatus.Resumable, + is ApkPreparationStatus.Error -> + ApkPrepareRowIconButton( + icon = Icons.Default.Refresh, + description = stringResource( + R.string.prepare_apk_retry + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.PrepareRowClicked + ) + }, + tint = colorScheme.primary + ) + else -> {} + } + } + + // Prepare Dialog + if (apkUiState.showPrepareDialog) { + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, + title = { + Text( + text = stringResource( + if (availableUpdate != null) { + R.string.prepare_apk_update_dialog_title + } else { + R.string.prepare_apk_dialog_title + } + ), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = if (availableUpdate != null) { + stringResource( + R.string.prepare_apk_update_dialog_message, + availableUpdate.version, + availableUpdate.sizeMB + ) + } else { + stringResource( + R.string.prepare_apk_dialog_message_unknown_size + ) + }, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button(onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDownload) + }) { + Text(stringResource(R.string.prepare_apk_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // Delete Dialog + if (apkUiState.showDeleteDialog) { + val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0 + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }, + title = { + Text( + text = stringResource(R.string.prepare_apk_delete_confirm), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = stringResource(R.string.prepare_apk_delete_message, sizeMB), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDelete) + }, + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = colorScheme.error + ) + ) { + Text( + stringResource( + R.string.prepare_apk_button_delete + ) + ) + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // A GitHub update is optional. Keep sharing visible while the + // replacement downloads or while metadata refreshes. + val canShareAPK = shareableApk != null + + AnimatedVisibility( + visible = canShareAPK, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Hotspot Row === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_via), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_via_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Bluetooth/Email Row (Fallback) === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_other), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_other_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + // APK Share Dialog + ApkShareExplanationDialog( + show = apkUiState.showShareApkDialog, + onConfirm = { + apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare) + }, + onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) } + ) + } + } + + } + } + + // Tor unavailable hint + if (!torAvailable) { Text( - text = "tor status: " + - (if (torStatus.running) "running" else "stopped") + - ", bootstrap=" + torStatus.bootstrapPercent + "%", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.75f) + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + 16.dp, + top = 8.dp + ) ) - val last = torStatus.lastLogLine - if (last.isNotEmpty()) { + } + } + } + + // PoW Difficulty Slider (when enabled) + item(key = "pow_slider") { + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + + if (powEnabled) { + Column(modifier = Modifier.padding(top = 12.dp)) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.about_difficulty), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + AnimatedCountLabel( + count = powDifficulty, + text = stringResource( + R.string.about_difficulty_value, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = colorScheme.onSurfaceVariant + ) + } + + Slider( + value = powDifficulty.toFloat(), + onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + valueRange = 0f..32f, + steps = 31, + colors = SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant + ) + ) + + AnimatedCountLabel( + count = powDifficulty, + text = when { + powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) + powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) + powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) + powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) + powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) + powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) + else -> stringResource(R.string.about_pow_desc_extreme) + }, + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) + } + } + } + } + } + + // Tor Status (when enabled) + item(key = "tor_status") { + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + + if (torMode.value == TorMode.ON) { + Column(modifier = Modifier.padding(top = 12.dp)) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error + } + Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} + Text( + text = if (torStatus.running) { + stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent) + } else { + stringResource(R.string.about_tor_disconnected) + }, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + } + if (torStatus.lastLogLine.isNotEmpty()) { + Text( + text = torStatus.lastLogLine.take(120), + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + maxLines = 2 + ) + } + } + } + } + } + } + + } // end Settings tab + + // Footer + item(key = "footer") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (selectedTab == AboutTab.Settings && onShowDebug != null) { + TextButton(onClick = onShowDebug) { Text( - text = "last: " + last.take(160), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) + text = stringResource(R.string.about_debug_settings), + fontSize = 13.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.primary ) } } - } - } - } - - // Emergency warning - item { - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color.Red.copy(alpha = 0.08f), - shape = RoundedCornerShape(12.dp) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = "Warning", - tint = Color(0xFFBF1A1A), - modifier = Modifier.size(16.dp) + Text( + text = stringResource(R.string.about_footer), + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "emergency data deletion", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = Color(0xFFBF1A1A) - ) - - Text( - text = "tip: triple-click the app title to emergency delete all stored data including messages, keys, and settings.", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } + Spacer(modifier = Modifier.height(20.dp)) } } } - - // Version and footer space - item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "open source • privacy first • decentralized", - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) - ) - - // Add extra space at bottom for gesture area - Spacer(modifier = Modifier.height(16.dp)) - } + + // TopBar + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(64.dp) + .background(colorScheme.background.copy(alpha = topBarAlpha)) + ) { + val dismiss = LocalSheetDismiss.current + CloseButton( + onClick = { dismiss?.invoke() ?: onDismiss() }, + modifier = modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 16.dp), + ) } } } } } -@Composable -private fun FeatureCard( - icon: ImageVector, - iconColor: Color, - title: String, - description: String, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), - shape = RoundedCornerShape(12.dp) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = icon, - contentDescription = title, - tint = iconColor, - modifier = Modifier.size(20.dp) - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - - Text( - text = description, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - lineHeight = 15.sp - ) - } - } - } -} - -@Composable -private fun FeatureItem(text: String) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top - ) { - Text( - text = "•", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - - Text( - text = text, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.weight(1f) - ) - } -} - /** * Password prompt dialog for password-protected channels * Kept as dialog since it requires user input @@ -412,7 +1308,7 @@ fun PasswordPromptDialog( onDismissRequest = onDismiss, title = { Text( - text = "Enter Channel Password", + text = stringResource(R.string.pwd_prompt_title), style = MaterialTheme.typography.titleMedium, color = colorScheme.onSurface ) @@ -420,7 +1316,7 @@ fun PasswordPromptDialog( text = { Column { Text( - text = "Channel $channelName is password protected. Enter the password to join.", + text = stringResource(R.string.pwd_prompt_message, channelName ?: ""), style = MaterialTheme.typography.bodyMedium, color = colorScheme.onSurface ) @@ -429,9 +1325,9 @@ fun PasswordPromptDialog( OutlinedTextField( value = passwordInput, onValueChange = onPasswordChange, - label = { Text("Password", style = MaterialTheme.typography.bodyMedium) }, + label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) }, textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = colorScheme.primary, @@ -443,7 +1339,7 @@ fun PasswordPromptDialog( confirmButton = { TextButton(onClick = onConfirm) { Text( - text = "Join", + text = stringResource(R.string.join), style = MaterialTheme.typography.bodyMedium, color = colorScheme.primary ) @@ -452,7 +1348,95 @@ fun PasswordPromptDialog( dismissButton = { TextButton(onClick = onDismiss) { Text( - text = "Cancel", + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + } + }, + containerColor = colorScheme.surface, + tonalElevation = 8.dp + ) + } +} + + +/** + * Dialog explaining APK sharing feature before sharing + */ +@Composable +private fun ApkShareExplanationDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + if (show) { + val colorScheme = MaterialTheme.colorScheme + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = stringResource(R.string.share_apk_title), + style = MaterialTheme.typography.titleLarge, + color = colorScheme.onSurface + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.share_apk_explanation), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + + // Info box with receiver instructions + Surface( + color = colorScheme.primaryContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.share_apk_receiver_instructions), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.8f), + lineHeight = 18.sp + ) + } + } + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text( + text = stringResource(R.string.share_apk_confirm), + style = MaterialTheme.typography.bodyMedium + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel), style = MaterialTheme.typography.bodyMedium, color = colorScheme.onSurface ) diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt new file mode 100644 index 00000000..ecf5eb69 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import com.bitchat.android.ui.theme.BitchatMotion + +/** + * Counts that roll to their new value instead of snapping. + * + * Peer counts change on their own, without the user doing anything, so a hard digit swap is easy + * to miss and looks like a rendering fault when it is noticed. Sliding the digits in the + * direction the number moved — up when someone joins, down when someone leaves — conveys the + * change without needing a separate indicator. + */ +@Composable +fun AnimatedCount( + count: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + prefix: String = "", +) { + AnimatedContent( + targetState = count, + transitionSpec = { + val goingUp = targetState > initialState + ( + slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { height -> if (goingUp) height else -height } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)) + ).togetherWith( + slideOutVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + targetOffsetY = { height -> if (goingUp) -height else height } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)) + // Clip so the outgoing digit cannot bleed past the text bounds mid-transition. + ) using SizeTransform(clip = true) + }, + modifier = modifier, + label = "animatedCount" + ) { value -> + Text( + text = "$prefix$value", + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} + +/** + * Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`. + * + * Used where the number is not isolated in its own composable and cannot be rolled on its own. + * The transition is keyed on [count] rather than on [text], so a label changing for some other + * reason — a locale switch, say — does not animate. + */ +@Composable +fun AnimatedCountLabel( + count: Int, + text: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, +) { + AnimatedContent( + targetState = count, + transitionSpec = { + fadeIn(tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)) + .togetherWith(fadeOut(tween(BitchatMotion.QUICK_MS))) + .using(SizeTransform(clip = false)) + }, + modifier = modifier, + label = "animatedCountLabel" + ) { state -> + // Captured per state, so the outgoing copy keeps rendering the label it entered with. + // Reading `text` directly would show the *new* label on both sides of the cross-fade, + // turning the transition into a flicker between two identical strings. + val stateText = remember(state) { text } + Text( + text = stateText, + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt new file mode 100644 index 00000000..26c8259a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope + +/** + * Motion for a row moving to a new position, or resizing in place. + * + * People lists reorder themselves constantly and without user input — someone sends a DM and jumps + * to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between + * positions makes the list feel unreliable and costs the reader their place in it. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +private val RowBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} + +/** Entry fade for a row that was not previously in the list. */ +private val RowEnterSpec: FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f) + +/** + * A vertical list whose rows animate when they are added, removed, or reordered. + * + * Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where + * nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives + * the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list + * needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so + * nothing is lost by composing every row. + * + * Rows are keyed so identity survives reordering. Without stable keys a row that moved would look + * like a different row appearing, and would fade instead of sliding. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun AnimatedRowColumn( + items: List, + key: (T) -> Any, + modifier: Modifier = Modifier, + row: @Composable (index: Int, item: T) -> Unit +) { + LookaheadScope { + Column(modifier = modifier) { + items.forEachIndexed { index, item -> + key(key(item)) { + // Fades the row in on the composition it first appears, then never again — + // reordering an existing row must slide, not blink. + val enter = remember { Animatable(0f) } + LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) } + + Box( + modifier = Modifier + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = RowBoundsTransform + ) + .graphicsLayer { alpha = enter.value } + ) { + row(index, item) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt new file mode 100644 index 00000000..4d0c2d5b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -0,0 +1,531 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.content.Context +import android.util.Log +import androidx.annotation.StringRes +import androidx.core.content.FileProvider +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.bitchat.android.R +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.AppVersion +import com.bitchat.android.util.GitHubReleaseClient +import com.bitchat.android.util.LatestReleaseProvider +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager +import com.bitchat.android.util.WorkManagerApkDownloader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// --- State --- + +sealed class ApkPreparationStatus { + object Loading : ApkPreparationStatus() + object NotDownloaded : ApkPreparationStatus() + data class Ready( + val version: String, + val sizeMB: Int, + val source: UniversalApkManager.ApkSource, + val variant: ShareableApkVariant + ) : ApkPreparationStatus() + /** [phase] is what the operation is actually doing; only a transfer has a real percentage. */ + data class Downloading( + val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource, + val shareableFallback: Ready? = null + ) : ApkPreparationStatus() + data class Resumable( + val progressPercent: Int, + val failure: ApkFailureMessage + ) : ApkPreparationStatus() + data class Error(val failure: ApkFailureMessage) : ApkPreparationStatus() +} + +/** A localizable failure kept as data until the UI or a one-shot effect renders it. */ +data class ApkFailureMessage( + @StringRes val messageRes: Int, + val messageArgs: List = emptyList() +) + +/** + * Resolves a failure defensively. The reason and its arguments cross the WorkManager boundary + * independently, so an argument list that does not match the format string is possible; a row + * showing generic text beats one that throws while formatting. + */ +internal fun Context.resolveApkFailureMessage(failure: ApkFailureMessage): String { + return runCatching { + getString( + failure.messageRes, + *failure.messageArgs.toTypedArray() + ) + }.getOrElse { + getString(R.string.prepare_apk_error_generic) + } +} + +sealed class ApkReleaseStatus { + object Unknown : ApkReleaseStatus() + object Checking : ApkReleaseStatus() + data class Known( + val version: String, + val sizeMB: Int, + val isNewerThanSharedApk: Boolean, + val fromStaleCache: Boolean + ) : ApkReleaseStatus() +} + +data class ApkUiState( + val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, + val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, + val downloadProgress: Int = 0, + val showPrepareDialog: Boolean = false, + val showDeleteDialog: Boolean = false, + val showShareApkDialog: Boolean = false +) + +// --- Events (UI → ViewModel) --- + +sealed class ApkUiEvent { + object CheckStatus : ApkUiEvent() + object PrepareRowClicked : ApkUiEvent() + object DownloadUniversalClicked : ApkUiEvent() + object ConfirmDownload : ApkUiEvent() + object DismissPrepareDialog : ApkUiEvent() + object DeleteClicked : ApkUiEvent() + object ConfirmDelete : ApkUiEvent() + object DismissDeleteDialog : ApkUiEvent() + object HotspotShareClicked : ApkUiEvent() + object AppShareClicked : ApkUiEvent() + object ConfirmAppShare : ApkUiEvent() + object DismissShareDialog : ApkUiEvent() + object CancelDownload : ApkUiEvent() +} + +// --- Row tap --- + +/** What tapping the body of the prepare row does. */ +internal enum class PrepareRowTapAction { + OpenPrepareDialog, + StartDownload +} + +/** + * What a tap on the prepare row means for [status], or null when the row has nothing to offer. + * + * The trailing controls are icon-only, so the row body is the discoverable half of every action + * and has to stay in step with them. Deriving both the tap handler and the row's `enabled` flag + * from this one function keeps the row from looking clickable while doing nothing. + */ +internal fun prepareRowTapAction( + status: ApkPreparationStatus, + releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown +): PrepareRowTapAction? = when { + status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog + // Consent was already given for these; resuming straight away avoids a redundant prompt. + status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Ready && + (status.source == UniversalApkManager.ApkSource.INSTALLED || + (releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) -> + PrepareRowTapAction.OpenPrepareDialog + else -> null +} + +// --- Effects (ViewModel → UI, one-shot) --- + +sealed class ApkUiEffect { + data class NavigateToHotspot(val apkPath: String) : ApkUiEffect() + data class ShareApk(val apkUri: android.net.Uri, val chooserTitle: String) : ApkUiEffect() + data class ShowToast(val message: String) : ApkUiEffect() +} + +/** + * ViewModel for APK download/status/share logic following MVI pattern. + * UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect]. + */ +class ApkDownloadViewModel internal constructor( + application: Application, + private val apkManager: UniversalApkManager, + private val downloader: ApkDownloader, + private val latestReleaseProvider: LatestReleaseProvider +) : AndroidViewModel(application) { + + constructor(application: Application) : this( + application = application, + apkManager = UniversalApkManager(application), + downloader = WorkManagerApkDownloader(application), + latestReleaseProvider = GitHubReleaseClient(application) + ) + + companion object { + private const val TAG = "ApkDownloadVM" + } + + private val _state = MutableStateFlow(ApkUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _effect = Channel(Channel.BUFFERED) + val effect = _effect.receiveAsFlow() + + private var metadataRefreshJob: Job? = null + + init { + observeDownloader() + } + + fun onEvent(event: ApkUiEvent) { + when (event) { + is ApkUiEvent.CheckStatus -> checkStatus() + is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked() + is ApkUiEvent.DownloadUniversalClicked -> onDownloadUniversalClicked() + is ApkUiEvent.ConfirmDownload -> onConfirmDownload() + is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) } + is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) } + is ApkUiEvent.ConfirmDelete -> onConfirmDelete() + is ApkUiEvent.DismissDeleteDialog -> _state.update { it.copy(showDeleteDialog = false) } + is ApkUiEvent.HotspotShareClicked -> onHotspotShareClicked() + is ApkUiEvent.AppShareClicked -> _state.update { it.copy(showShareApkDialog = true) } + is ApkUiEvent.ConfirmAppShare -> onConfirmAppShare() + is ApkUiEvent.DismissShareDialog -> _state.update { it.copy(showShareApkDialog = false) } + is ApkUiEvent.CancelDownload -> onCancelDownload() + } + } + + private fun onPrepareRowClicked() { + when ( + prepareRowTapAction( + _state.value.apkStatus, + _state.value.releaseStatus + ) + ) { + PrepareRowTapAction.OpenPrepareDialog -> + _state.update { it.copy(showPrepareDialog = true) } + PrepareRowTapAction.StartDownload -> startDownload() + null -> {} + } + } + + private fun onConfirmDownload() { + _state.update { it.copy(showPrepareDialog = false) } + startDownload() + } + + private fun onDownloadUniversalClicked() { + val status = _state.value.apkStatus + val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known) + ?.isNewerThanSharedApk == true + if (status is ApkPreparationStatus.Ready && + (status.source == UniversalApkManager.ApkSource.INSTALLED || hasUpdate) + ) { + _state.update { it.copy(showPrepareDialog = true) } + } + } + + private fun onConfirmDelete() { + _state.update { it.copy(showDeleteDialog = false) } + downloader.cancelDownload() + apkManager.deleteCachedApk() + checkStatus() + } + + private fun onHotspotShareClicked() { + val apkFile = apkManager.getCachedApk() + if (apkFile != null) { + viewModelScope.launch { + _effect.send(ApkUiEffect.NavigateToHotspot(apkFile.absolutePath)) + } + } else { + sendToast(getString(R.string.apk_not_ready_please_prepare_it_first)) + } + } + + private fun onConfirmAppShare() { + _state.update { it.copy(showShareApkDialog = false) } + viewModelScope.launch(Dispatchers.IO) { + try { + val apkFile = apkManager.getCachedApk() + if (apkFile == null || !apkFile.exists()) { + sendToast(getString(R.string.apk_not_ready_please_prepare_it_first)) + return@launch + } + + val context = getApplication() + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + _effect.send( + ApkUiEffect.ShareApk( + apkUri = uri, + chooserTitle = getString(R.string.share_apk_chooser_title) + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Error preparing APK share", e) + sendToast(getString(R.string.share_apk_error)) + } + } + } + + private fun onCancelDownload() { + downloader.cancelDownload() + + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + _state.update { + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) + } + if (fallback == null) checkStatus() + } + + private fun startDownload() { + val current = _state.value.apkStatus + val fallback = when (current) { + is ApkPreparationStatus.Ready -> current + is ApkPreparationStatus.Downloading -> current.shareableFallback + else -> null + } + val partial = apkManager.getPartialDownloadProgress() + _state.update { + it.copy( + apkStatus = ApkPreparationStatus.Downloading( + shareableFallback = fallback + ), + downloadProgress = partial ?: 0 + ) + } + downloader.startDownload() + } + + private fun checkStatus() { + viewModelScope.launch { + val resolvedStatus = resolveApkStatus() + _state.update { current -> + // WorkManager is the source of truth for active work. A queued or newly started + // job legitimately has no partial file yet, so never infer that it is orphaned + // from cache contents, and never let a resolved status overwrite it - the user + // may have started a download while the local artifact was being inspected. + when (val active = current.apkStatus) { + is ApkPreparationStatus.Downloading -> + // Active work still adopts a local artifact it was created without. A + // ViewModel restored onto a running download starts from Loading, so the + // observer had no Ready to carry into shareableFallback, and an installed + // APK - with both sharing actions - would stay hidden for the whole + // transfer. Deciding here covers the observer arriving before this runs + // and during the resolve above, which are different orderings. + if (active.shareableFallback == null) { + current.copy( + apkStatus = active.copy( + shareableFallback = shareableReady(resolvedStatus) + ) + ) + } else { + current + } + else -> current.copy( + apkStatus = resolvedStatus, + downloadProgress = 0 + ) + } + } + // Metadata is skipped while work is active, as it was before: an in-flight download + // has no use for a freshness check and the API budget is scarce. + if (_state.value.apkStatus is ApkPreparationStatus.Downloading) return@launch + // Local availability is resolved and published before this independent network task + // starts. Metadata can add a freshness warning, but can never hide sharing. + refreshReleaseMetadata() + } + } + + private fun refreshReleaseMetadata() { + if (metadataRefreshJob?.isActive == true) return + metadataRefreshJob = viewModelScope.launch { + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Checking) } + latestReleaseProvider.latestRelease() + .onSuccess { snapshot -> + val shared = shareableReady(_state.value.apkStatus) + _state.update { + it.copy( + releaseStatus = ApkReleaseStatus.Known( + version = snapshot.release.versionName, + sizeMB = (snapshot.release.universalApkSize / 1024 / 1024).toInt(), + isNewerThanSharedApk = shared?.let { ready -> + AppVersion.isNewer(ready.version, snapshot.release.versionName) + } ?: false, + fromStaleCache = snapshot.isStale + ) + ) + } + } + .onFailure { + // Metadata is an optional enhancement. Keep the locally resolved APK state. + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Unknown) } + } + } + } + + private fun observeDownloader() { + viewModelScope.launch { + downloader.downloadState.collect { downloadState -> + when (downloadState) { + is ApkDownloader.DownloadState.Idle -> { + val downloading = _state.value.apkStatus as? ApkPreparationStatus.Downloading + if (downloading != null) { + val fallback = downloading.shareableFallback + _state.update { + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) + } + if (fallback == null) checkStatus() + } + } + is ApkDownloader.DownloadState.Downloading -> { + _state.update { + val fallback = (it.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: (it.apkStatus as? ApkPreparationStatus.Ready) + it.copy( + apkStatus = ApkPreparationStatus.Downloading( + phase = downloadState.phase, + shareableFallback = fallback + ), + downloadProgress = downloadState.progressPercent + ) + } + } + is ApkDownloader.DownloadState.Success -> { + val info = apkManager.getCachedApkInfo() + _state.update { + val ready = ApkPreparationStatus.Ready( + version = info?.version ?: downloadState.version, + sizeMB = info?.let { cached -> + (cached.size / 1024 / 1024).toInt() + } ?: downloadState.sizeMB, + source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED, + variant = info?.variant ?: ShareableApkVariant.UNIVERSAL + ) + it.copy( + apkStatus = ready, + releaseStatus = releaseStatusFor(ready, it.releaseStatus), + downloadProgress = 100 + ) + } + } + is ApkDownloader.DownloadState.Failed -> { + val failure = downloadState.toFailureMessage() + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: apkManager.getCachedApkInfo()?.toReady() + if (fallback != null) { + _state.update { + it.copy( + apkStatus = fallback, + releaseStatus = releaseStatusFor(fallback, it.releaseStatus), + downloadProgress = 0 + ) + } + _effect.send( + ApkUiEffect.ShowToast( + getApplication().resolveApkFailureMessage( + failure + ) + ) + ) + } else { + _state.update { + if (downloadState.resumablePercent != null) { + it.copy( + apkStatus = ApkPreparationStatus.Resumable( + progressPercent = downloadState.resumablePercent, + failure = failure + ), + downloadProgress = downloadState.resumablePercent + ) + } else { + it.copy(apkStatus = ApkPreparationStatus.Error(failure)) + } + } + } + } + } + } + } + } + + private fun sendToast(message: String) { + viewModelScope.launch { + _effect.send(ApkUiEffect.ShowToast(message)) + } + } + + private fun getString(resId: Int): String { + return getApplication().getString(resId) + } + + private fun ApkDownloader.DownloadState.Failed.toFailureMessage() = ApkFailureMessage( + messageRes = reason.messageRes, + messageArgs = messageArgs + ) + + private fun shareableReady(status: ApkPreparationStatus): ApkPreparationStatus.Ready? = + when (status) { + is ApkPreparationStatus.Ready -> status + is ApkPreparationStatus.Downloading -> status.shareableFallback + else -> null + } + + private fun releaseStatusFor( + ready: ApkPreparationStatus.Ready, + releaseStatus: ApkReleaseStatus + ): ApkReleaseStatus = (releaseStatus as? ApkReleaseStatus.Known)?.let { + it.copy(isNewerThanSharedApk = AppVersion.isNewer(ready.version, it.version)) + } ?: releaseStatus + + private fun UniversalApkManager.ApkInfo.toReady() = ApkPreparationStatus.Ready( + version = version, + sizeMB = (size / 1024 / 1024).toInt(), + source = source, + variant = variant + ) + + private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { + try { + val info = apkManager.prepareLocalApkInfo() + if (info != null) { + info.toReady() + } else { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + failure = ApkFailureMessage( + messageRes = R.string.prepare_apk_download_interrupted + ) + ) + } else { + ApkPreparationStatus.NotDownloaded + } + } + } catch (e: Exception) { + // The exception text is English and often internal; log it, show a translated line. + Log.e(TAG, "Error reading APK status", e) + ApkPreparationStatus.Error( + ApkFailureMessage(messageRes = R.string.share_apk_error) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt new file mode 100644 index 00000000..7e20edda --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt @@ -0,0 +1,105 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * The progress readout for the prepare-for-sharing row. + * + * This sits under the row's subtitle so the trailing slot is free to hold a single control. Which + * of the three renderings applies is decided entirely by [status]; the caller does not choose. + * + * Determinate transfer/resume progress and indeterminate non-transfer phases use the stable + * Material 3 progress API. The expressive wavy variant can be introduced independently later. + */ +@Composable +internal fun ApkDownloadProgressBar( + status: ApkPreparationStatus, + progressPercent: Int, + modifier: Modifier = Modifier +) { + val barModifier = modifier + .fillMaxWidth() + .padding(top = 6.dp) + + when { + // Only the transfer knows a fraction. Elsewhere an indeterminate bar is honest about + // having no measure, the same distinction the subtitle already draws. + status is ApkPreparationStatus.Downloading && + status.phase.hasMeasurableProgress && + progressPercent > 0 -> + LinearProgressIndicator( + progress = { progressPercent.asProgressFraction() }, + modifier = barModifier + ) + + status is ApkPreparationStatus.Downloading -> + LinearProgressIndicator(modifier = barModifier) + + status is ApkPreparationStatus.Resumable -> + LinearProgressIndicator( + progress = { status.progressPercent.asProgressFraction() }, + modifier = barModifier + ) + } +} + +/** Percentages arrive from a worker across a process boundary, so they are not trusted to be 0..100. */ +private fun Int.asProgressFraction(): Float = (this / 100f).coerceIn(0f, 1f) + +/** + * The single trailing control on the prepare row. + * + * Every status renders exactly one of these at the same width, so the text column beside it keeps + * its measure and stops re-wrapping each time the status changes. + * + * These buttons carry no visible label, which makes [description] load-bearing rather than + * decorative: it is both the TalkBack announcement and the long-press tooltip for a sighted user + * who does not recognise the glyph. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ApkPrepareRowIconButton( + icon: ImageVector, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant +) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(description) } }, + state = rememberTooltipState(), + modifier = modifier + ) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = if (enabled) tint else tint.copy(alpha = 0.38f), + modifier = Modifier.size(20.dp) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt b/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt new file mode 100644 index 00000000..0daf401c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt @@ -0,0 +1,287 @@ +package com.bitchat.android.ui + +import com.google.gson.JsonParser +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.math.BigDecimal +import java.util.Base64 +import java.util.Currency +import java.util.Locale + +/** + * Bounded, display-only Cashu token decoder. Tokens are bearer instruments, so + * this class never contacts a mint or attempts to hold/redeem funds. + */ +object CashuTokenDecoder { + const val MAX_TOKEN_LENGTH = 60_000 + private const val MAX_AMOUNT = 2_100_000_000_000_000L + + data class TokenInfo( + val version: Char, + val amount: Long?, + val unit: String?, + val mintHost: String?, + val memo: String? + ) { + val displayAmount: String? + get() = amount?.let { value -> + val displayUnit = unit ?: "sat" + val minorDigits = minorUnitDigits(displayUnit) + val formatted = if (minorDigits == null || minorDigits == 0) { + value.toString() + } else { + BigDecimal.valueOf(value, minorDigits).setScale(minorDigits).toPlainString() + } + "$formatted $displayUnit" + } + } + + fun bareToken(raw: String): String? { + var token = raw.trim() + if ('%' in token) token = percentDecode(token) ?: return null + token = when { + token.startsWith("cashu://", ignoreCase = true) -> token.substring(8) + token.startsWith("cashu:", ignoreCase = true) -> token.substring(6) + else -> token + } + if (token.length !in 12..MAX_TOKEN_LENGTH) return null + if (!token.startsWith("cashuA") && !token.startsWith("cashuB")) return null + if (token.any { !it.isLetterOrDigit() && it !in "-_+/=" }) return null + return token + } + + /** + * Permissive decoding is suitable for display: unsupported but plausible + * v4 CBOR still gets a generic chip. Strict decoding is required before + * sending and accepts only a fully parsed token with a positive amount. + */ + fun decode(raw: String, strict: Boolean = false): TokenInfo? { + val token = bareToken(raw) ?: return null + val payload = decodeBase64Url(token.substring(6)) ?: return null + if (payload.isEmpty()) return null + val info = when (token[5]) { + 'A' -> decodeV3(payload) + 'B' -> decodeV4(payload) ?: if (strict) null else TokenInfo('B', null, null, null, null) + else -> null + } ?: return null + return if (!strict || (info.amount != null && info.amount > 0)) info else null + } + + fun extractTokens(text: String, max: Int = 3): List { + if (text.isEmpty() || max <= 0) return emptyList() + val matches = TOKEN_REGEX.findAll(text) + val result = LinkedHashSet() + for (match in matches) { + bareToken(match.value)?.let(result::add) + if (result.size == max) break + } + return result.toList() + } + + fun walletUri(token: String): String? = bareToken(token)?.let { "cashu:${encodeUriComponent(it)}" } + + fun webRedeemUri(token: String): String? = + bareToken(token)?.let { "https://redeem.cashu.me/?token=${encodeUriComponent(it)}" } + + private fun encodeUriComponent(value: String): String = + URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20") + + /** Percent-decodes URI input without URLDecoder's form-specific '+' → space conversion. */ + private fun percentDecode(value: String): String? { + val output = StringBuilder(value.length) + var index = 0 + while (index < value.length) { + if (value[index] != '%') { + output.append(value[index++]) + continue + } + val bytes = ArrayList() + while (index < value.length && value[index] == '%') { + if (index + 2 >= value.length) return null + val byte = value.substring(index + 1, index + 3).toIntOrNull(16) ?: return null + bytes += byte.toByte() + index += 3 + } + output.append(String(bytes.toByteArray(), StandardCharsets.UTF_8)) + } + return output.toString() + } + + private fun decodeBase64Url(input: String): ByteArray? { + val normalized = input.replace('-', '+').replace('_', '/').trimEnd('=') + if (normalized.length % 4 == 1) return null + val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4) + return runCatching { Base64.getDecoder().decode(padded) }.getOrNull() + } + + private fun decodeV3(payload: ByteArray): TokenInfo? = runCatching { + val root = JsonParser.parseString(String(payload, StandardCharsets.UTF_8)).asJsonObject + val entries = root.getAsJsonArray("token")?.takeIf { it.size() > 0 } ?: return null + var total = 0L + var sawAmount = false + var mintHost: String? = null + for (entryElement in entries) { + val entry = entryElement.takeIf { it.isJsonObject }?.asJsonObject ?: continue + if (mintHost == null) mintHost = sanitizeHost(entry.get("mint")?.takeIf { it.isJsonPrimitive }?.asString) + val proofs = entry.getAsJsonArray("proofs") ?: continue + for (proofElement in proofs) { + val amountElement = proofElement.takeIf { it.isJsonObject }?.asJsonObject?.get("amount") ?: continue + if (!amountElement.isJsonPrimitive || !amountElement.asJsonPrimitive.isNumber) continue + val value = runCatching { amountElement.asBigDecimal.longValueExact() }.getOrNull() ?: continue + if (value <= 0 || value > MAX_AMOUNT) continue + if (total > MAX_AMOUNT - value) return null + total += value + sawAmount = true + } + } + TokenInfo( + version = 'A', + amount = total.takeIf { sawAmount }, + unit = sanitizeUnit(root.get("unit")?.takeIf { it.isJsonPrimitive }?.asString), + mintHost = mintHost, + memo = sanitizeMemo(root.get("memo")?.takeIf { it.isJsonPrimitive }?.asString) + ) + }.getOrNull() + + private fun decodeV4(payload: ByteArray): TokenInfo? { + val root = CborReader(payload).parseComplete() as? CborValue.MapValue ?: return null + var total = 0L + var sawAmount = false + var mintHost: String? = null + var unit: String? = null + var memo: String? = null + for ((key, value) in root.pairs) { + when ((key as? CborValue.Text)?.value) { + "m" -> mintHost = sanitizeHost((value as? CborValue.Text)?.value) + "u" -> unit = sanitizeUnit((value as? CborValue.Text)?.value) + "d" -> memo = sanitizeMemo((value as? CborValue.Text)?.value) + "t" -> for (group in (value as? CborValue.ArrayValue)?.values.orEmpty()) { + for ((groupKey, groupValue) in (group as? CborValue.MapValue)?.pairs.orEmpty()) { + if ((groupKey as? CborValue.Text)?.value != "p") continue + for (proof in (groupValue as? CborValue.ArrayValue)?.values.orEmpty()) { + for ((proofKey, proofValue) in (proof as? CborValue.MapValue)?.pairs.orEmpty()) { + if ((proofKey as? CborValue.Text)?.value != "a") continue + val amount = (proofValue as? CborValue.Unsigned)?.value ?: continue + if (amount == 0L || amount > MAX_AMOUNT) continue + if (total > MAX_AMOUNT - amount) return null + total += amount + sawAmount = true + } + } + } + } + } + } + return TokenInfo('B', total.takeIf { sawAmount }, unit, mintHost, memo) + } + + private fun sanitizeHost(value: String?): String? = value + ?.takeIf { it.length <= 512 } + ?.let { runCatching { URI(it).host }.getOrNull() } + ?.takeIf { it.isNotEmpty() } + ?.lowercase() + ?.take(48) + + private fun sanitizeUnit(value: String?): String? = + value?.takeIf { it.isNotEmpty() && it.length <= 12 && it.all(Char::isLetterOrDigit) } + + private fun sanitizeMemo(value: String?): String? { + if (value == null || value.length > 512) return null + return value.filterNot(Char::isISOControl).trim().take(80).takeIf(String::isNotEmpty) + } + + /** ISO-4217 values use their currency's minor unit; custom units stay integer-denominated. */ + private fun minorUnitDigits(unit: String): Int? { + return runCatching { + Currency.getInstance(unit.uppercase(Locale.ROOT)).defaultFractionDigits + }.getOrNull()?.takeIf { it >= 0 } + } + + private val TOKEN_REGEX = Regex("""(?i:cashu:(?://)?)?cashu[AB][A-Za-z0-9_+/%=-]{6,}""") +} + +private sealed interface CborValue { + data class Unsigned(val value: Long) : CborValue + data class Text(val value: String) : CborValue + data class ArrayValue(val values: List) : CborValue + data class MapValue(val pairs: List>) : CborValue + data object Opaque : CborValue +} + +private class CborReader(private val bytes: ByteArray) { + private var index = 0 + private var itemBudget = 50_000 + + fun parseComplete(): CborValue? { + val value = parseValue(0) ?: return null + return value.takeIf { index == bytes.size } + } + + private fun parseValue(depth: Int): CborValue? { + if (depth >= 16 || itemBudget-- <= 0) return null + val (major, argument) = readHead() ?: return null + return when (major) { + 0 -> CborValue.Unsigned(argument.takeIf { it <= Long.MAX_VALUE }?.toLong() ?: return null) + 1 -> CborValue.Opaque + 2 -> if (readBytes(argument) != null) CborValue.Opaque else null + 3 -> readBytes(argument)?.toString(StandardCharsets.UTF_8)?.let(CborValue::Text) + 4 -> parseContainer(argument, depth) { CborValue.ArrayValue(it) } + 5 -> { + if (argument > 10_000 || argument > itemBudget / 2) return null + val pairs = ArrayList>(argument.coerceAtMost(64).toInt()) + repeat(argument.toInt()) { + pairs += (parseValue(depth + 1) ?: return null) to (parseValue(depth + 1) ?: return null) + } + CborValue.MapValue(pairs) + } + 6 -> parseValue(depth + 1) + 7 -> CborValue.Opaque + else -> null + } + } + + private fun parseContainer( + count: Long, + depth: Int, + wrap: (List) -> CborValue + ): CborValue? { + if (count > 10_000 || count > itemBudget) return null + val values = ArrayList(count.coerceAtMost(64).toInt()) + repeat(count.toInt()) { values += parseValue(depth + 1) ?: return null } + return wrap(values) + } + + private fun readHead(): Pair? { + if (index >= bytes.size) return null + val head = bytes[index++].toInt() and 0xff + val major = head ushr 5 + val info = head and 0x1f + val argument = when (info) { + in 0..23 -> info.toLong() + 24 -> readUInt(1) + 25 -> readUInt(2) + 26 -> readUInt(4) + 27 -> readUInt(8) + else -> null + } ?: return null + return major to argument + } + + private fun readUInt(width: Int): Long? { + if (bytes.size - index < width) return null + var value = 0L + repeat(width) { + val next = bytes[index++].toLong() and 0xff + if (value > (Long.MAX_VALUE - next) ushr 8) return null + value = (value shl 8) or next + } + return value + } + + private fun readBytes(count: Long): ByteArray? { + if (count < 0 || count > bytes.size - index) return null + val end = index + count.toInt() + return bytes.copyOfRange(index, end).also { index = end } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt index 5cc1375c..43bc2085 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import com.bitchat.android.geohash.ChannelID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.security.MessageDigest @@ -16,7 +17,8 @@ class ChannelManager( private val state: ChatState, private val messageManager: MessageManager, private val dataManager: DataManager, - private val coroutineScope: CoroutineScope + private val coroutineScope: CoroutineScope, + private val onSwitchToMeshLocation: () -> Unit = {} ) { // Channel encryption and security @@ -109,6 +111,8 @@ class ChannelManager( fun switchToChannel(channel: String?) { state.setCurrentChannel(channel) state.setSelectedPrivateChatPeer(null) + state.setSelectedLocationChannel(ChannelID.Mesh) + onSwitchToMeshLocation() // Clear unread count channel?.let { ch -> diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index ca886713..aac5283c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,109 +1,513 @@ package com.bitchat.android.ui -import android.util.Log +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.core.ui.utils.singleOrTripleClickable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily +import androidx.annotation.DrawableRes +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.layout.RowScope +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.BitChatBrandButton +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Header components for ChatScreen * Extracted from ChatScreen.kt for better organization */ +/** Height of the chat top bar. Taller than the old 42.dp so 44.dp tap targets fit properly. */ +val ChatHeaderHeight = 52.dp + /** - * Reactive helper to compute favorite state from fingerprint mapping - * This eliminates the need for static isFavorite parameters and makes - * the UI reactive to fingerprint manager changes + * The single visible glyph size used by every icon in the top bar. + * + * The Figma header pairs 16 px icons with compact labels. At our 17sp header scale, 19dp preserves + * that icon-to-cap-height relationship without affecting the surrounding 44dp touch targets. + */ +internal val HeaderIconSize = 19.dp + +/** + * Text size for the top bar's labels: nickname, channel name, peer count. + * + * A step up from the 15.sp body scale. The bar is the app's primary status readout and was + * noticeably harder to read than the messages below it; the extra point costs nothing because + * the bar's height is driven by [HeaderTapTarget], not by the text. + */ +private val HeaderTextSize = 17.sp + +/** Minimum tap target for every interactive element in the header. */ +private val HeaderTapTarget = 44.dp + +internal enum class HeaderCrowdingMode { + Full, + HideJoinedChannelCount, + IconOnlyLocationChannel, +} + +internal fun headerCrowdingMode(availableWidth: Dp): HeaderCrowdingMode = when { + availableWidth < 360.dp -> HeaderCrowdingMode.IconOnlyLocationChannel + availableWidth < 400.dp -> HeaderCrowdingMode.HideJoinedChannelCount + else -> HeaderCrowdingMode.Full +} + +internal fun locationChannelContentDescription( + actionDescription: String, + channelLabel: String, + showLabel: Boolean, +): String = if (showLabel) { + actionDescription +} else { + "$channelLabel. $actionDescription" +} + +/** Corner radius for the header's tappable label+icon clusters. */ +private val HeaderClusterShape = RoundedCornerShape(8.dp) + +/** + * Edge insets for the bar. + * + * Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies + * some optical inset, while the trailing action's does the same on the other side. + */ +internal val HeaderInsetStart = 12.dp +internal val HeaderInsetEnd = 8.dp + +/** + * A minimum-48x40 tap target wrapping a small icon. + * + * The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far + * below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit. */ @Composable -fun isFavoriteReactive( - peerID: String, - peerFingerprints: Map, - favoritePeers: Set -): Boolean { - return remember(peerID, peerFingerprints, favoritePeers) { - val fingerprint = peerFingerprints[peerID] - fingerprint != null && favoritePeers.contains(fingerprint) +private fun HeaderIconButton( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .size(HeaderTapTarget) + .clip(CircleShape) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center + ) { + content() } } +/** + * Tor health for location-channel header glyphs. + * + * Status colours are heavily muted (blended into [normal]) so they read as a soft signal + * rather than an alarm. Connecting / not-yet-running also drives a slow glow pulse. + */ +internal data class TorConnectionVisual( + val tint: Color, + /** True while Tor is enabled but not fully bootstrapped — drives a pulse. */ + val isProgress: Boolean, +) + @Composable -fun TorStatusIcon( - modifier: Modifier = Modifier +internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() + + // ~28% of the loud accent mixed into the base tint keeps the hue without intensity. + val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f) + val mutedFailed = lerp(normal, colorScheme.error, 0.30f) + + val target = when { + torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false) + torStatus.running && torStatus.bootstrapPercent >= 100 -> + TorConnectionVisual(normal, isProgress = false) + torStatus.running -> TorConnectionVisual(mutedConnecting, isProgress = true) + else -> TorConnectionVisual(mutedFailed, isProgress = true) + } + + val animatedTint by animateColorAsState( + targetValue = target.tint, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torConnectionTint" + ) + return TorConnectionVisual(tint = animatedTint, isProgress = target.isProgress) +} + +/** + * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout + * does not shift; only opacity / a faint halo breathe. Glow strength itself cross-fades so + * starting/stopping progress never pops. + */ +@Composable +internal fun TorAwareHeaderIcon( + imageVector: ImageVector, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, ) { - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() - - if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { - val cableColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) - torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) - else -> Color.Red + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torGlowFade" + ) + val pulse = if (progressFade > 0.01f) { + val transition = rememberInfiniteTransition(label = "torGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torGlowPulse" + ).value + } else { + 1f + } + + // Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never + // pushes neighbouring text when the pulse starts/stops. + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { + if (progressFade > 0.01f) { + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } + Box( + modifier = Modifier + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f * progressFade } + .background(glowBrush) + ) } Icon( - imageVector = Icons.Outlined.Cable, - contentDescription = "Tor status", - modifier = modifier, - tint = cableColor + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + // Idle = solid; in-progress = breathing opacity, lerped by [progressFade]. + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) + }, + tint = tint ) } } +/** Painter-resource counterpart used by the extracted Figma SVG family. */ +@Composable +internal fun TorAwareHeaderIcon( + painter: Painter, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torPainterGlowFade" + ) + val pulse = if (progressFade > 0.01f) { + val transition = rememberInfiniteTransition(label = "torPainterGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torPainterGlowPulse" + ).value + } else { + 1f + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { + if (progressFade > 0.01f) { + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } + Box( + modifier = Modifier + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f * progressFade } + .background(glowBrush) + ) + } + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) + }, + tint = tint + ) + } +} + +/** + * Noise session status for private-chat headers. + * + * Same visual language as the main header's Tor-aware globe: tint cross-fades between states, + * and a soft radial glow pulse while the handshake is in flight. The glyph itself is the open + * lock until a session is established (or fails), then the closed lock — both share the same + * baseline so a [Crossfade] reads as the shackle settling shut rather than an icon swap. + */ @Composable fun NoiseSessionIcon( sessionState: String?, modifier: Modifier = Modifier ) { - val (icon, color, contentDescription) = when (sessionState) { - "uninitialized" -> Triple( - Icons.Outlined.NoEncryption, - Color(0x87878700), // Grey - ready to establish - "Ready for handshake" + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val (targetTint, isProgress, contentDescription) = when { + sessionState == "handshaking" -> Triple( + palette.accentOrange, + true, + stringResource(R.string.cd_handshake_in_progress) ) - "handshaking" -> Triple( - Icons.Outlined.Sync, - Color(0x87878700), // Grey - in progress - "Handshake in progress" + sessionState == "established" -> Triple( + colorScheme.primary, + false, + stringResource(R.string.cd_encrypted) ) - "established" -> Triple( - Icons.Filled.Lock, - Color(0xFFFF9500), // Orange - secure - "End-to-end encrypted" + sessionState?.startsWith("failed") == true -> Triple( + colorScheme.error, + false, + stringResource(R.string.cd_handshake_failed) ) - else -> { // "failed" or any other state - Triple( - Icons.Outlined.Warning, - Color(0xFFFF4444), // Red - error - "Handshake failed" + else -> Triple( + // Not yet started — quiet grey open lock. + colorScheme.onSurfaceVariant, + false, + stringResource(R.string.cd_ready_for_handshake) + ) + } + + // Closed once the handshake resolves (success or failure); open while idle or in flight. + val lockIconRes = when { + sessionState == "established" || sessionState?.startsWith("failed") == true -> + R.drawable.ic_spec_lock + else -> R.drawable.ic_spec_lock_open + } + + // Match the tint wash so open → closed and grey → orange → green land together. + val lockTransitionMs = 480 + + val animatedTint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing), + label = "noiseSessionTint" + ) + + Crossfade( + targetState = lockIconRes, + animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing), + modifier = modifier, + label = "noiseLockGlyph" + ) { iconRes -> + TorAwareHeaderIcon( + painter = painterResource(iconRes), + tint = animatedTint, + isProgress = isProgress, + contentDescription = contentDescription, + ) + } +} + +/** + * Reachability glyph for a conversation, drawn from the same spec set the main header uses. + * + * Mirrors the main header's channel button: a globe for anything reached over the internet, the + * range mark for the local mesh, and the more specific transport glyph when we know it. + */ +@DrawableRes +internal fun conversationTransportIcon( + isReachedOverInternet: Boolean, + isWifiAware: Boolean, + isDirect: Boolean +): Int = when { + isReachedOverInternet -> R.drawable.ic_spec_globe + isWifiAware -> R.drawable.ic_spec_wifi + isDirect -> R.drawable.ic_spec_bluetooth + else -> R.drawable.ic_spec_routed +} + +/** + * The shared chrome for a conversation header — private chats and channels alike. + * + * Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical + * height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it + * lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward + * that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you + * move between the main timeline and a conversation. + * + * Actions are right-aligned and unweighted, so a long title yields space to them rather than + * pushing them off screen. + */ +@Composable +fun ConversationHeader( + @DrawableRes leadingIconRes: Int, + leadingIconTint: Color, + title: String, + modifier: Modifier = Modifier, + onTitleClick: (() -> Unit)? = null, + leadingContentDescription: String? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(HeaderTapTarget), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(leadingIconRes), + contentDescription = leadingContentDescription, + modifier = Modifier.size(HeaderIconSize), + tint = leadingIconTint + ) + } + + // Same optical correction as the main header: the 44.dp tap target leaves more gap + // than the design wants between glyph and label. + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .offset(x = (-6).dp) + .then( + if (onTitleClick != null) { + Modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onTitleClick) + .padding(horizontal = 6.dp, vertical = 4.dp) + } else { + Modifier + } + ) ) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + content = actions + ) } - - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = modifier, - tint = color +} + +/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */ +@Composable +fun ConversationHeaderAction( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) = HeaderIconButton( + onClick = onClick, + contentDescription = contentDescription, + modifier = modifier, + content = content +) + +/** A read-only status slot matching the footprint of [ConversationHeaderAction]. */ +@Composable +fun ConversationHeaderStatus( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier.size(HeaderTapTarget), + contentAlignment = Alignment.Center, + content = { content() } ) } @@ -127,17 +531,19 @@ fun NicknameEditor( modifier = modifier ) { Text( - text = "@", + text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary.copy(alpha = 0.8f) + fontSize = HeaderTextSize, + color = colorScheme.primary.copy(alpha = 0.7f) ) - + BasicTextField( value = value, onValueChange = onValueChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily, + fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), singleLine = true, @@ -148,7 +554,8 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 120.dp) + .weight(1f) + .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) } @@ -159,87 +566,75 @@ fun PeerCounter( connectedPeers: List, joinedChannels: Set, hasUnreadChannels: Map, - hasUnreadPrivateMessages: Set, isConnected: Boolean, selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, geohashPeople: List, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showJoinedChannelCount: Boolean = true ) { + val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme - + // Compute channel-aware people count and color (matches iOS logic exactly) val (peopleCount, countColor) = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { // Geohash channel: show geohash participants val count = geohashPeople.size - val green = Color(0xFF00C851) // Standard green - Pair(count, if (count > 0) green else Color.Gray) + Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary) } is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) val count = connectedPeers.size - val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh - Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray) + Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) } } - + + // Peers come and go constantly; fading the tint avoids a flicker every time the count + // crosses zero. + val animatedCountColor by animateColorAsState( + targetValue = countColor, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "peerCountColor" + ) + Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onClick) + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { - if (hasUnreadChannels.values.any { it > 0 }) { - // Channel icon in a Box to ensure consistent size with other icons - Box( - modifier = Modifier.size(16.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "#", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF0080FF), - fontSize = 16.sp - ) - } - Spacer(modifier = Modifier.width(6.dp)) - } - - if (hasUnreadPrivateMessages.isNotEmpty()) { - // Filled mail icon to match sidebar style - Icon( - imageVector = Icons.Filled.Email, - contentDescription = "Unread private messages", - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // Orange to match private message theme - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Icon( - imageVector = Icons.Default.Group, + // The extracted people glyph stays legible at the compact header scale; the number + // beside it carries the precise count. + painter = painterResource(R.drawable.ic_spec_people), contentDescription = when (selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> "Geohash participants" - else -> "Connected peers" + is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) + else -> stringResource(R.string.cd_connected_peers) }, - modifier = Modifier.size(16.dp), - tint = countColor + modifier = Modifier.size(HeaderIconSize), + tint = animatedCountColor ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "$peopleCount", + + AnimatedCount( + count = peopleCount, style = MaterialTheme.typography.bodyMedium, - color = countColor, - fontSize = 16.sp, + fontSize = HeaderTextSize, + color = animatedCountColor, fontWeight = FontWeight.Medium ) - - if (joinedChannels.isNotEmpty()) { - Text( - text = " · ⧉ ${joinedChannels.size}", + + if (showJoinedChannelCount && joinedChannels.isNotEmpty()) { + AnimatedCount( + count = joinedChannels.size, + prefix = stringResource(R.string.channel_count_prefix), style = MaterialTheme.typography.bodyMedium, - color = if (isConnected) Color(0xFF00C851) else Color.Red, - fontSize = 16.sp, + fontSize = HeaderTextSize, + color = if (isConnected) colorScheme.primary else colorScheme.error, fontWeight = FontWeight.Medium ) } @@ -256,43 +651,12 @@ fun ChatHeaderContent( onSidebarClick: () -> Unit, onTripleClick: () -> Unit, onShowAppInfo: () -> Unit, - onLocationChannelsClick: () -> Unit + onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme when { - selectedPrivatePeer != null -> { - // Private chat header - Fully reactive state tracking - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) - val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) - val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) - - // Reactive favorite computation - no more static lookups! - val isFavorite = isFavoriteReactive( - peerID = selectedPrivatePeer, - peerFingerprints = peerFingerprints, - favoritePeers = favoritePeers - ) - val sessionState = peerSessionStates[selectedPrivatePeer] - - Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") - - // Pass geohash context and people for NIP-17 chat title formatting - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) - - PrivateChatHeader( - peerID = selectedPrivatePeer, - peerNicknames = peerNicknames, - isFavorite = isFavorite, - sessionState = sessionState, - selectedLocationChannel = selectedLocationChannel, - geohashPeople = geohashPeople, - onBackClick = onBackClick, - onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } - ) - } currentChannel != null -> { // Channel header ChannelHeader( @@ -311,141 +675,14 @@ fun ChatHeaderContent( onTripleTitleClick = onTripleClick, onSidebarClick = onSidebarClick, onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = onLocationNotesClick, viewModel = viewModel ) } } } -@Composable -private fun PrivateChatHeader( - peerID: String, - peerNicknames: Map, - isFavorite: Boolean, - sessionState: String?, - selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, - geohashPeople: List, - onBackClick: () -> Unit, - onToggleFavorite: () -> Unit -) { - val colorScheme = MaterialTheme.colorScheme - val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") - // Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup) - val isMutualFavorite = remember(peerID, peerNicknames) { - try { - if (isNostrDM) return@remember false - if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true - } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true - } else false - } catch (_: Exception) { false } - } - // Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity) - val titleText: String = if (isNostrDM) { - val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash - val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:") - val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) } - val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown" - val geoPart = geohash?.let { "#$it" } ?: "#geohash" - "$geoPart/@$baseName" - } else { - // Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key - peerNicknames[peerID] ?: run { - val titleFromFavorites = try { - if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname - } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname - } else null - } catch (_: Exception) { null } - titleFromFavorites ?: peerID.take(12) - } - } - - Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( - onClick = onBackClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = colorScheme.primary - ), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = (-8).dp) // Move even further left to minimize margin - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(16.dp), - tint = colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "back", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - } - - // Title - perfectly centered regardless of other elements - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.Center) - ) { - - Text( - text = titleText, - style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500) // Orange - ) - - Spacer(modifier = Modifier.width(4.dp)) - - // Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists - val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite) - if (showGlobe) { - Icon( - imageVector = Icons.Outlined.Public, - contentDescription = "Nostr reachable", - modifier = Modifier.size(14.dp), - tint = Color(0xFF9B59B6) // Purple like iOS - ) - } else { - NoiseSessionIcon( - sessionState = sessionState, - modifier = Modifier.size(14.dp) - ) - } - - } - - // Favorite button - positioned on the right - IconButton( - onClick = { - Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") - onToggleFavorite() - }, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon - tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey - ) - } - } -} @Composable private fun ChannelHeader( @@ -455,59 +692,18 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - - Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( - onClick = onBackClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = colorScheme.primary - ), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = (-8).dp) // Move even further left to minimize margin - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(16.dp), - tint = colorScheme.primary - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "back", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - } - - // Title - perfectly centered regardless of other elements - Text( - text = "channel: $channel", - style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500), // Orange to match input field - modifier = Modifier - .align(Alignment.Center) - .clickable { onSidebarClick() } - ) - - // Leave button - positioned on the right - TextButton( - onClick = onLeaveChannel, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Text( - text = "leave", - style = MaterialTheme.typography.bodySmall, - color = Color.Red - ) - } + + // No back affordance: the close action on the right is the way out, exactly as in a private + // chat. Leaving the channel outright lives on its row in the network sheet, so it does not + // need a second, easily-mistaken home next to the exit. + ConversationHeader( + leadingIconRes = R.drawable.ic_spec_chat_bubbles, + leadingIconTint = colorScheme.primary, + leadingContentDescription = null, + title = "#$channel", + onTitleClick = onSidebarClick + ) { + CloseButton(onClick = onBackClick) } } @@ -519,123 +715,189 @@ private fun MainHeader( onTripleTitleClick: () -> Unit, onSidebarClick: () -> Unit, onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit, viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) - val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val isConnected by viewModel.isConnected.observeAsState(false) - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + val palette = LocalBitchatPalette.current + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val crowdingMode = headerCrowdingMode(maxWidth) + Row( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "bitchat/", - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.primary, - modifier = Modifier.singleOrTripleClickable( - onSingleClick = onTitleClick, - onTripleClick = onTripleTitleClick + // Keep the brand and trailing actions fixed. Only the nickname yields under pressure. + BitChatBrandButton( + onClick = onTitleClick, + onTripleClick = onTripleTitleClick, + contentDescription = stringResource(R.string.cd_open_about), + modifier = Modifier.size(HeaderTapTarget), + ) + + // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than the + // spacing between the mark and path label. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .offset(x = (-6).dp) + ) { + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + // Dimmed: the slash is a separator, not content. At full brightness it competed + // with the nickname beside it. + color = colorScheme.primary.copy(alpha = 0.45f), + modifier = Modifier.padding(end = 2.dp) ) - ) - - Spacer(modifier = Modifier.width(2.dp)) - - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - } - - // Right section with location channels button and peer counter - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - - // Tor status cable icon when Tor is enabled - TorStatusIcon(modifier = Modifier.size(15.dp)) - // Location channels button (matching iOS implementation) - LocationChannelsButton( - viewModel = viewModel, - onClick = onLocationChannelsClick - ) - - PeerCounter( - connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, - joinedChannels = joinedChannels, - hasUnreadChannels = hasUnreadChannels, - hasUnreadPrivateMessages = hasUnreadPrivateMessages, - isConnected = isConnected, - selectedLocationChannel = selectedLocationChannel, - geohashPeople = geohashPeople, - onClick = onSidebarClick - ) - } - } -} + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange, + modifier = Modifier.weight(1f) + ) + } -@Composable -private fun LocationChannelsButton( - viewModel: ChatViewModel, - onClick: () -> Unit -) { - val colorScheme = MaterialTheme.colorScheme - - // Get current channel selection from location manager - val selectedChannel by viewModel.selectedLocationChannel.observeAsState() - val teleported by viewModel.isTeleported.observeAsState(false) - - val (badgeText, badgeColor) = when (selectedChannel) { - is com.bitchat.android.geohash.ChannelID.Mesh -> { - "#mesh" to Color(0xFF007AFF) // iOS blue for mesh - } - is com.bitchat.android.geohash.ChannelID.Location -> { - val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash - "#$geohash" to Color(0xFF00C851) // Green for location - } - null -> "#mesh" to Color(0xFF007AFF) // Default to mesh - } - - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = badgeColor - ), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = badgeText, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = badgeColor, - maxLines = 1 - ) - - // Teleportation indicator (like iOS) - if (teleported) { - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = "Teleported", - modifier = Modifier.size(12.dp), - tint = badgeColor + // Order, left to right: unread DMs, notes, channel, people. This cluster is measured + // before the weighted nickname, so actions cannot be pushed off-screen by identity. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (hasUnreadPrivateMessages.isNotEmpty()) { + HeaderIconButton( + onClick = { viewModel.openLatestUnreadPrivateChat() }, + contentDescription = stringResource(R.string.cd_unread_private_messages) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_private_messages), + modifier = Modifier.size(HeaderIconSize), + tint = palette.accentOrange + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) + + LocationChannelsButton( + viewModel = viewModel, + onClick = onLocationChannelsClick, + showLabel = crowdingMode != HeaderCrowdingMode.IconOnlyLocationChannel + ) + } + + PeerCounter( + connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, + joinedChannels = joinedChannels, + hasUnreadChannels = hasUnreadChannels, + isConnected = isConnected, + selectedLocationChannel = selectedLocationChannel, + geohashPeople = geohashPeople, + onClick = onSidebarClick, + showJoinedChannelCount = crowdingMode == HeaderCrowdingMode.Full ) } } } } + +/** + * Current channel indicator: a globe for geohash channels, a mesh glyph for the local mesh. + * + * The design brief asked for the "addition of globe icon to represent channels". Previously this + * was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width + * and 40.dp minimum height that fought the header's explicit sizing. + */ +@Composable +private fun LocationChannelsButton( + viewModel: ChatViewModel, + onClick: () -> Unit, + showLabel: Boolean +) { + val colorScheme = MaterialTheme.colorScheme + + // Get current channel selection from location manager + val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + + val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location + val badgeText = when (val channel = selectedChannel) { + // Geohashes keep the '#' because that is how they are written and typed everywhere else. + is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}" + // The local mesh is not a hashtag channel, and the mesh glyph already says what it is, + // so it is plain "mesh". + else -> stringResource(R.string.mesh_label) + } + val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary + // Tor status only tints the globe (location channels). The local mesh stays blue. + val torVisual = if (isLocation) { + rememberTorConnectionVisual(normal = channelColor) + } else { + TorConnectionVisual(tint = channelColor, isProgress = false) + } + val badgeIconRes = if (isLocation) { + R.drawable.ic_spec_globe + } else { + R.drawable.ic_spec_range + } + val actionDescription = stringResource(R.string.cd_open_location_channels) + val contentDescription = locationChannelContentDescription( + actionDescription = actionDescription, + channelLabel = badgeText, + showLabel = showLabel + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (showLabel) Arrangement.spacedBy(6.dp) else Arrangement.Center, + modifier = Modifier + .clip(HeaderClusterShape) + .pressScaleClickable( + onClick = onClick, + onClickLabel = actionDescription + ) + .height(HeaderTapTarget) + .widthIn(min = HeaderTapTarget) + .padding(end = if (showLabel) 6.dp else 0.dp) + ) { + TorAwareHeaderIcon( + painter = painterResource(badgeIconRes), + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = contentDescription + ) + + if (showLabel) { + Text( + text = badgeText, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = channelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 88.dp) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 8638c094..1ae1c47f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,26 +1,52 @@ package com.bitchat.android.ui +import android.os.Build +import com.bitchat.android.ui.theme.BitchatFontFamily +// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition + +// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel + + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.animation.* -import androidx.compose.animation.core.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.ui.platform.LocalContext import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NearbyNotesController +import com.bitchat.android.ui.media.FullScreenImageViewer +import com.bitchat.android.ui.theme.BitchatMotion /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -35,47 +61,199 @@ import com.bitchat.android.model.BitchatMessage @Composable fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme - val messages by viewModel.messages.observeAsState(emptyList()) - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val nickname by viewModel.nickname.observeAsState("") - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() - val currentChannel by viewModel.currentChannel.observeAsState() - val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) - val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val channelMessages by viewModel.channelMessages.observeAsState(emptyMap()) - val showSidebar by viewModel.showSidebar.observeAsState(false) - val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) - val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) - val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) - val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) - val showAppInfo by viewModel.showAppInfo.observeAsState(false) + val messages by viewModel.messages.collectAsStateWithLifecycle() + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle() + val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle() + val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle() + val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle() + val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle() + val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle() + val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle() + val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle() + val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() + val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() + val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var passwordInput by remember { mutableStateOf("") } var showLocationChannelsSheet by remember { mutableStateOf(false) } + var showLocationNotesSheet by remember { mutableStateOf(false) } var showUserSheet by remember { mutableStateOf(false) } var selectedUserForSheet by remember { mutableStateOf("") } var selectedMessageForSheet by remember { mutableStateOf(null) } + var showFullScreenImageViewer by remember { mutableStateOf(false) } + var viewerImagePaths by remember { mutableStateOf(emptyList()) } + var initialViewerIndex by remember { mutableStateOf(0) } var forceScrollToBottom by remember { mutableStateOf(false) } var isScrolledUp by remember { mutableStateOf(false) } + LaunchedEffect(selectedPrivatePeer) { + messageText = TextFieldValue( + selectedPrivatePeer + ?.let(viewModel::conversationDraft) + .orEmpty() + ) + } + // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { showPasswordDialog = showPasswordPrompt } - val isConnected by viewModel.isConnected.observeAsState(false) - val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle() - // Determine what messages to show + // Get location channel info for timeline switching + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val context = LocalContext.current + val locationManager = remember { LocationChannelManager.getInstance(context) } + val nearbyNotesController = remember { NearbyNotesController.shared } + val liveVoiceManager = remember(context) { + com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context) + } + val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle() + val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) + val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val nearbyNotes by remember { LocationNotesManager.getInstance() } + .notes + .collectAsStateWithLifecycle() + val buildingGeohash = availableLocationChannels + .firstOrNull { it.level == GeohashChannelLevel.BUILDING } + ?.geohash + val isMeshTimeline = + currentChannel == null && + selectedLocationChannel is ChannelID.Mesh && + selectedPrivatePeer == null && + privateChatSheetPeer == null + + val processLifecycleOwner = remember { ProcessLifecycleOwner.get() } + DisposableEffect(processLifecycleOwner, nearbyNotesController) { + val lifecycle = processLifecycleOwner.lifecycle + val observer = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + nearbyNotesController.updateAppForeground(true) + liveVoiceManager.setAppForeground(true) + } + + override fun onStop(owner: LifecycleOwner) { + nearbyNotesController.updateAppForeground(false) + liveVoiceManager.setAppForeground(false) + } + } + + lifecycle.addObserver(observer) + nearbyNotesController.updateAppForeground( + lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED), + ) + + onDispose { + lifecycle.removeObserver(observer) + nearbyNotesController.updateAppForeground(false) + liveVoiceManager.setAppForeground(false) + } + } + + LaunchedEffect(isMeshTimeline, privateChatSheetPeer, selectedPrivatePeer) { + when { + privateChatSheetPeer != null -> liveVoiceManager.showDirectMessage(privateChatSheetPeer!!) + selectedPrivatePeer != null -> liveVoiceManager.showDirectMessage(selectedPrivatePeer!!) + isMeshTimeline -> liveVoiceManager.showPublicMesh() + else -> liveVoiceManager.clearVisibleConversation() + } + } + + DisposableEffect( + isMeshTimeline, + locationEnabled, + locationPermissionState, + buildingGeohash, + nearbyNotesController, + ) { + nearbyNotesController.updateAvailability( + locationEnabled = locationEnabled, + locationAuthorized = + locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED, + buildingGeohash = buildingGeohash, + ) + if (isMeshTimeline) nearbyNotesController.activate() + onDispose { + if (isMeshTimeline) nearbyNotesController.deactivate() + } + } + + // Determine what messages to show based on current context (unified timelines) + // Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet val displayMessages = when { - selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList() currentChannel != null -> channelMessages[currentChannel] ?: emptyList() - else -> messages + else -> { + val locationChannel = selectedLocationChannel + if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) { + val geokey = "geo:${locationChannel.channel.geohash}" + channelMessages[geokey] ?: emptyList() + } else { + messages // Mesh timeline + } + } + } + + // Identity of the timeline on screen, derived exactly like displayMessages above. Drives the + // per-conversation scroll position and animation state in MessagesList. + val conversationKey = when { + currentChannel != null -> "channel:$currentChannel" + else -> { + val locationChannel = selectedLocationChannel + if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) { + "geo:${locationChannel.channel.geohash}" + } else { + "mesh" + } + } + } + + val mentionPeerIdentities = remember( + displayMessages, + currentChannel, + selectedLocationChannel, + connectedPeers, + peerNicknames, + geohashPeople, + ) { + val knownPeers = if ( + currentChannel == null && selectedLocationChannel is ChannelID.Location + ) { + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) + geohashPeople.mapNotNull { person -> + if (isUnannouncedNickname(person.displayName)) return@mapNotNull null + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + displayName to PeerIdentity.nostr(person.id) + } + } else { + connectedPeers.mapNotNull { peerID -> + peerNicknames[peerID]?.let { displayName -> + displayName to PeerIdentity.mesh(peerID) + } + } + } + buildMentionPeerIdentityMap(displayMessages, knownPeers) + } + + // Determine whether to show media buttons (only hide in geohash location chats) + val showMediaButtons = when { + currentChannel != null -> true + else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location } // Use WindowInsets to handle keyboard properly @@ -84,53 +262,82 @@ fun ChatScreen(viewModel: ChatViewModel) { .fillMaxSize() .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { - val headerHeight = 42.dp - + val headerHeight = ChatHeaderHeight + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + // Both bars are translucent and the conversation scrolls underneath them, so their + // heights are reserved as list padding instead of as layout space. The composer's height + // varies (suggestion rows, wrapped lines), so it is measured rather than assumed. + var composerHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + // Main content area that responds to keyboard/window insets Column( modifier = Modifier .fillMaxSize() - .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets - .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded - ) { - // Header spacer - creates exact space for the floating header (status bar + compact header) - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(headerHeight) - ) + .then( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+: Handle both IME and navigation bar insets in Compose + Modifier.windowInsetsPadding( + WindowInsets.ime.union(WindowInsets.navigationBars) + ) + } else { + // Android 10 and below: Window is resized by the system (adjustResize), + // so only account for the navigation bar. + Modifier.windowInsetsPadding(WindowInsets.navigationBars) + } + ) + ) { + Box(modifier = Modifier.weight(1f)) { // Messages area - takes up available space, will compress when keyboard appears + // Nearby-notes strip and the reveal hint both live in this Box alongside the + // list, rather than in a Column above it, because the conversation has to scroll + // underneath the translucent bars. Their heights are reserved as list padding. + var notesStripHeight by remember { mutableStateOf(0.dp) } + val showNotesStrip = + isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty() + MessagesList( messages = displayMessages, currentUserNickname = nickname, - meshService = viewModel.meshService, - modifier = Modifier.weight(1f), + meshService = viewModel.meshServiceFacade, + mentionPeerIdentities = mentionPeerIdentities, + modifier = Modifier.fillMaxSize(), + conversationKey = conversationKey, + contentPadding = PaddingValues( + top = statusBarHeight + headerHeight + + (if (showNotesStrip) notesStripHeight else 0.dp), + bottom = composerHeight + ), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { fullSenderName -> // Single click - mention user in text input val currentText = messageText.text - + // Extract base nickname and hash suffix from full sender name val (baseName, hashSuffix) = splitSuffix(fullSenderName) - + // Check if we're in a geohash channel to include hash suffix val selectedLocationChannel = viewModel.selectedLocationChannel.value - val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) { + val mentionText = if ( + selectedLocationChannel is ChannelID.Location && + hashSuffix.isNotEmpty() + ) { // In geohash chat - include the hash suffix from the full display name "@$baseName$hashSuffix" } else { // Regular chat - just the base nickname "@$baseName" } - + val newText = when { currentText.isEmpty() -> "$mentionText " currentText.endsWith(" ") -> "$currentText$mentionText " else -> "$currentText $mentionText " } - + messageText = TextFieldValue( text = newText, selection = TextRange(newText.length) @@ -143,28 +350,82 @@ fun ChatScreen(viewModel: ChatViewModel) { selectedUserForSheet = baseName selectedMessageForSheet = message showUserSheet = true + }, + onCancelTransfer = { msg -> + viewModel.cancelMediaSend(msg.id) + }, + onImageClick = { currentPath, allImagePaths, initialIndex -> + viewerImagePaths = allImagePaths + initialViewerIndex = initialIndex + showFullScreenImageViewer = true } ) - // Input area - stays at bottom - ChatInputSection( - messageText = messageText, - onMessageTextChange = { newText: TextFieldValue -> - messageText = newText - viewModel.updateCommandSuggestions(newText.text) - viewModel.updateMentionSuggestions(newText.text) - }, - onSend = { - if (messageText.text.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.text.trim()) + + if (showNotesStrip) { + NearbyNotesStrip( + noteCount = nearbyNotes.size, + onClick = { showLocationNotesSheet = true }, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = statusBarHeight + headerHeight) + .onSizeChanged { size -> + notesStripHeight = with(density) { size.height.toDp() } + }, + ) + } + + // Input area - overlays the bottom of the conversation + // Bridge file share from lower-level input to ViewModel + androidx.compose.runtime.LaunchedEffect(Unit) { + com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path -> + viewModel.sendFileNote(peer, channel, path) + } + } + + ChatInputSection( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + composerHeight = with(density) { size.height.toDp() } + }, + messageText = messageText, + onMessageTextChange = { newText: TextFieldValue -> + messageText = newText + viewModel.setConversationDraft(selectedPrivatePeer, newText.text) + viewModel.updateCommandSuggestions(newText.text) + viewModel.updateMentionSuggestions(newText.text) + }, + onSend = { + if (messageText.text.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.text.trim()) { accepted -> + if (accepted) { messageText = TextFieldValue("") - forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll + viewModel.setConversationDraft(selectedPrivatePeer, "") + // Clearing the field in code does not run onMessageTextChange, + // so the popups have to be dismissed here. + viewModel.clearSuggestions() + forceScrollToBottom = !forceScrollToBottom } - }, - showCommandSuggestions = showCommandSuggestions, - commandSuggestions = commandSuggestions, - showMentionSuggestions = showMentionSuggestions, - mentionSuggestions = mentionSuggestions, - onCommandSuggestionClick = { suggestion: CommandSuggestion -> + } + } + }, + onSendVoiceNote = { peer, onionOrChannel, path -> + viewModel.sendVoiceNote(peer, onionOrChannel, path) + }, + onSendImageNote = { peer, onionOrChannel, path -> + viewModel.sendImageNote(peer, onionOrChannel, path) + }, + onSendFileNote = { peer, onionOrChannel, path -> + viewModel.sendFileNote(peer, onionOrChannel, path) + }, + recorderFactory = viewModel::createVoiceRecorder, + + showCommandSuggestions = showCommandSuggestions, + commandSuggestions = commandSuggestions, + showMentionSuggestions = showMentionSuggestions, + mentionSuggestions = mentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, + onCommandSuggestionClick = { suggestion: CommandSuggestion -> val commandText = viewModel.selectCommandSuggestion(suggestion) messageText = TextFieldValue( text = commandText, @@ -178,103 +439,78 @@ fun ChatScreen(viewModel: ChatViewModel) { selection = TextRange(mentionText.length) ) }, - selectedPrivatePeer = selectedPrivatePeer, + selectedPrivatePeer = null, currentChannel = currentChannel, nickname = nickname, - colorScheme = colorScheme + colorScheme = colorScheme, + showMediaButtons = showMediaButtons ) + } } // Floating header - positioned absolutely at top, ignores keyboard ChatFloatingHeader( - headerHeight = headerHeight, - selectedPrivatePeer = selectedPrivatePeer, + selectedPrivatePeer = null, currentChannel = currentChannel, nickname = nickname, viewModel = viewModel, colorScheme = colorScheme, - onSidebarToggle = { viewModel.showSidebar() }, + onSidebarToggle = { viewModel.showMeshPeerList() }, onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() }, - onLocationChannelsClick = { showLocationChannelsSheet = true } + onLocationChannelsClick = { showLocationChannelsSheet = true }, + onLocationNotesClick = { + nearbyNotesController.reveal() + showLocationNotesSheet = true + } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - color = colorScheme.outline.copy(alpha = 0.3f) - ) - - val alpha by animateFloatAsState( - targetValue = if (showSidebar) 0.5f else 0f, - animationSpec = tween( - durationMillis = 300, - easing = EaseOutCubic - ), label = "overlayAlpha" - ) - - // Only render the background if it's visible - if (alpha > 0f) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = alpha)) - .clickable { viewModel.hideSidebar() } - .zIndex(1f) - ) - } - // Scroll-to-bottom floating button AnimatedVisibility( - visible = isScrolledUp && !showSidebar, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + visible = isScrolledUp, + // Short and eased: the button appears mid-scroll, so a slow entrance draws the eye + // away from the messages the user is actually reading. + enter = slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { it / 2 } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)), + exit = slideOutVertically( + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + targetOffsetY = { it / 2 } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)), modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 64.dp) + .padding(end = 16.dp, bottom = composerHeight + 8.dp) .zIndex(1.5f) .windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.ime) ) { Surface( shape = CircleShape, - color = colorScheme.background, + color = colorScheme.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(2.dp, Color(0xFF00C851)) + border = BorderStroke(1.dp, colorScheme.primary) ) { IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) { Icon( imageVector = Icons.Filled.ArrowDownward, - contentDescription = "Scroll to bottom", - tint = Color(0xFF00C851) + contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), + modifier = Modifier.size(22.dp), + tint = colorScheme.primary ) } } } + } - AnimatedVisibility( - visible = showSidebar, - enter = slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(300, easing = EaseOutCubic) - ) + fadeIn(animationSpec = tween(300)), - exit = slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(250, easing = EaseInCubic) - ) + fadeOut(animationSpec = tween(250)), - modifier = Modifier.zIndex(2f) - ) { - SidebarOverlay( - viewModel = viewModel, - onDismiss = { viewModel.hideSidebar() }, - modifier = Modifier.fillMaxSize() - ) - } + // Full-screen image viewer - separate from other sheets to allow image browsing without navigation + if (showFullScreenImageViewer) { + FullScreenImageViewer( + imagePaths = viewerImagePaths, + initialIndex = initialViewerIndex, + onClose = { showFullScreenImageViewer = false } + ) } // Dialogs and Sheets @@ -300,6 +536,12 @@ fun ChatScreen(viewModel: ChatViewModel) { onAppInfoDismiss = { viewModel.hideAppInfo() }, showLocationChannelsSheet = showLocationChannelsSheet, onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false }, + onLocationNotesFromChannelsClick = { + showLocationChannelsSheet = false + showLocationNotesSheet = true + }, + showLocationNotesSheet = showLocationNotesSheet, + onLocationNotesSheetDismiss = { showLocationNotesSheet = false }, showUserSheet = showUserSheet, onUserSheetDismiss = { showUserSheet = false @@ -307,71 +549,209 @@ fun ChatScreen(viewModel: ChatViewModel) { }, selectedUserForSheet = selectedUserForSheet, selectedMessageForSheet = selectedMessageForSheet, - viewModel = viewModel + viewModel = viewModel, + showVerificationSheet = showVerificationSheet, + onVerificationSheetDismiss = viewModel::hideVerificationSheet, + showSecurityVerificationSheet = showSecurityVerificationSheet, + onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet, + showMeshPeerListSheet = showMeshPeerListSheet, + onMeshPeerListDismiss = viewModel::hideMeshPeerList, ) + + legacyPrivateMediaConsent?.let { request -> + AlertDialog( + onDismissRequest = { viewModel.cancelLegacyPrivateMedia(request.requestId) }, + title = { Text(stringResource(com.bitchat.android.R.string.private_media_legacy_title)) }, + text = { + Text( + stringResource( + com.bitchat.android.R.string.private_media_legacy_body, + request.fileName, + request.recipientNickname, + request.warning + ) + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.approveLegacyPrivateMedia(request.requestId) }) { + Text(stringResource(com.bitchat.android.R.string.private_media_legacy_send_once)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.cancelLegacyPrivateMedia(request.requestId) }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } } @Composable -private fun ChatInputSection( - messageText: TextFieldValue, - onMessageTextChange: (TextFieldValue) -> Unit, - onSend: () -> Unit, - showCommandSuggestions: Boolean, - commandSuggestions: List, - showMentionSuggestions: Boolean, - mentionSuggestions: List, - onCommandSuggestionClick: (CommandSuggestion) -> Unit, - onMentionSuggestionClick: (String) -> Unit, - selectedPrivatePeer: String?, - currentChannel: String?, - nickname: String, - colorScheme: ColorScheme +private fun NearbyNotesStrip( + noteCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, ) { Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.background + onClick = onClick, + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), ) { - Column { - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = onCommandSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) - } - - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { - MentionSuggestionsBox( - suggestions = mentionSuggestions, - onSuggestionClick = onMentionSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) - } - - MessageInput( - value = messageText, - onValueChange = onMessageTextChange, - onSend = onSend, - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "📍 " + if (noteCount == 1) { + stringResource(R.string.nearby_notes_one) + } else { + stringResource(R.string.nearby_notes_many, noteCount) + }, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.primary, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + ) + Text( + text = "›", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 18.sp, ) } } } -@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatInputSection( + messageText: TextFieldValue, + onMessageTextChange: (TextFieldValue) -> Unit, + onSend: () -> Unit, + onSendVoiceNote: (String?, String?, String) -> Unit, + onSendImageNote: (String?, String?, String) -> Unit, + onSendFileNote: (String?, String?, String) -> Unit, + showCommandSuggestions: Boolean, + commandSuggestions: List, + showMentionSuggestions: Boolean, + mentionSuggestions: List, + mentionPeerIdentities: Map = emptyMap(), + onCommandSuggestionClick: (CommandSuggestion) -> Unit, + onMentionSuggestionClick: (String) -> Unit, + selectedPrivatePeer: String?, + currentChannel: String?, + nickname: String, + colorScheme: ColorScheme, + showMediaButtons: Boolean, + recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null, + modifier: Modifier = Modifier +) { + val context = androidx.compose.ui.platform.LocalContext.current + val activePublicTalker by remember(context) { + com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context).activePublicTalker + }.collectAsState() + Column( + // Flat, slightly translucent screen background — the same treatment as the top bar, so the + // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked + // like a smudge above a crisp hairline. The rule is inside the background so the whole bar + // is one surface with a top border, rather than a line floating over the conversation. + modifier = modifier + .fillMaxWidth() + .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) + ) { + // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onCommandSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + } + // Retain the final populated list while the picker exits. The state layer clears + // suggestions together with visibility; without this snapshot the panel would empty and + // snap shut before its shrink/fade animation had a frame to run. + var retainedMentionSuggestions by remember { mutableStateOf(emptyList()) } + LaunchedEffect(mentionSuggestions) { + if (mentionSuggestions.isNotEmpty()) { + retainedMentionSuggestions = mentionSuggestions + } + } + val mentionPickerVisible = showMentionSuggestions && mentionSuggestions.isNotEmpty() + val displayedMentionSuggestions = mentionSuggestions.ifEmpty { + retainedMentionSuggestions + } + + AnimatedVisibility( + visible = mentionPickerVisible, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + expandFrom = Alignment.Bottom + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ), + shrinkTowards = Alignment.Bottom + ) + ) { + Column { + MentionSuggestionsBox( + suggestions = displayedMentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, + onSuggestionClick = onMentionSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + } + } + MessageInput( + value = messageText, + onValueChange = onMessageTextChange, + onSend = onSend, + onSendVoiceNote = onSendVoiceNote, + onSendImageNote = onSendImageNote, + onSendFileNote = onSendFileNote, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + showMediaButtons = showMediaButtons, + mentionPeerIdentities = mentionPeerIdentities, + recorderFactory = recorderFactory, + activePublicTalker = activePublicTalker, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * Opacity shared by both bars. + * + * Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads + * as sitting over the content rather than boxing it in — without ever costing legibility. + */ +private const val BarBackgroundAlpha = 0.88f + +/** + * Fraction of the header that stays fully opaque, measured from the top. + * + * The header is the one place a gradient earns its keep: the status bar is transparent, so the + * header has to be the true background colour where the two meet or the system bar stops looking + * like part of the app. Everything below that stop matches the composer's flat translucency. + */ +private const val HeaderOpaqueStop = 0.72f @Composable private fun ChatFloatingHeader( - headerHeight: Dp, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, @@ -380,42 +760,58 @@ private fun ChatFloatingHeader( onSidebarToggle: () -> Unit, onShowAppInfo: () -> Unit, onPanicClear: () -> Unit, - onLocationChannelsClick: () -> Unit + onLocationChannelsClick: () -> Unit, + onLocationNotesClick: () -> Unit ) { - Surface( + val context = androidx.compose.ui.platform.LocalContext.current + val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } + + Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area - color = colorScheme.background // Solid background color extending into status bar - ) { - TopAppBar( - title = { - ChatHeaderContent( - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - viewModel = viewModel, - onBackClick = { - when { - selectedPrivatePeer != null -> viewModel.endPrivateChat() - currentChannel != null -> viewModel.switchToChannel(null) - } - }, - onSidebarClick = onSidebarToggle, - onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick + // Fully opaque where it meets the system status bar, fading to translucent at its + // lower edge. The status bar itself is transparent, so anything less than opaque at + // the top would let the wallpaper or a light system-bar scrim bleed through and the + // header would stop reading as part of the app. + .background( + Brush.verticalGradient( + 0f to colorScheme.background, + HeaderOpaqueStop to colorScheme.background, + 1f to colorScheme.background.copy(alpha = BarBackgroundAlpha) ) + ) + .windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area + ) { + // No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and + // applies its own minimum heights, which made the header's spacing impossible to specify + // exactly. Height and edge insets belong to each header variant, so that a conversation + // header rendered here and one rendered in a sheet are laid out identically. + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) + } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.height(headerHeight) // Ensure compact header height + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo, + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() + } ) } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatDialogs( showPasswordDialog: Boolean, @@ -428,12 +824,23 @@ private fun ChatDialogs( onAppInfoDismiss: () -> Unit, showLocationChannelsSheet: Boolean, onLocationChannelsSheetDismiss: () -> Unit, + onLocationNotesFromChannelsClick: () -> Unit, + showLocationNotesSheet: Boolean, + onLocationNotesSheetDismiss: () -> Unit, showUserSheet: Boolean, onUserSheetDismiss: () -> Unit, selectedUserForSheet: String, selectedMessageForSheet: BitchatMessage?, - viewModel: ChatViewModel + viewModel: ChatViewModel, + showVerificationSheet: Boolean, + onVerificationSheetDismiss: () -> Unit, + showSecurityVerificationSheet: Boolean, + onSecurityVerificationSheetDismiss: () -> Unit, + showMeshPeerListSheet: Boolean, + onMeshPeerListDismiss: () -> Unit, ) { + val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle() + // Password dialog PasswordPromptDialog( show = showPasswordDialog, @@ -445,20 +852,38 @@ private fun ChatDialogs( ) // About sheet + var showDebugSheet by remember { mutableStateOf(false) } AboutSheet( isPresented = showAppInfo, - onDismiss = onAppInfoDismiss + onDismiss = onAppInfoDismiss, + onShowDebug = { showDebugSheet = true } ) + if (showDebugSheet) { + com.bitchat.android.ui.debug.DebugSettingsSheet( + isPresented = showDebugSheet, + onDismiss = { showDebugSheet = false }, + meshService = viewModel.meshService + ) + } // Location channels sheet if (showLocationChannelsSheet) { LocationChannelsSheet( isPresented = showLocationChannelsSheet, onDismiss = onLocationChannelsSheetDismiss, + onLocationNotesClick = onLocationNotesFromChannelsClick, viewModel = viewModel ) } + // Location notes sheet (extracted to separate presenter) + if (showLocationNotesSheet) { + LocationNotesSheetPresenter( + viewModel = viewModel, + onDismiss = onLocationNotesSheetDismiss + ) + } + // User action sheet if (showUserSheet) { ChatUserSheet( @@ -469,4 +894,44 @@ private fun ChatDialogs( viewModel = viewModel ) } + // MeshPeerList sheet (network view) + if (showMeshPeerListSheet){ + MeshPeerListSheet( + isPresented = showMeshPeerListSheet, + viewModel = viewModel, + onDismiss = onMeshPeerListDismiss, + onShowVerification = { + onMeshPeerListDismiss() + viewModel.showVerificationSheet(fromSidebar = true) + } + ) + } + + if (showVerificationSheet) { + VerificationSheet( + isPresented = showVerificationSheet, + onDismiss = onVerificationSheetDismiss, + viewModel = viewModel + ) + } + + if (showSecurityVerificationSheet) { + SecurityVerificationSheet( + isPresented = showSecurityVerificationSheet, + onDismiss = onSecurityVerificationSheetDismiss, + viewModel = viewModel + ) + } + + if (privateChatSheetPeer != null) { + PrivateChatSheet( + isPresented = true, + peerID = privateChatSheetPeer!!, + viewModel = viewModel, + onDismiss = { + viewModel.hidePrivateChatSheet() + viewModel.endPrivateChat() + } + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index a0770218..f4504cc2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -1,10 +1,17 @@ package com.bitchat.android.ui import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Centralized state definitions and data classes for the chat system @@ -21,154 +28,178 @@ data class CommandSuggestion( /** * Contains all the observable state for the chat system */ -class ChatState { +class ChatState( + scope: CoroutineScope +) { // Core messages and peer state - private val _messages = MutableLiveData>(emptyList()) - val messages: LiveData> = _messages + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() - private val _connectedPeers = MutableLiveData>(emptyList()) - val connectedPeers: LiveData> = _connectedPeers + private val _connectedPeers = MutableStateFlow>(emptyList()) + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() - private val _nickname = MutableLiveData() - val nickname: LiveData = _nickname + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() - private val _isConnected = MutableLiveData(false) - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Private chats - private val _privateChats = MutableLiveData>>(emptyMap()) - val privateChats: LiveData>> = _privateChats + private val _privateChats = MutableStateFlow>>(emptyMap()) + val privateChats: StateFlow>> = _privateChats.asStateFlow() - private val _selectedPrivateChatPeer = MutableLiveData(null) - val selectedPrivateChatPeer: LiveData = _selectedPrivateChatPeer + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() - private val _unreadPrivateMessages = MutableLiveData>(emptySet()) - val unreadPrivateMessages: LiveData> = _unreadPrivateMessages + private val _unreadPrivateMessages = MutableStateFlow>(emptySet()) + val unreadPrivateMessages: StateFlow> = _unreadPrivateMessages.asStateFlow() // Channels - private val _joinedChannels = MutableLiveData>(emptySet()) - val joinedChannels: LiveData> = _joinedChannels + private val _joinedChannels = MutableStateFlow>(emptySet()) + val joinedChannels: StateFlow> = _joinedChannels.asStateFlow() - private val _currentChannel = MutableLiveData(null) - val currentChannel: LiveData = _currentChannel + private val _currentChannel = MutableStateFlow(null) + val currentChannel: StateFlow = _currentChannel.asStateFlow() - private val _channelMessages = MutableLiveData>>(emptyMap()) - val channelMessages: LiveData>> = _channelMessages + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() - private val _unreadChannelMessages = MutableLiveData>(emptyMap()) - val unreadChannelMessages: LiveData> = _unreadChannelMessages + private val _unreadChannelMessages = MutableStateFlow>(emptyMap()) + val unreadChannelMessages: StateFlow> = _unreadChannelMessages.asStateFlow() - private val _passwordProtectedChannels = MutableLiveData>(emptySet()) - val passwordProtectedChannels: LiveData> = _passwordProtectedChannels + private val _passwordProtectedChannels = MutableStateFlow>(emptySet()) + val passwordProtectedChannels: StateFlow> = _passwordProtectedChannels.asStateFlow() - private val _showPasswordPrompt = MutableLiveData(false) - val showPasswordPrompt: LiveData = _showPasswordPrompt - - private val _passwordPromptChannel = MutableLiveData(null) - val passwordPromptChannel: LiveData = _passwordPromptChannel - - // Sidebar state - private val _showSidebar = MutableLiveData(false) - val showSidebar: LiveData = _showSidebar + private val _showPasswordPrompt = MutableStateFlow(false) + val showPasswordPrompt: StateFlow = _showPasswordPrompt.asStateFlow() + private val _passwordPromptChannel = MutableStateFlow(null) + val passwordPromptChannel: StateFlow = _passwordPromptChannel.asStateFlow() + // Command autocomplete - private val _showCommandSuggestions = MutableLiveData(false) - val showCommandSuggestions: LiveData = _showCommandSuggestions + private val _showCommandSuggestions = MutableStateFlow(false) + val showCommandSuggestions: StateFlow = _showCommandSuggestions.asStateFlow() - private val _commandSuggestions = MutableLiveData>(emptyList()) - val commandSuggestions: LiveData> = _commandSuggestions + private val _commandSuggestions = MutableStateFlow>(emptyList()) + val commandSuggestions: StateFlow> = _commandSuggestions.asStateFlow() // Mention autocomplete - private val _showMentionSuggestions = MutableLiveData(false) - val showMentionSuggestions: LiveData = _showMentionSuggestions + private val _showMentionSuggestions = MutableStateFlow(false) + val showMentionSuggestions: StateFlow = _showMentionSuggestions.asStateFlow() - private val _mentionSuggestions = MutableLiveData>(emptyList()) - val mentionSuggestions: LiveData> = _mentionSuggestions + private val _mentionSuggestions = MutableStateFlow>(emptyList()) + val mentionSuggestions: StateFlow> = _mentionSuggestions.asStateFlow() // Favorites - private val _favoritePeers = MutableLiveData>(emptySet()) - val favoritePeers: LiveData> = _favoritePeers + private val _favoritePeers = MutableStateFlow>(emptySet()) + val favoritePeers: StateFlow> = _favoritePeers.asStateFlow() + + // Fingerprints of peers who favorited us (drives "favorited you" UI celebrations) + private val _peerFavoritedUs = MutableStateFlow>(emptySet()) + val peerFavoritedUs: StateFlow> = _peerFavoritedUs.asStateFlow() // Noise session states for peers (for reactive UI updates) - private val _peerSessionStates = MutableLiveData>(emptyMap()) - val peerSessionStates: LiveData> = _peerSessionStates + private val _peerSessionStates = MutableStateFlow>(emptyMap()) + val peerSessionStates: StateFlow> = _peerSessionStates.asStateFlow() // Peer fingerprint state for reactive favorites (for reactive UI updates) - private val _peerFingerprints = MutableLiveData>(emptyMap()) - val peerFingerprints: LiveData> = _peerFingerprints + private val _peerFingerprints = MutableStateFlow>(emptyMap()) + val peerFingerprints: StateFlow> = _peerFingerprints.asStateFlow() - private val _peerNicknames = MutableLiveData>(emptyMap()) - val peerNicknames: LiveData> = _peerNicknames + private val _peerNicknames = MutableStateFlow>(emptyMap()) + val peerNicknames: StateFlow> = _peerNicknames.asStateFlow() - private val _peerRSSI = MutableLiveData>(emptyMap()) - val peerRSSI: LiveData> = _peerRSSI + private val _peerRSSI = MutableStateFlow>(emptyMap()) + val peerRSSI: StateFlow> = _peerRSSI.asStateFlow() + + // Direct connection status per peer (for live UI updates) + private val _peerDirect = MutableStateFlow>(emptyMap()) + val peerDirect: StateFlow> = _peerDirect.asStateFlow() // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // Navigation state - private val _showAppInfo = MutableLiveData(false) - val showAppInfo: LiveData = _showAppInfo + private val _showAppInfo = MutableStateFlow(false) + val showAppInfo: StateFlow = _showAppInfo.asStateFlow() + + private val _showMeshPeerList = MutableStateFlow(false) + val showMeshPeerList: StateFlow = _showMeshPeerList.asStateFlow() + + private val _privateChatSheetPeer = MutableStateFlow(null) + val privateChatSheetPeer: StateFlow = _privateChatSheetPeer.asStateFlow() + + private val _showVerificationSheet = MutableStateFlow(false) + val showVerificationSheet: StateFlow = _showVerificationSheet.asStateFlow() + + private val _showSecurityVerificationSheet = MutableStateFlow(false) + val showSecurityVerificationSheet: StateFlow = _showSecurityVerificationSheet.asStateFlow() // Location channels state (for Nostr geohash features) - private val _selectedLocationChannel = MutableLiveData(com.bitchat.android.geohash.ChannelID.Mesh) - val selectedLocationChannel: LiveData = _selectedLocationChannel + private val _selectedLocationChannel = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh) + val selectedLocationChannel: StateFlow = _selectedLocationChannel.asStateFlow() - private val _isTeleported = MutableLiveData(false) - val isTeleported: LiveData = _isTeleported + private val _isTeleported = MutableStateFlow(false) + val isTeleported: StateFlow = _isTeleported.asStateFlow() // Geohash people state (iOS-compatible) - private val _geohashPeople = MutableLiveData>(emptyList()) - val geohashPeople: LiveData> = _geohashPeople + private val _geohashPeople = MutableStateFlow>(emptyList()) + val geohashPeople: StateFlow> = _geohashPeople.asStateFlow() - private val _teleportedGeo = MutableLiveData>(emptySet()) - val teleportedGeo: LiveData> = _teleportedGeo + private val _teleportedGeo = MutableStateFlow>(emptySet()) + val teleportedGeo: StateFlow> = _teleportedGeo.asStateFlow() // Geohash participant counts reactive state (for real-time location channel counts) - private val _geohashParticipantCounts = MutableLiveData>(emptyMap()) - val geohashParticipantCounts: LiveData> = _geohashParticipantCounts + private val _geohashParticipantCounts = MutableStateFlow>(emptyMap()) + val geohashParticipantCounts: StateFlow> = _geohashParticipantCounts.asStateFlow() - // Unread state computed properties - val hasUnreadChannels: MediatorLiveData = MediatorLiveData() - val hasUnreadPrivateMessages: MediatorLiveData = MediatorLiveData() - - init { - // Initialize unread state mediators - hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap -> - hasUnreadChannels.value = unreadMap.values.any { it > 0 } - } - - hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet -> - hasUnreadPrivateMessages.value = unreadSet.isNotEmpty() - } - } + + val hasUnreadChannels: StateFlow = _unreadChannelMessages + .map { unreadMap -> unreadMap.values.any { it > 0 } } + .stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) + + val hasUnreadPrivateMessages: StateFlow = _unreadPrivateMessages + .map { unreadSet -> unreadSet.isNotEmpty() } + .stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) // Getters for internal state access - fun getMessagesValue() = _messages.value ?: emptyList() - fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList() + fun getMessagesValue() = _messages.value + fun getConnectedPeersValue() = _connectedPeers.value fun getNicknameValue() = _nickname.value - fun getPrivateChatsValue() = _privateChats.value ?: emptyMap() + fun getPrivateChatsValue() = _privateChats.value fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value - fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet() - fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet() + fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value + fun getJoinedChannelsValue() = _joinedChannels.value + fun getPeerFavoritedUsValue() = _peerFavoritedUs.value fun getCurrentChannelValue() = _currentChannel.value - fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() - fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() - fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet() - fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false + fun getChannelMessagesValue() = _channelMessages.value + fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value + fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value + fun getShowPasswordPromptValue() = _showPasswordPrompt.value fun getPasswordPromptChannelValue() = _passwordPromptChannel.value - fun getShowSidebarValue() = _showSidebar.value ?: false - fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false - fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() - fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false - fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList() - fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() - fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() - fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() - fun getShowAppInfoValue() = _showAppInfo.value ?: false - fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList() - fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet() - fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap() + fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value + fun getCommandSuggestionsValue() = _commandSuggestions.value + fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value + fun getMentionSuggestionsValue() = _mentionSuggestions.value + fun getFavoritePeersValue() = _favoritePeers.value + fun getPeerSessionStatesValue() = _peerSessionStates.value + fun getPeerFingerprintsValue() = _peerFingerprints.value + fun getShowAppInfoValue() = _showAppInfo.value + fun getGeohashPeopleValue() = _geohashPeople.value + + fun getShowMeshPeerListValue() = _showMeshPeerList.value + fun getPrivateChatSheetPeerValue() = _privateChatSheetPeer.value + + fun getTeleportedGeoValue() = _teleportedGeo.value + fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value // Setters for state updates fun setMessages(messages: List) { @@ -179,8 +210,13 @@ class ChatState { _connectedPeers.value = peers } + fun postTeleportedGeo(teleported: Set) { + _teleportedGeo.value = teleported + } + fun setNickname(nickname: String) { _nickname.value = nickname + com.bitchat.android.services.AppStateStore.setNickname(nickname) } fun setIsConnected(connected: Boolean) { @@ -193,6 +229,7 @@ class ChatState { fun setSelectedPrivateChatPeer(peerID: String?) { _selectedPrivateChatPeer.value = peerID + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(peerID) } fun setUnreadPrivateMessages(unread: Set) { @@ -226,11 +263,7 @@ class ChatState { fun setPasswordPromptChannel(channel: String?) { _passwordPromptChannel.value = channel } - - fun setShowSidebar(show: Boolean) { - _showSidebar.value = show - } - + fun setShowCommandSuggestions(show: Boolean) { _showCommandSuggestions.value = show } @@ -248,7 +281,7 @@ class ChatState { } fun setFavoritePeers(favorites: Set) { - val currentValue = _favoritePeers.value ?: emptySet() + val currentValue = _favoritePeers.value Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites") Log.d("ChatState", "Current value: $currentValue") Log.d("ChatState", "Values equal: ${currentValue == favorites}") @@ -257,8 +290,11 @@ class ChatState { // Always set the value - even if equal, this ensures observers are triggered _favoritePeers.value = favorites - Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}") - Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}") + Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}") + } + + fun setPeerFavoritedUs(fingerprints: Set) { + _peerFavoritedUs.value = fingerprints } fun setPeerSessionStates(states: Map) { @@ -276,10 +312,22 @@ class ChatState { fun setPeerRSSI(rssi: Map) { _peerRSSI.value = rssi } + + fun setPeerDirect(direct: Map) { + _peerDirect.value = direct + } fun setShowAppInfo(show: Boolean) { _showAppInfo.value = show } + + fun setShowVerificationSheet(show: Boolean) { + _showVerificationSheet.value = show + } + + fun setShowSecurityVerificationSheet(show: Boolean) { + _showSecurityVerificationSheet.value = show + } fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { _selectedLocationChannel.value = channel @@ -301,4 +349,11 @@ class ChatState { _geohashParticipantCounts.value = counts } + fun setShowMeshPeerList(show: Boolean) { + _showMeshPeerList.value = show + } + + fun setPrivateChatSheetPeer(peerID: String?) { + _privateChatSheetPeer.value = peerID + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt index 30f9a894..f00d650e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt @@ -3,10 +3,7 @@ package com.bitchat.android.ui /** * UI constants/utilities for nickname rendering. */ -const val MAX_NICKNAME_LENGTH: Int = 15 - -fun truncateNickname(name: String, maxLen: Int = MAX_NICKNAME_LENGTH): String { +fun truncateNickname(name: String, maxLen: Int = com.bitchat.android.util.AppConstants.UI.MAX_NICKNAME_LENGTH): String { return if (name.length <= maxLen) name else name.take(maxLen) } - diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index bdb2fa1f..14c7879d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -3,14 +3,14 @@ package com.bitchat.android.ui import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.mesh.BluetoothMeshService -import androidx.compose.material3.ColorScheme import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.* @@ -19,6 +19,27 @@ import java.util.* * Extracted from ChatScreen.kt for better organization */ +/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */ +internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha + +/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */ +internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm" + +/** Background opacity for a mention chip referring to somebody else. */ +internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha + +/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */ +internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha + +/** + * Mention token grammar shared by rendered messages and the composer. + * + * The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the + * same nickname. Keeping one regex prevents the composer from styling only `@name` while the + * rendered transcript styles the complete token. + */ +internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)") + /** * Get RSSI-based color for signal strength visualization */ @@ -32,57 +53,250 @@ fun getRSSIColor(rssi: Int): Color { } } +/** Thin space (U+2009) separating a display name from its `#abcd` disambiguation suffix. */ +internal const val SUFFIX_THIN_SPACE = " " + /** - * Format message as annotated string with iOS-style formatting - * Timestamp at END, peer colors, hashtag suffix handling + * Build the sender label shown above the first message of a group. + * + * Renders `@name` plus a dimmed `#abcd` suffix, separated by a thin space. The name carries + * a `nickname_click` annotation for everyone except yourself. */ -fun formatMessageAsAnnotatedString( +fun formatTextMessageSender( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + myPeerID: String, + palette: BitchatPalette ): AnnotatedString { val builder = AnnotatedString.Builder() - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // Determine if this message was sent by self - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") - - if (message.sender != "system") { - // Get base color for this peer (iOS-style color assignment) - val baseColor = if (isSelf) { - Color(0xFFFF9500) // Orange for self (iOS orange) - } else { - getPeerColor(message, isDark) - } - - // Split sender into base name and hashtag suffix - val (baseName, suffix) = splitSuffix(message.sender) - - // Sender prefix "<@" - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("<@") + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) + val senderColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } + val senderWeight = FontWeight.SemiBold + val (baseName, suffix) = splitSuffix(message.sender) + + builder.pushStyle( + SpanStyle( + color = senderColor, + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = senderWeight + ) + ) + builder.append("@") + val nicknameStart = builder.length + builder.append(truncateNickname(baseName)) + val nicknameEnd = builder.length + if (!isSelf) { + builder.addStringAnnotation( + tag = "nickname_click", + annotation = message.originalSender ?: message.sender, + start = nicknameStart, + end = nicknameEnd + ) + } + builder.pop() + + if (suffix.isNotEmpty()) { + builder.append(SUFFIX_THIN_SPACE) + builder.pushStyle( + SpanStyle( + color = senderColor.copy(alpha = SUFFIX_ALPHA), + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal + ) + ) + builder.append(suffix) builder.pop() - - // Base name (clickable) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + } + + return builder.toAnnotatedString() +} + +/** + * Build the compact timestamp and optional proof-of-work label. + * + * Used standalone by media rows; text messages get the same span appended inline to the end of + * their body via [appendBodyTimestamp]. + */ +fun formatTextMessageMetadata( + message: BitchatMessage, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = Color.Gray.copy(alpha = 0.7f), + fontSize = (BASE_FONT_SIZE - 4).sp + ) + ) + builder.append(timeFormatter.format(message.timestamp)) + message.powDifficulty?.takeIf { it > 0 }?.let { bits -> + builder.append(" ⛨${bits}b") + } + builder.pop() + return builder.toAnnotatedString() +} + +/** + * Append the timestamp (and optional PoW difficulty) directly after the message body so it + * trails the final words rather than occupying its own column. + * + * Deliberately carries no click annotation: the timestamp is decoration, and making it + * tappable would create dead zones inside the message body. + */ +private fun appendTimestampText( + builder: AnnotatedString.Builder, + message: BitchatMessage, + timeFormatter: SimpleDateFormat +) { + builder.append(" ") + builder.append(timeFormatter.format(message.timestamp)) + message.powDifficulty?.takeIf { it > 0 }?.let { bits -> + builder.append(" ⛨${bits}b") + } +} + +private fun appendBodyTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = palette.textTertiary, + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + builder.pop() +} + +private fun appendMutedTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + contentColor: Color, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + builder.pop() +} + +/** + * Build the message body: neutral text with mention/URL/geohash accents, followed by an inline + * trailing timestamp. + * + * Body text is intentionally neutral rather than peer-colored. Colour is reserved for + * `@names`, which is what makes a busy channel scannable. + */ +fun formatTextMessageBody( + message: BitchatMessage, + currentUserNickname: String, + palette: BitchatPalette, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map = emptyMap(), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), + includeTimestamp: Boolean = true +): AnnotatedString { + val builder = AnnotatedString.Builder() + + appendIOSFormattedContent( + builder = builder, + content = message.content, + currentUserNickname = currentUserNickname, + palette = palette, + contentColor = contentColor, + linkColor = linkColor, + mentionPeerIdentities = mentionPeerIdentities, + ) + + if (includeTimestamp) { + appendBodyTimestamp(builder, message, palette, timeFormatter) + } + return builder.toAnnotatedString() +} + +/** + * Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`. + * + * The `//` prefix reads as machine narration in a monospace context and is far quieter than + * the previous `* italic asterisk *` treatment, which competed with real messages. + */ +fun formatSystemMessage( + message: BitchatMessage, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemActionFontSize, + fontWeight = FontWeight.Medium, + ) + ) + builder.append("// ") + builder.append(message.content) + builder.pop() + + appendMutedTimestamp(builder, message, contentColor, timeFormatter) + return builder.toAnnotatedString() +} + +/** + * Header line for media (image / audio / file) rows. + * + * Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an + * inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on + * the same line as the name. + */ +fun formatMessageHeaderAnnotatedString( + message: BitchatMessage, + currentUserNickname: String, + myPeerID: String, + palette: BitchatPalette, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), + includeSender: Boolean = true +): AnnotatedString { + val builder = AnnotatedString.Builder() + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) + + if (message.sender == "system") { + return formatSystemMessage(message, contentColor, timeFormatter) + } + + if (includeSender) { + val baseColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } + val (baseName, suffix) = splitSuffix(message.sender) + + builder.pushStyle( + SpanStyle( + color = baseColor, + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.SemiBold, + ) + ) + builder.append("@") val nicknameStart = builder.length - val truncatedBase = truncateNickname(baseName) - builder.append(truncatedBase) + builder.append(truncateNickname(baseName)) val nicknameEnd = builder.length - - // Add click annotation for nickname (store canonical sender name with hash if available) if (!isSelf) { builder.addStringAnnotation( tag = "nickname_click", @@ -92,116 +306,25 @@ fun formatMessageAsAnnotatedString( ) } builder.pop() - - // Hashtag suffix in lighter color (iOS style) + if (suffix.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) + builder.append(SUFFIX_THIN_SPACE) + builder.pushStyle( + SpanStyle( + color = baseColor.copy(alpha = SUFFIX_ALPHA), + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal, + ) + ) builder.append(suffix) builder.pop() } - - // Sender suffix "> " - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium - )) - builder.append("> ") - builder.pop() - - // Message content with iOS-style hashtag and mention highlighting - appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark) - - // iOS-style timestamp at the END (smaller, grey) - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() - - } else { - // System message - iOS style - builder.pushStyle(SpanStyle( - color = Color.Gray, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - - // Timestamp for system messages too - builder.pushStyle(SpanStyle( - color = Color.Gray.copy(alpha = 0.5f), - fontSize = (BASE_FONT_SIZE - 4).sp - )) - builder.append(" [${timeFormatter.format(message.timestamp)}]") - builder.pop() } - + + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } -/** - * iOS-style peer color assignment using djb2 hash algorithm - * Avoids orange (~30°) reserved for self messages - */ -fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { - // Create seed from peer identifier (prioritizing stable keys) - val seed = when { - message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> { - // For Nostr peers, use the full key if available, otherwise the peer ID - "nostr:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 16 -> { - // For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID - "noise:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 64 -> { - // This is already a stable Noise key - "noise:${message.senderPeerID.lowercase()}" - } - else -> { - // Fallback to sender name - message.sender.lowercase() - } - } - - return colorForPeerSeed(seed, isDark) -} - -/** - * Generate consistent peer color using djb2 hash (matches iOS algorithm exactly) - */ -fun colorForPeerSeed(seed: String, isDark: Boolean): Color { - // djb2 hash algorithm (matches iOS implementation) - var hash = 5381UL - for (byte in seed.toByteArray()) { - hash = ((hash shl 5) + hash) + byte.toUByte().toULong() - } - - var hue = (hash % 360UL).toDouble() / 360.0 - - // Avoid orange (~30°) reserved for self (matches iOS logic) - val orange = 30.0 / 360.0 - if (kotlin.math.abs(hue - orange) < 0.05) { - hue = (hue + 0.12) % 1.0 - } - - val saturation = if (isDark) 0.50 else 0.70 - val brightness = if (isDark) 0.85 else 0.35 - - return Color.hsv( - hue = (hue * 360).toFloat(), - saturation = saturation.toFloat(), - value = brightness.toFloat() - ) -} - /** * Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly) */ @@ -220,23 +343,106 @@ fun splitSuffix(name: String): Pair { } /** - * iOS-style content formatting with proper hashtag and mention handling + * Build a case-insensitive mention-token lookup from canonical peer identities. + * + * Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when + * it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention + * as the wrong person. + */ +internal fun buildMentionPeerIdentityMap( + messages: List, + knownPeers: List> = emptyList(), +): Map { + val candidates = linkedMapOf>() + + fun add(displayName: String, identity: PeerIdentity) { + val normalizedName = displayName.trim().removePrefix("@") + if (normalizedName.isEmpty()) return + + val (baseName, suffix) = splitSuffix(normalizedName) + val exactKey = normalizedName.lowercase(Locale.ROOT) + candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity) + + if (suffix.isNotEmpty()) { + val baseKey = baseName.lowercase(Locale.ROOT) + candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity) + } + } + + messages + .asSequence() + .filterNot { it.sender == "system" } + .forEach { add(it.sender, peerIdentityForMessage(it)) } + knownPeers.forEach { (displayName, identity) -> add(displayName, identity) } + + return candidates.mapNotNull { (token, identities) -> + identities.singleOrNull()?.let { token to it } + }.toMap() +} + +internal fun resolveMentionPeerIdentity( + mention: String, + mentionPeerIdentities: Map, +): PeerIdentity? { + val mentionWithoutAt = mention.trim().removePrefix("@") + val baseName = splitSuffix(mentionWithoutAt).first + return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)] + ?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)] +} + +/** + * Resolve the deterministic color for a mention on every surface that displays one. + * + * Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is + * unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID. + */ +internal fun colorForMention( + mention: String, + mentionPeerIdentities: Map, + palette: BitchatPalette, +): Color { + val mentionWithoutAt = mention.trim().removePrefix("@") + val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities) + ?: PeerIdentity.nickname(mentionWithoutAt) + return colorForPeer(identity, palette) +} + +/** + * A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport + * may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as + * `anon1234`, `anonymous`, and `anonracer` are real announced usernames. + */ +internal fun isUnannouncedNickname(displayName: String): Boolean { + val base = splitSuffix(displayName.trim()).first + return base.equals("anon", ignoreCase = true) +} + +/** + * iOS-style content formatting with proper hashtag and mention handling. + * + * Redesign notes: + * - Plain text renders in Material `onSurface`; colour is reserved for `@mentions`, + * links and geohashes. + * - Mentions get a tinted background chip so they read as a distinct token inside a sentence. + * The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks + * the same everywhere she is referenced. + * - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire + * paragraphs was the single biggest source of visual noise in the old layout; the mention + * chip carries that emphasis instead. */ private fun appendIOSFormattedContent( builder: AnnotatedString.Builder, content: String, - mentions: List?, currentUserNickname: String, - baseColor: Color, - isSelf: Boolean, - isDark: Boolean + palette: BitchatPalette, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map, ) { - // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() - val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() val hashtagMatches = hashtagPattern.findAll(content).toList() - val mentionMatches = mentionPattern.findAll(content).toList() + val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList() // Combine and sort matches, but exclude hashtags that overlap with mentions val mentionRanges = mentionMatches.map { it.range } @@ -289,36 +495,36 @@ private fun appendIOSFormattedContent( val iterator = allMatches.listIterator() while (iterator.hasNext()) { val (range, type) = iterator.next() - // Remove generic hashtags that overlap with geohashes, and geohashes that overlap with URLs + // Remove generic hashtags that overlap with geohashes or URLs, and geohashes that overlap with URLs val overlapsGeo = geoRanges.any { rangesOverlap(range, it) } val overlapsUrl = urlRanges.any { rangesOverlap(range, it) } - if ((type == "hashtag" && overlapsGeo) || (type == "geohash" && overlapsUrl)) iterator.remove() + if ((type == "hashtag" && (overlapsGeo || overlapsUrl)) || (type == "geohash" && overlapsUrl)) iterator.remove() } } allMatches.sortBy { it.first.first } - + + val plainStyle = SpanStyle( + color = contentColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal + ) + val linkStyle = SpanStyle( + color = linkColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = FontWeight.Normal, + textDecoration = TextDecoration.Underline + ) + var lastEnd = 0 - val isMentioned = mentions?.contains(currentUserNickname) == true - + for ((range, type) in allMatches) { // Add text before match if (lastEnd < range.first) { val beforeText = content.substring(lastEnd, range.first) if (beforeText.isNotEmpty()) { - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - // Make entire message bold if user is mentioned - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(beforeText) - builder.pop() - } else { - builder.append(beforeText) - } + builder.pushStyle(plainStyle) + builder.append(beforeText) builder.pop() } } @@ -330,65 +536,58 @@ private fun appendIOSFormattedContent( // iOS-style mention with hashtag suffix support val mentionWithoutAt = matchText.removePrefix("@") val (mBase, mSuffix) = splitSuffix(mentionWithoutAt) - - // Check if this mention targets current user + + // Mentions targeting you are the one thing worth shouting about. val isMentionToMe = mBase == currentUserNickname - val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor - - // "@" symbol + val mentionColor = if (isMentionToMe) { + palette.accentOrange + } else { + colorForMention( + mention = mentionWithoutAt, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + } + val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA + val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold + + // A single outer span carrying the background makes the chip render as one + // continuous rectangle. Pushing the background per-token would leave hairline + // seams between "@", the name and the "#abcd" suffix. + builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha))) + builder.pushStyle(SpanStyle( color = mentionColor, fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append("@") - builder.pop() - - // Base name (truncate for rendering) - builder.pushStyle(SpanStyle( - color = mentionColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold - )) builder.append(truncateNickname(mBase)) builder.pop() - + // Hashtag suffix in lighter color if (mSuffix.isNotEmpty()) { builder.pushStyle(SpanStyle( - color = mentionColor.copy(alpha = 0.6f), + color = mentionColor.copy(alpha = SUFFIX_ALPHA), fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + fontWeight = mentionWeight )) builder.append(mSuffix) builder.pop() } + + builder.pop() // background chip } "hashtag" -> { // Render general hashtags like normal content - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(matchText) - builder.pop() - } else { - builder.append(matchText) - } + builder.pushStyle(plainStyle) + builder.append(matchText) builder.pop() } else -> { if (type == "geohash") { - // Style geohash in blue, underlined, and add click annotation - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style geohash as a link and add click annotation + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -401,13 +600,8 @@ private fun appendIOSFormattedContent( ) builder.pop() } else if (type == "url") { - // Style URL in blue, underlined, and add click annotation with the raw text - builder.pushStyle(SpanStyle( - color = Color(0xFF007AFF), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, - textDecoration = TextDecoration.Underline - )) + // Style URL as a link and add click annotation with the raw text + builder.pushStyle(linkStyle) val start = builder.length builder.append(matchText) val end = builder.length @@ -420,11 +614,7 @@ private fun appendIOSFormattedContent( builder.pop() } else { // Fallback: treat as normal text - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) + builder.pushStyle(plainStyle) builder.append(matchText) builder.pop() } @@ -437,18 +627,8 @@ private fun appendIOSFormattedContent( // Add remaining text if (lastEnd < content.length) { val remainingText = content.substring(lastEnd) - builder.pushStyle(SpanStyle( - color = baseColor, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal - )) - if (isMentioned) { - builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - builder.append(remainingText) - builder.pop() - } else { - builder.append(remainingText) - } + builder.pushStyle(plainStyle) + builder.append(remainingText) builder.pop() } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index e1f5df5e..f0647e6c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -4,17 +4,19 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString -import kotlinx.coroutines.launch +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.model.BitchatMessage /** @@ -34,23 +36,17 @@ fun ChatUserSheet( val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - - // iOS system colors (matches LocationChannelsSheet exactly) val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue - val standardRed = Color(0xFFFF3B30) // iOS red - val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + val standardPurple = palette.accentPurple + val standardRed = colorScheme.error + val standardGrey = colorScheme.onSurfaceVariant if (isPresented) { - ModalBottomSheet( + BitchatBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState, modifier = modifier ) { Column( @@ -61,17 +57,17 @@ fun ChatUserSheet( ) { // Header Text( - text = "@$targetNickname", + text = stringResource(R.string.at_nickname, targetNickname), fontSize = 18.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Text( - text = if (selectedMessage != null) "choose an action for this message or user" else "choose an action for this user", + text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -83,8 +79,8 @@ fun ChatUserSheet( selectedMessage?.let { message -> item { UserActionRow( - title = "copy message", - subtitle = "copy this message to clipboard", + title = stringResource(R.string.action_copy_message_title), + subtitle = stringResource(R.string.action_copy_message_subtitle), titleColor = standardGrey, onClick = { // Copy the message content to clipboard @@ -97,11 +93,38 @@ fun ChatUserSheet( // Only show user actions for other users' messages or when no message is selected if (selectedMessage?.sender != viewModel.nickname.value) { + // Send private message action + item { + UserActionRow( + title = stringResource(R.string.action_private_message_title, targetNickname), + subtitle = stringResource(R.string.action_private_message_subtitle), + titleColor = standardPurple, + onClick = { + val selectedLocationChannel = viewModel.selectedLocationChannel.value + if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { + if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) { + val shortId = selectedMessage.senderPeerID!!.substring(6) + viewModel.startGeohashDMByShortId(shortId) + } else { + viewModel.startGeohashDMByNickname(targetNickname) + } + } else { + // Mesh chat + val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname) + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + } + } + onDismiss() + } + ) + } + // Slap action item { UserActionRow( - title = "slap $targetNickname", - subtitle = "send a playful slap message", + title = stringResource(R.string.action_slap_title, targetNickname), + subtitle = stringResource(R.string.action_slap_subtitle), titleColor = standardBlue, onClick = { // Send slap command @@ -114,8 +137,8 @@ fun ChatUserSheet( // Hug action item { UserActionRow( - title = "hug $targetNickname", - subtitle = "send a friendly hug message", + title = stringResource(R.string.action_hug_title, targetNickname), + subtitle = stringResource(R.string.action_hug_subtitle), titleColor = standardGreen, onClick = { // Send hug command @@ -128,8 +151,8 @@ fun ChatUserSheet( // Block action item { UserActionRow( - title = "block $targetNickname", - subtitle = "block all messages from this user", + title = stringResource(R.string.action_block_title, targetNickname), + subtitle = stringResource(R.string.action_block_subtitle), titleColor = standardRed, onClick = { // Check if we're in a geohash channel @@ -158,9 +181,9 @@ fun ChatUserSheet( modifier = Modifier.fillMaxWidth() ) { Text( - text = "cancel", + text = stringResource(R.string.cancel_lower), fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } @@ -191,7 +214,7 @@ private fun UserActionRow( Text( text = title, fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = titleColor ) @@ -199,7 +222,7 @@ private fun UserActionRow( Text( text = subtitle, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 0a0ea6a7..f40549b6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -4,19 +4,50 @@ import android.app.Application import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope +import com.bitchat.android.favorites.FavoritesChangeListener +import com.bitchat.android.favorites.FavoritesPersistenceService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.protocol.BitchatPacket -import com.bitchat.android.nostr.NostrGeohashService + + import kotlinx.coroutines.launch -import com.bitchat.android.util.NotificationIntervalManager +import kotlinx.coroutines.withContext import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import java.util.Date import kotlin.random.Random +import com.bitchat.android.services.VerificationService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.util.hexEncodedString +import com.bitchat.android.features.voice.LiveVoicePreferences +import com.bitchat.android.features.voice.LiveVoiceTarget +import com.bitchat.android.features.voice.VoiceRecorder + +private data class ConversationLiveIdentityState( + val connectedPeerIDs: List, + val peerNicknames: Map, + val persistedDisplayNames: Map +) /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -24,35 +55,152 @@ import kotlin.random.Random */ class ChatViewModel( application: Application, - val meshService: BluetoothMeshService + initialMeshService: BluetoothMeshService, + initialUnifiedMeshService: MeshService ) : AndroidViewModel(application), BluetoothMeshDelegate { + // Made var to support mesh service replacement after panic clear + var meshService: BluetoothMeshService = initialMeshService + private set + private var unifiedMeshService: MeshService = initialUnifiedMeshService + private val mesh: MeshService + get() = unifiedMeshService + private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } + companion object { private const val TAG = "ChatViewModel" + private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L } - // State management - private val state = ChatState() - + fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath) + } + + fun createVoiceRecorder(toPeerIDOrNull: String?, channelOrNull: String?): VoiceRecorder { + val context = getApplication().applicationContext + if (!LiveVoicePreferences.isEnabled(context)) return VoiceRecorder(context) + val recipientPeerID = toPeerIDOrNull?.let { + PrivateMediaRecipientResolver.resolve(it, mesh)?.meshPeerID + } + val liveTarget = when { + toPeerIDOrNull != null && recipientPeerID != null && mesh.hasEstablishedSession(recipientPeerID) -> + LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipientPeerID, payload) } + toPeerIDOrNull == null && channelOrNull == null && mesh.getActivePeerCount() > 0 -> + LiveVoiceTarget { payload -> mesh.sendVoiceFrame(null, payload) } + else -> null + } + return VoiceRecorder(context, liveTarget) + } + + fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + mediaSendingManager.sendFileNote(toPeerIDOrNull, channelOrNull, filePath) + } + + fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) + } + + fun approveLegacyPrivateMedia(requestId: String) { + mediaSendingManager.approveLegacyPrivateMedia(requestId) + } + + fun cancelLegacyPrivateMedia(requestId: String) { + mediaSendingManager.cancelLegacyPrivateMedia(requestId) + } + + fun getCurrentNpub(): String? { + return try { + NostrIdentityBridge + .getCurrentNostrIdentity(getApplication()) + ?.npub + } catch (_: Exception) { + null + } + } + + fun buildMyQRString(nickname: String, npub: String?): String { + return VerificationService.buildMyQRString(nickname, npub) ?: "" + } + + // MARK: - State management + private val state = ChatState( + scope = viewModelScope, + ) + + // Transfer progress tracking + private val transferMessageMap = mutableMapOf() + private val messageTransferMap = mutableMapOf() + // Specialized managers private val dataManager = DataManager(application.applicationContext) + private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } + private val seenMessageStore by lazy { + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + } + private val conversationListPreferences = + com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication()) private val messageManager = MessageManager(state) - private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) - + private val channelManager = ChannelManager( + state, + messageManager, + dataManager, + viewModelScope, + onSwitchToMeshLocation = { + com.bitchat.android.geohash.LocationChannelManager + .getInstance(getApplication()) + .select(com.bitchat.android.geohash.ChannelID.Mesh) + } + ) + // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { - override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID) - override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) - override fun getMyPeerID(): String = meshService.myPeerID + override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID) + override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID) + override fun getMyPeerID(): String = mesh.myPeerID } - - val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) - private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) + + val privateChatManager = PrivateChatManager( + state, + messageManager, + dataManager, + noiseSessionDelegate, + hasReadReceiptBeenSent = { messageID -> + seenMessageStore.hasReadReceiptBeenSent(messageID) + }, + markMessageReadLocally = { messageID -> + seenMessageStore.markReadLocally(messageID) + } + ) + private val commandProcessor = CommandProcessor( + state, + messageManager, + channelManager, + privateChatManager, + viewModelScope + ) private val notificationManager = NotificationManager( application.applicationContext, - NotificationManagerCompat.from(application.applicationContext), - NotificationIntervalManager() + NotificationManagerCompat.from(application.applicationContext) ) + + private val verificationHandler = VerificationHandler( + context = application.applicationContext, + scope = viewModelScope, + getMeshService = { mesh }, + identityManager = identityManager, + state = state, + notificationManager = notificationManager, + messageManager = messageManager + ) + val verifiedFingerprints = verificationHandler.verifiedFingerprints + + // Media file sending manager + private val mediaSendingManager = MediaSendingManager( + state, + messageManager, + channelManager, + viewModelScope + ) { mesh } // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( @@ -63,59 +211,360 @@ class ChatViewModel( notificationManager = notificationManager, coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, - getMyPeerID = { meshService.myPeerID }, - getMeshService = { meshService } + getMyPeerID = { mesh.myPeerID }, + getMeshService = { mesh }, + markMessageReadLocally = { messageID -> + seenMessageStore.markReadLocally(messageID) + } ) - // Nostr and Geohash service - initialize singleton - private val nostrGeohashService = NostrGeohashService.initialize( + // New Geohash architecture ViewModel (replaces God object service usage in UI path) + val geohashViewModel = GeohashViewModel( application = application, state = state, messageManager = messageManager, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, - coroutineScope = viewModelScope, dataManager = dataManager, notificationManager = notificationManager ) - // Expose state through LiveData (maintaining the same interface) - val messages: LiveData> = state.messages - val connectedPeers: LiveData> = state.connectedPeers - val nickname: LiveData = state.nickname - val isConnected: LiveData = state.isConnected - val privateChats: LiveData>> = state.privateChats - val selectedPrivateChatPeer: LiveData = state.selectedPrivateChatPeer - val unreadPrivateMessages: LiveData> = state.unreadPrivateMessages - val joinedChannels: LiveData> = state.joinedChannels - val currentChannel: LiveData = state.currentChannel - val channelMessages: LiveData>> = state.channelMessages - val unreadChannelMessages: LiveData> = state.unreadChannelMessages - val passwordProtectedChannels: LiveData> = state.passwordProtectedChannels - val showPasswordPrompt: LiveData = state.showPasswordPrompt - val passwordPromptChannel: LiveData = state.passwordPromptChannel - val showSidebar: LiveData = state.showSidebar + + + + + val messages: StateFlow> = state.messages + val connectedPeers: StateFlow> = state.connectedPeers + val nickname: StateFlow = state.nickname + val isConnected: StateFlow = state.isConnected + val privateChats: StateFlow>> = state.privateChats + val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer + val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages + internal val conversationStoreState = + com.bitchat.android.services.AppStateStore.conversationStoreState + private val conversationPresencePeers = MutableStateFlow>(emptyList()) + private val conversationPresenceRemovalJobs = mutableMapOf() + private val conversationDirectoryRevision = MutableStateFlow(0L) + private var favoriteRelationshipListenerRegistered = false + private val favoriteRelationshipChangeListener = object : FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) { + refreshConversationDirectoryState() + } + + override fun onAllCleared() { + refreshConversationDirectoryState() + } + } + + private fun refreshConversationDirectoryState() { + viewModelScope.launch { + refreshPeerFavoritedUs() + conversationListPreferences.canonicalizeAliases() + conversationDirectoryRevision.update { it + 1L } + } + } + + private val conversationLiveIdentityState = combine( + conversationPresencePeers, + state.peerNicknames, + state.peerFingerprints, + conversationDirectoryRevision, + com.bitchat.android.services.AppStateStore.privateConversationDisplayNames + ) { connectedPeerIDs, peerNicknames, _, _, persistedDisplayNames -> + ConversationLiveIdentityState( + connectedPeerIDs = connectedPeerIDs, + peerNicknames = peerNicknames, + persistedDisplayNames = persistedDisplayNames + .mapKeys { (conversationID, _) -> conversationID.lowercase() } + ) + } + private val baseConversations = combine( + state.unreadPrivateMessages, + state.privateChats, + state.nickname, + conversationLiveIdentityState, + com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts + ) { unreadConversationIDs, chats, currentNickname, liveIdentity, unreadCounts -> + val seenStore = seenMessageStore + val connectedPeerByIdentity = buildMap { + liveIdentity.connectedPeerIDs.forEach { peerID -> + val identities = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.canonicalConversationId(peerID) + }.getOrDefault(setOf(peerID)) + identities.forEach { identity -> + putIfAbsent(identity.lowercase(), peerID) + } + } + } + buildConversationSummaries( + unreadConversationIDs = unreadConversationIDs, + privateChats = chats, + currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID), + canonicalize = ContactDirectory::canonicalConversationId, + isMessageRead = { message -> + com.bitchat.android.services.AppStateStore.isPrivateMessageRead(message.id) || + seenStore.hasBeenReadLocally(message.id) + }, + persistedUnreadCounts = unreadCounts + ).map { summary -> + val resolution = ContactDirectory.resolve(summary.conversationID) + val resolvedNostrPubkey = summary.nostrPubkey + ?: resolution.nostrPubkey?.let(ContactIdentityResolver::nostrPubkeyHex) + val aliases = buildSet { + addAll(summary.identityAliases) + add(summary.conversationID) + add(resolution.conversationID) + resolution.meshPeerID?.let(::add) + resolution.noiseKeyHex?.let(::add) + resolvedNostrPubkey + ?.let(ContactIdentityResolver::nostrAliasForPubkey) + ?.let(::add) + }.mapTo(mutableSetOf()) { it.lowercase() } + val connectedPeerID = aliases + .asSequence() + .mapNotNull(connectedPeerByIdentity::get) + .firstOrNull() + val persistedDisplayName = liveIdentity.persistedDisplayNames[ + summary.conversationID.lowercase() + ] ?: aliases + .asSequence() + .mapNotNull(liveIdentity.persistedDisplayNames::get) + .firstOrNull() + + summary.copy( + displayName = resolveConversationDisplayName( + fallbackName = summary.displayName, + connectedPeerID = connectedPeerID, + peerNicknames = liveIdentity.peerNicknames, + resolvedContactName = resolution.displayName, + persistedDisplayName = persistedDisplayName + ), + nostrPubkey = resolvedNostrPubkey, + transport = if (resolvedNostrPubkey != null) { + DirectMessageTransport.NOSTR + } else { + summary.transport + }, + identityAliases = aliases, + isConnected = connectedPeerID != null, + connectedPeerID = connectedPeerID, + sourceGeohash = aliases + .asSequence() + .mapNotNull(GeohashConversationRegistry::get) + .firstOrNull() + ) + } + } + + internal val conversations: StateFlow> = combine( + baseConversations, + conversationListPreferences.pinned, + conversationListPreferences.muted, + conversationListPreferences.drafts + ) { summaries, pinned, muted, drafts -> + sortConversationSummaries( + summaries.map { summary -> + val key = summary.conversationID.lowercase() + summary.copy( + isPinned = key in pinned, + isMuted = key in muted, + draft = drafts[key] + ) + } + ) + } + .flowOn(Dispatchers.IO) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + val joinedChannels: StateFlow> = state.joinedChannels + val currentChannel: StateFlow = state.currentChannel + val channelMessages: StateFlow>> = state.channelMessages + val unreadChannelMessages: StateFlow> = state.unreadChannelMessages + val passwordProtectedChannels: StateFlow> = state.passwordProtectedChannels + val showPasswordPrompt: StateFlow = state.showPasswordPrompt + val passwordPromptChannel: StateFlow = state.passwordPromptChannel val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages - val showCommandSuggestions: LiveData = state.showCommandSuggestions - val commandSuggestions: LiveData> = state.commandSuggestions - val showMentionSuggestions: LiveData = state.showMentionSuggestions - val mentionSuggestions: LiveData> = state.mentionSuggestions - val favoritePeers: LiveData> = state.favoritePeers - val peerSessionStates: LiveData> = state.peerSessionStates - val peerFingerprints: LiveData> = state.peerFingerprints - val peerNicknames: LiveData> = state.peerNicknames - val peerRSSI: LiveData> = state.peerRSSI - val showAppInfo: LiveData = state.showAppInfo - val selectedLocationChannel: LiveData = state.selectedLocationChannel - val isTeleported: LiveData = state.isTeleported - val geohashPeople: LiveData> = state.geohashPeople - val teleportedGeo: LiveData> = state.teleportedGeo - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + val showCommandSuggestions: StateFlow = state.showCommandSuggestions + val commandSuggestions: StateFlow> = state.commandSuggestions + val showMentionSuggestions: StateFlow = state.showMentionSuggestions + val mentionSuggestions: StateFlow> = state.mentionSuggestions + val favoritePeers: StateFlow> = state.favoritePeers + val peerFavoritedUs: StateFlow> = state.peerFavoritedUs + val peerSessionStates: StateFlow> = state.peerSessionStates + val peerFingerprints: StateFlow> = state.peerFingerprints + val peerNicknames: StateFlow> = state.peerNicknames + val peerRSSI: StateFlow> = state.peerRSSI + val peerDirect: StateFlow> = state.peerDirect + val showAppInfo: StateFlow = state.showAppInfo + val showMeshPeerList: StateFlow = state.showMeshPeerList + val privateChatSheetPeer: StateFlow = state.privateChatSheetPeer + val showVerificationSheet: StateFlow = state.showVerificationSheet + val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet + val legacyPrivateMediaConsent: StateFlow = + mediaSendingManager.legacyPrivateMediaConsent + val selectedLocationChannel: StateFlow = state.selectedLocationChannel + val isTeleported: StateFlow = state.isTeleported + val geohashPeople: StateFlow> = state.geohashPeople + val teleportedGeo: StateFlow> = state.teleportedGeo + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val meshServiceFacade: MeshService + get() = mesh + val myPeerID: String + get() = mesh.myPeerID + + fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID) + + fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID) + + fun initiateMeshHandshake(peerID: String) { + mesh.initiateNoiseHandshake(peerID) + } init { + observeConversationPresenceWithDisconnectGrace() // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() + ContactDirectory.initialize(getApplication()) { mesh } + com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() + observeConversationDisplayNames() + // Application startup performs the initial restore. Repeat it for every new UI owner + // because a quick reopen can reuse a process whose in-memory state was cleared during + // controlled shutdown. + com.bitchat.android.services.AppStateStore.reloadConversationPersistence( + getApplication() + ) + // Mark queued private messages as failed when the router gives up on them + try { + com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID -> + messageManager.updateMessageDeliveryStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery") + ) + } + } catch (_: Exception) { } + // Hydrate UI state from process-wide AppStateStore to survive Activity recreation + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.peers.collect { peers -> + state.setConnectedPeers(peers) + state.setIsConnected(peers.isNotEmpty()) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs -> + // Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn + state.setMessages(msgs) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { + combine( + com.bitchat.android.services.AppStateStore.privateMessages, + com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts + ) { byPeer, unreadCounts -> byPeer to unreadCounts } + .collect { (byPeer, unreadCounts) -> + val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) { + val canonical = ContactDirectory.canonicalizePrivateChats(byPeer) + val unread = try { + val myNick = state.getNicknameValue().ifBlank { mesh.myPeerID } + canonical + .filterValues { messages -> + messages.any { message -> + message.sender != myNick && + message.sender != "system" && + !com.bitchat.android.services.AppStateStore + .isPrivateMessageRead(message.id) && + !seenMessageStore.hasBeenReadLocally(message.id) + } + } + .keys + unreadCounts + .filterValues { it > 0 } + .keys + } catch (_: Exception) { + state.getUnreadPrivateMessagesValue() + } + canonical to unread + } + state.setPrivateChats(canonicalChats) + // Recompute unread set using SeenMessageStore for robustness across Activity recreation + state.setUnreadPrivateMessages(unreadConversationIDs) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel -> + // Replace with store snapshot + state.setChannelMessages(byChannel) + } } catch (_: Exception) { } + } + // Subscribe to BLE transfer progress and reflect in message deliveryStatus + viewModelScope.launch { + com.bitchat.android.mesh.TransferProgressManager.events.collect { evt -> + mediaSendingManager.handleTransferProgressEvent(evt) + } + } + + // Removed background location notes subscription. Notes now load only when sheet opens. + } + + /** + * Mesh discovery can briefly drop a peer while transports hand over. Preserve its online + * treatment for a short grace window to keep conversation rows from jumping between sections. + * New connections still appear immediately. + */ + private fun observeConversationPresenceWithDisconnectGrace() { + viewModelScope.launch { + state.connectedPeers.collect { connected -> + val current = connected.toSet() + current.forEach { peerID -> + conversationPresenceRemovalJobs.remove(peerID)?.cancel() + } + + val displayed = conversationPresencePeers.value.toMutableList() + connected.forEach { peerID -> + if (peerID !in displayed) displayed.add(peerID) + } + if (displayed != conversationPresencePeers.value) { + conversationPresencePeers.value = displayed + } + + (displayed.toSet() - current).forEach { peerID -> + if (peerID in conversationPresenceRemovalJobs) return@forEach + conversationPresenceRemovalJobs[peerID] = launch { + delay(CONVERSATION_DISCONNECT_GRACE_MS) + if (peerID !in state.connectedPeers.value) { + conversationPresencePeers.value = + conversationPresencePeers.value - peerID + } + conversationPresenceRemovalJobs.remove(peerID) + } + } + } + } + } + + private fun observeConversationDisplayNames() { + viewModelScope.launch { + combine( + state.peerNicknames, + state.connectedPeers, + state.peerFingerprints + ) { peerNicknames, connectedPeers, _ -> + connectedPeers.mapNotNull { peerID -> + peerNicknames[peerID]?.let { peerID to it } + }.toMap() + }.collect { connectedNames -> + conversationListPreferences.canonicalizeAliases() + com.bitchat.android.services.AppStateStore + .updatePrivateConversationDisplayNames(connectedNames) + } + } + } + + fun cancelMediaSend(messageId: String) { + // Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state + mediaSendingManager.cancelMediaSend(messageId) } private fun loadAndInitialize() { @@ -149,41 +598,64 @@ class ChatViewModel( // Initialize session state monitoring initializeSessionStateMonitoring() + + // Bridge DebugSettingsManager -> Chat messages when verbose logging is on + viewModelScope.launch { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().debugMessages.collect { msgs -> + if (com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().verboseLoggingEnabled.value) { + // Only show debug logs in the Mesh chat timeline to avoid leaking into geohash chats + val selectedLocation = state.selectedLocationChannel.value + if (selectedLocation is com.bitchat.android.geohash.ChannelID.Mesh) { + // Append only latest debug message as system message to avoid flooding + msgs.lastOrNull()?.let { dm -> + messageManager.addSystemMessage(dm.content) + } + } + } + } + } - // Initialize location channel state - nostrGeohashService.initializeLocationChannelState() + // Initialize new geohash architecture + geohashViewModel.initialize() // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) - // Initialize Nostr integration - nostrGeohashService.initializeNostrIntegration() + // Reflect "they favorited us" changes into reactive UI state (drives star celebrations) + refreshPeerFavoritedUs() + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener( + favoriteRelationshipChangeListener + ) + favoriteRelationshipListenerRegistered = true + } catch (_: Exception) { } + + // Load verified fingerprints from secure storage + verificationHandler.loadVerifiedFingerprints() + // Ensure NostrTransport knows our mesh peer ID for embedded packets try { val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID + nostrTransport.senderPeerID = mesh.myPeerID } catch (_: Exception) { } // Note: Mesh service is now started by MainActivity - - // Show welcome message if no peers after delay - viewModelScope.launch { - delay(10000) - if (state.getConnectedPeersValue().isEmpty() && state.getMessagesValue().isEmpty()) { - val welcomeMessage = BitchatMessage( - sender = "system", - content = "get people around you to download bitchat and chat with them here!", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(welcomeMessage) - } - } + + // BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch. } override fun onCleared() { - super.onCleared() + if (favoriteRelationshipListenerRegistered) { + runCatching { + FavoritesPersistenceService.shared.removeListener( + favoriteRelationshipChangeListener + ) + } + favoriteRelationshipListenerRegistered = false + } + geohashViewModel.shutdownUiSubscriptions() + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) // Note: Mesh service lifecycle is now managed by MainActivity } @@ -192,13 +664,20 @@ class ChatViewModel( fun setNickname(newNickname: String) { state.setNickname(newNickname) dataManager.saveNickname(newNickname) - meshService.sendBroadcastAnnounce() + mesh.sendBroadcastAnnounce() } + /** + * Ensure Nostr DM subscription for a geohash conversation key if known + */ + private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) { + geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey) + } + // MARK: - Channel Management (delegated) fun joinChannel(channel: String, password: String? = null): Boolean { - return channelManager.joinChannel(channel, password, meshService.myPeerID) + return channelManager.joinChannel(channel, password, mesh.myPeerID) } fun switchToChannel(channel: String?) { @@ -207,107 +686,364 @@ class ChatViewModel( fun leaveChannel(channel: String) { channelManager.leaveChannel(channel) - meshService.sendMessage("left $channel") + mesh.sendMessage("left $channel", emptyList(), null) } // MARK: - Private Chat Management (delegated) - fun startPrivateChat(peerID: String) { - val success = privateChatManager.startPrivateChat(peerID, meshService) + suspend fun startPrivateChat(peerID: String) { + // For geohash conversation keys, ensure DM subscription is active + if (peerID.startsWith("nostr_")) { + ensureGeohashDMSubscriptionIfNeeded(peerID) + } + + val (conversationID, success) = withContext(Dispatchers.IO) { + val canonicalID = ContactDirectory.canonicalConversationId(peerID) + com.bitchat.android.services.AppStateStore + .loadPrivateConversationHistory(canonicalID) + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) + val unreadAliases = matchingUnreadAliases( + unreadConversationIDs = state.getUnreadPrivateMessagesValue(), + canonicalConversationID = canonicalID, + canonicalize = ContactDirectory::canonicalConversationId + ) + canonicalID to privateChatManager.startPrivateChat( + peerID = canonicalID, + meshService = mesh, + unreadAliases = unreadAliases + ) + } if (success) { // Notify notification manager about current private chat - setCurrentPrivateChatPeer(peerID) + setCurrentPrivateChatPeer(conversationID) // Clear notifications for this sender since user is now viewing the chat - clearNotificationsForSender(peerID) - - // Persistently mark all messages in this conversation as read so Nostr fetches - // after app restarts won't re-mark them as unread. - try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val chats = state.getPrivateChatsValue() - val messages = chats[peerID] ?: emptyList() - messages.forEach { msg -> - try { seen.markRead(msg.id) } catch (_: Exception) { } - } - } catch (_: Exception) { } + clearNotificationsForSender(conversationID) } } fun endPrivateChat() { + val conversationID = state.getSelectedPrivateChatPeerValue() privateChatManager.endPrivateChat() + if (conversationID != null) { + com.bitchat.android.services.AppStateStore + .releasePrivateConversationHistory(conversationID) + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) + } // Notify notification manager that no private chat is active setCurrentPrivateChatPeer(null) // Clear mesh mention notifications since user is now back in mesh chat clearMeshMentionNotifications() + // Ensure sheet is hidden + hidePrivateChatSheet() } + + internal suspend fun deletePrivateConversation( + peerOrConversationID: String + ): com.bitchat.android.services.DeletedPrivateConversation? { + val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID) + val wasPinned = conversationListPreferences.isPinned(canonicalID) + val wasMuted = conversationListPreferences.isMuted(canonicalID) + val draft = conversationListPreferences.draftFor(canonicalID) + val unreadAliases = matchingUnreadAliases( + unreadConversationIDs = state.getUnreadPrivateMessagesValue(), + canonicalConversationID = canonicalID, + canonicalize = ContactDirectory::canonicalConversationId + ) + val deletion = withContext(Dispatchers.IO) { + com.bitchat.android.services.AppStateStore + .deletePrivateConversationAndWait(canonicalID) + }?.copy( + wasPinned = wasPinned, + wasMuted = wasMuted, + draft = draft + ) ?: return null + conversationListPreferences.removeConversation(canonicalID) + + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) + state.setUnreadPrivateMessages( + state.getUnreadPrivateMessagesValue() - unreadAliases + ) + seenMessageStore.remove(deletion.messageIDs) + + val selected = state.getSelectedPrivateChatPeerValue() + if ( + selected != null && + ContactDirectory.canonicalConversationId(selected) + .equals(canonicalID, ignoreCase = true) + ) { + privateChatManager.endPrivateChat() + setCurrentPrivateChatPeer(null) + } + val sheetPeer = state.getPrivateChatSheetPeerValue() + if ( + sheetPeer != null && + ContactDirectory.canonicalConversationId(sheetPeer) + .equals(canonicalID, ignoreCase = true) + ) { + hidePrivateChatSheet() + } + clearNotificationsForSender(canonicalID) + notificationManager.removeConversationShortcut(canonicalID) + return deletion + } + + internal suspend fun restoreDeletedConversation( + deletion: com.bitchat.android.services.DeletedPrivateConversation + ): Boolean { + val restored = withContext(Dispatchers.IO) { + com.bitchat.android.services.AppStateStore + .restoreDeletedConversation(deletion) + } + if (!restored) return false + if (deletion.wasPinned != conversationListPreferences.isPinned(deletion.conversationID)) { + conversationListPreferences.togglePinned(deletion.conversationID) + } + if (deletion.wasMuted != conversationListPreferences.isMuted(deletion.conversationID)) { + conversationListPreferences.toggleMuted(deletion.conversationID) + } + deletion.draft?.let { + conversationListPreferences.setDraft(deletion.conversationID, it) + } + state.setPrivateChats( + ContactDirectory.canonicalizePrivateChats( + com.bitchat.android.services.AppStateStore.privateMessages.value + ) + ) + if (deletion.unreadMessageCount > 0) { + state.setUnreadPrivateMessages( + state.getUnreadPrivateMessagesValue() + deletion.conversationID + ) + } + return true + } + + internal suspend fun setConversationRead( + conversationID: String, + isRead: Boolean + ): Boolean { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val updated = withContext(Dispatchers.IO) { + com.bitchat.android.services.AppStateStore + .setPrivateConversationRead(canonicalID, isRead) + } + if (!updated) return false + state.setUnreadPrivateMessages( + if (isRead) { + state.getUnreadPrivateMessagesValue().filterNotTo(mutableSetOf()) { + ContactDirectory.canonicalConversationId(it) + .equals(canonicalID, ignoreCase = true) + } + } else { + state.getUnreadPrivateMessagesValue() + canonicalID + } + ) + return true + } + + internal fun toggleConversationPinned(conversationID: String) { + conversationListPreferences.togglePinned(conversationID) + } + + internal fun toggleConversationMuted(conversationID: String) { + conversationListPreferences.toggleMuted(conversationID) + } + + internal fun conversationDraft(conversationID: String?): String = + conversationID + ?.let(ContactDirectory::canonicalConversationId) + ?.lowercase() + ?.let(conversationListPreferences.drafts.value::get) + .orEmpty() + + internal fun setConversationDraft(conversationID: String?, text: String) { + if (conversationID.isNullOrBlank()) return + conversationListPreferences.setDraft(conversationID, text) + } + + // MARK: - Open Latest Unread Private Chat + + fun openLatestUnreadPrivateChat() { + try { + val unreadKeys = state.getUnreadPrivateMessagesValue() + if (unreadKeys.isEmpty()) return + + val me = state.getNicknameValue() ?: mesh.myPeerID + val chats = state.getPrivateChatsValue() + + // Pick the latest incoming message among unread conversations + var bestKey: String? = null + var bestTime: Long = Long.MIN_VALUE + + unreadKeys.forEach { key -> + val list = chats[key] + if (!list.isNullOrEmpty()) { + // Prefer the latest incoming message (sender != me), fallback to last message + val latestIncoming = list.lastOrNull { it.sender != me } + val candidateTime = (latestIncoming ?: list.last()).timestamp.time + if (candidateTime > bestTime) { + bestTime = candidateTime + bestKey = key + } + } + } + + val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return + + val openPeer: String = if (targetKey.startsWith("nostr_")) { + // Use the exact conversation key for geohash DMs and ensure DM subscription + ensureGeohashDMSubscriptionIfNeeded(targetKey) + targetKey + } else { + // Resolve to a canonical mesh peer if needed + val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = targetKey, + connectedPeers = state.getConnectedPeersValue(), + meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey }, + nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, + findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) + canonical ?: targetKey + } + + showPrivateChatSheet(openPeer) + } catch (e: Exception) { + Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") + } + } + + // END - Open Latest Unread Private Chat + // MARK: - Message Sending - fun sendMessage(content: String) { - if (content.isEmpty()) return - - // Check for commands - if (content.startsWith("/")) { - commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel -> - meshService.sendMessage(messageContent, mentions, channel) - }, this) + fun sendMessage( + content: String, + onAccepted: (Boolean) -> Unit = {} + ) { + if (content.isEmpty()) { + onAccepted(false) return } - val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue()) - // REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions - // This was causing messages like "test @jack#1234 test" to auto-join channel "#1234" + // Check for commands + if (content.startsWith("/")) { + val selectedLocationForCommand = state.selectedLocationChannel.value + commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel -> + if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) { + // Route command-generated public messages via Nostr in geohash channels + geohashViewModel.sendGeohashMessage( + messageContent, + selectedLocationForCommand.channel, + mesh.myPeerID, + state.getNicknameValue() + ) + } else if (channel != null && channelManager.hasChannelKey(channel)) { + channelManager.sendEncryptedChannelMessage( + messageContent, + mentions, + channel, + state.getNicknameValue(), + mesh.myPeerID, + onEncryptedPayload = { + mesh.sendMessage(messageContent, mentions, channel) + }, + onFallback = { + mesh.sendMessage(messageContent, mentions, channel) + } + ) + } else { + mesh.sendMessage(messageContent, mentions, channel) + } + }, this) + onAccepted(true) + return + } + val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue()) var selectedPeer = state.getSelectedPrivateChatPeerValue() val currentChannelValue = state.getCurrentChannelValue() if (selectedPeer != null) { // If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target - selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeer = ContactDirectory.canonicalConversationId( + com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( selectedPeerID = selectedPeer, connectedPeers = state.getConnectedPeersValue(), - meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, - nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] }, + meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey }, + nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) ).also { canonical -> if (canonical != state.getSelectedPrivateChatPeerValue()) { - privateChatManager.startPrivateChat(canonical, meshService) + privateChatManager.startPrivateChat(canonical, mesh) + // If we're in the private chat sheet, update its active peer too + if (state.getPrivateChatSheetPeerValue() != null) { + showPrivateChatSheet(canonical) + } } } // Send private message - val recipientNickname = meshService.getPeerNicknames()[selectedPeer] - privateChatManager.sendPrivateMessage( - content, - selectedPeer, - recipientNickname, - state.getNicknameValue(), - meshService.myPeerID - ) { messageContent, peerID, recipientNicknameParam, messageId -> - // Route via MessageRouter (mesh when connected+established, else Nostr) - val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService) - router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) + val recipientNickname = nicknameForPeer(selectedPeer) + val destination = selectedPeer + viewModelScope.launch { + val accepted = privateChatManager.sendPrivateMessageDurably( + content, + destination, + recipientNickname, + state.getNicknameValue(), + mesh.myPeerID + ) { messageContent, peerID, recipientNicknameParam, messageId -> + val router = com.bitchat.android.services.MessageRouter.getInstance( + getApplication(), + mesh + ) + val route = router.sendPrivate( + messageContent, + peerID, + recipientNicknameParam, + messageId + ) + if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) { + messageManager.updateMessageDeliveryStatus( + messageId, + com.bitchat.android.model.DeliveryStatus.Sent + ) + } + } + onAccepted(accepted) } } else { // Check if we're in a location channel val selectedLocationChannel = state.selectedLocationChannel.value if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { // Send to geohash channel via Nostr ephemeral event - nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) + geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue()) } else { // Send public/channel message via mesh val message = BitchatMessage( - sender = state.getNicknameValue() ?: meshService.myPeerID, + sender = state.getNicknameValue() ?: mesh.myPeerID, content = content, timestamp = Date(), isRelay = false, - senderPeerID = meshService.myPeerID, + senderPeerID = mesh.myPeerID, mentions = if (mentions.isNotEmpty()) mentions else null, channel = currentChannelValue ) if (currentChannelValue != null) { - channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID) + channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID) // Check if encrypted channel if (channelManager.hasChannelKey(currentChannelValue)) { @@ -316,69 +1052,72 @@ class ChatViewModel( mentions, currentChannelValue, state.getNicknameValue(), - meshService.myPeerID, + mesh.myPeerID, onEncryptedPayload = { encryptedData -> - // This would need proper mesh service integration - meshService.sendMessage(content, mentions, currentChannelValue) + mesh.sendMessage(content, mentions, currentChannelValue) }, onFallback = { - meshService.sendMessage(content, mentions, currentChannelValue) + mesh.sendMessage(content, mentions, currentChannelValue) } ) } else { - meshService.sendMessage(content, mentions, currentChannelValue) + mesh.sendMessage(content, mentions, currentChannelValue) } } else { messageManager.addMessage(message) - meshService.sendMessage(content, mentions, null) + mesh.sendMessage(content, mentions, null) } } + onAccepted(true) } } // MARK: - Utility Functions fun getPeerIDForNickname(nickname: String): String? { - return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key + return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key } fun toggleFavorite(peerID: String) { Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") privateChatManager.toggleFavorite(peerID) - // Persist relationship in FavoritesPersistenceService when we have Noise key + // Persist relationship in FavoritesPersistenceService try { - val peerInfo = meshService.getPeerInfo(peerID) - val noiseKey = peerInfo?.noisePublicKey - val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID) + var noiseKey: ByteArray? = null + var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID + + val peerInfo = mesh.getPeerInfo(peerID) + if (peerInfo?.noisePublicKey != null) { + noiseKey = peerInfo.noisePublicKey + nickname = peerInfo.nickname + } else if (ContactIdentityResolver.isNoiseKeyHex(peerID)) { + noiseKey = ContactIdentityResolver.bytesFromHex(peerID) + val rel = noiseKey?.let { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + if (rel != null) nickname = rel.peerNickname + } else { + val contact = ContactDirectory.resolve(peerID) + noiseKey = contact.noisePublicKey + contact.displayName?.let { nickname = it } + } + if (noiseKey != null) { - val isNowFavorite = dataManager.favoritePeers.contains( - com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: "" - ) + val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val fingerprint = identityManager.generateFingerprint(noiseKey!!) + val isNowFavorite = dataManager.favoritePeers.contains(fingerprint) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus( - noisePublicKey = noiseKey, + noisePublicKey = noiseKey!!, nickname = nickname, isFavorite = isNowFavorite ) - // Send favorite notification via mesh or Nostr with our npub if available try { - val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) - val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}" - // Prefer mesh if session established, else try Nostr - if (meshService.hasEstablishedSession(peerID)) { - // Reuse existing private message path for notifications - meshService.sendPrivateMessage( - announcementContent, - peerID, - nickname, - java.util.UUID.randomUUID().toString() - ) - } else { - val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID - nostrTransport.sendFavoriteNotification(peerID, isNowFavorite) - } + com.bitchat.android.services.MessageRouter + .getInstance(getApplication(), mesh) + .sendFavoriteNotification(peerID, isNowFavorite) } catch (_: Exception) { } } } catch (_: Exception) { } @@ -387,13 +1126,64 @@ class ChatViewModel( logCurrentFavoriteState() } - private fun logCurrentFavoriteState() { - Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") - Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") + private fun refreshPeerFavoritedUs() { + try { + val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getAllRelationships() + .filter { it.theyFavoritedUs } + .mapNotNull { relationship -> + runCatching { + ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) + }.getOrNull() + } + .toSet() + state.setPeerFavoritedUs(fingerprints) + } catch (_: Exception) { } + } + + private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") + Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") } + + private fun isConnectedOnMesh(peerID: String): Boolean { + return try { + mesh.getPeerInfo(peerID)?.isConnected == true + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnMesh(peerID: String): Boolean { + return try { + mesh.getPeerInfo(peerID)?.isConnected == true && + mesh.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean { + return hasEstablishedSessionOnMesh(peerID) + } + + private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) { + mesh.initiateNoiseHandshake(peerID) + } + + private fun nicknameForPeer(peerID: String): String? { + val contact = ContactDirectory.resolve(peerID) + val meshPeerID = contact.meshPeerID ?: peerID + return contact.displayName + ?: state.peerNicknames.value[meshPeerID] + ?: try { mesh.getPeerNicknames()[meshPeerID] } catch (_: Exception) { null } + } + + private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState { + return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } + } /** * Initialize session state monitoring for reactive UI updates @@ -407,6 +1197,8 @@ class ChatViewModel( } } + // Location notes subscription management moved to LocationNotesViewModelExtensions.kt + /** * Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI) */ @@ -416,7 +1208,7 @@ class ChatViewModel( // Update session states val prevStates = state.getPeerSessionStatesValue() val sessionStates = currentPeers.associateWith { peerID -> - meshService.getSessionState(peerID).toString() + sessionStateForPeer(peerID).toString() } state.setPeerSessionStates(sessionStates) // Detect new established sessions and flush router outbox for them and their noiseHex aliases @@ -424,68 +1216,165 @@ class ChatViewModel( val old = prevStates[peerID] if (old != "established" && newState == "established") { com.bitchat.android.services.MessageRouter - .getInstance(getApplication(), meshService) + .getInstance(getApplication(), mesh) .onSessionEstablished(peerID) } } // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) + fingerprints.forEach { (peerID, fingerprint) -> + identityManager.cachePeerFingerprint(peerID, fingerprint) + val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null } + val noiseKeyHex = info?.noisePublicKey?.hexEncodedString() + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint) + } + info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname -> + identityManager.cacheFingerprintNickname(fingerprint, nickname) + } + } - val nicknames = meshService.getPeerNicknames() - state.setPeerNicknames(nicknames) + state.setPeerNicknames(mesh.getPeerNicknames()) - val rssiValues = meshService.getPeerRSSI() - state.setPeerRSSI(rssiValues) + state.setPeerRSSI(mesh.getPeerRSSI()) + + // Update directness per peer (driven by PeerManager state) + try { + val directMap = state.getConnectedPeersValue().associateWith { pid -> + mesh.getPeerInfo(pid)?.isDirectConnection == true + } + state.setPeerDirect(directMap) + } catch (_: Exception) { } + + // Flush any pending QR verification once a Noise session is established + currentPeers.forEach { peerID -> + if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) { + verificationHandler.sendPendingVerificationIfNeeded(peerID) + } + } + } + + // MARK: - QR Verification + + fun isPeerVerified(peerID: String, verifiedFingerprints: Set): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID) + return fingerprint != null && verifiedFingerprints.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set): Boolean { + val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) + return verifiedFingerprints.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + verificationHandler.unverifyFingerprint(peerID) + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + return verificationHandler.beginQRVerification(qr) } // MARK: - Debug and Troubleshooting fun getDebugStatus(): String { - return meshService.getDebugStatus() - } - - // Note: Mesh service restart is now handled by MainActivity - // This function is no longer needed - - fun setAppBackgroundState(inBackground: Boolean) { - // Forward to notification manager for notification logic - notificationManager.setAppBackgroundState(inBackground) + return mesh.getDebugStatus() } fun setCurrentPrivateChatPeer(peerID: String?) { - // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) } fun setCurrentGeohash(geohash: String?) { - // Update notification manager with current geohash for notification logic notificationManager.setCurrentGeohash(geohash) } fun clearNotificationsForSender(peerID: String) { - // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } fun clearNotificationsForGeohash(geohash: String) { - // Clear notifications when user opens a geohash chat notificationManager.clearNotificationsForGeohash(geohash) } - /** - * Clear mesh mention notifications when user opens mesh chat - */ fun clearMeshMentionNotifications() { notificationManager.clearMeshMentionNotifications() } + private var reopenSidebarAfterVerification = false + + fun showVerificationSheet(fromSidebar: Boolean = false) { + if (fromSidebar) { + reopenSidebarAfterVerification = true + } + state.setShowVerificationSheet(true) + } + + fun hideVerificationSheet() { + state.setShowVerificationSheet(false) + if (reopenSidebarAfterVerification) { + reopenSidebarAfterVerification = false + state.setShowMeshPeerList(true) + } + } + + fun showSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(true) + } + + fun hideSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(false) + } + + fun showMeshPeerList() { + state.setShowMeshPeerList(true) + } + + fun hideMeshPeerList() { + state.setShowMeshPeerList(false) + } + + fun showPrivateChatSheet(peerID: String) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + state.setPrivateChatSheetPeer(conversationID) + } + + fun hidePrivateChatSheet() { + state.setPrivateChatSheetPeer(null) + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + return verificationHandler.getPeerFingerprintForDisplay(peerID) + } + + fun getMyFingerprint(): String { + return verificationHandler.getMyFingerprint() + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID) + } + + fun verifyFingerprintValue(fingerprint: String) { + verificationHandler.verifyFingerprintValue(fingerprint) + } + + fun unverifyFingerprintValue(fingerprint: String) { + verificationHandler.unverifyFingerprintValue(fingerprint) + } + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { commandProcessor.updateCommandSuggestions(input) } - + + fun clearSuggestions() { + commandProcessor.clearSuggestions() + } + fun selectCommandSuggestion(suggestion: CommandSuggestion): String { return commandProcessor.selectCommandSuggestion(suggestion) } @@ -493,7 +1382,7 @@ class ChatViewModel( // MARK: - Mention Autocomplete fun updateMentionSuggestions(input: String) { - commandProcessor.updateMentionSuggestions(input, meshService, this) + commandProcessor.updateMentionSuggestions(input, mesh, this) } fun selectMentionSuggestion(nickname: String, currentText: String): String { @@ -509,7 +1398,7 @@ class ChatViewModel( override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } - + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } @@ -521,6 +1410,18 @@ class ChatViewModel( override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyChallenge(peerID, payload) + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyResponse(peerID, payload) + } + + override fun didResolvePrivateMediaPolicy(peerID: String) { + mediaSendingManager.retryPendingPrivateMedia(peerID) + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) @@ -534,31 +1435,80 @@ class ChatViewModel( return meshDelegateHandler.isFavorite(peerID) } - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager - // MARK: - Emergency Clear - + + private var panicClearInProgress = false + fun panicClearAllData() { + if (panicClearInProgress) return + panicClearInProgress = true + viewModelScope.launch { + try { + performPanicClearAllData() + } finally { + panicClearInProgress = false + } + } + } + + private suspend fun performPanicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") - + try { + com.bitchat.android.geohash.LocationChannelManager + .getInstance(getApplication()) + .disableLocationServices() + } catch (_: Exception) { } + + // A pending one-shot downgrade confirmation must not survive panic or + // become actionable against the fresh post-wipe identity. + mediaSendingManager.clearPendingPrivateMediaConsent() + + // Stop all message admission before wiping storage. The AppStateStore gate also rejects + // any transport callback already in flight until the fresh identity is ready. + clearAllMeshServiceData() + val conversationsCleared = + com.bitchat.android.services.AppStateStore + .panicClearPrivateConversations() + // Clear all UI managers + com.bitchat.android.services.AppStateStore.clear() messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() dataManager.clearAllData() + conversationListPreferences.clearAll() - // Clear all mesh service data - clearAllMeshServiceData() + // Clear seen message store and MessageRouter outbox + try { + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() + } catch (_: Exception) { } + try { + com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() + } catch (_: Exception) { } // Clear all cryptographic data clearAllCryptographicData() // Clear all notifications - notificationManager.clearAllNotifications() + notificationManager.clearAllNotifications(removeConversationShortcuts = true) + + // Clear all media files + com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) - // Clear Nostr/geohash state, keys, connections, and reinitialize from scratch + // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch try { - nostrGeohashService.panicResetNostrAndGeohash() + // Clear geohash bookmarks too (panic should remove everything) + try { + val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) + store.clearAll() + } catch (_: Exception) { } + + try { + val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + locationManager.clearPersistedChannel() + } catch (_: Exception) { } + + geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") } @@ -567,11 +1517,49 @@ class ChatViewModel( val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - - Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared") - - // Note: Mesh service restart is now handled by MainActivity - // This method now only clears data, not mesh service lifecycle + + if (!conversationsCleared) { + // Privacy wins over availability: keep private-message admission and transports + // stopped if SQLite could not prove that the conversation history was erased. + Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed") + return + } + + // Recreate mesh service with fresh identity + com.bitchat.android.services.AppStateStore + .resumePrivateConversationsAfterPanic() + recreateMeshServiceAfterPanic() + + Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") + } + + /** + * Recreate the mesh service with a fresh identity after panic clear. + * This ensures the new cryptographic keys are used for a new peer ID. + */ + private fun recreateMeshServiceAfterPanic() { + val oldPeerID = mesh.myPeerID + + // Clear the holder so getOrCreate() returns a fresh instance + MeshServiceHolder.clear() + + // Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData) + val freshMeshService = MeshServiceHolder.getOrCreate(getApplication()) + val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication()) + + // Replace our reference and set up the new service + meshService = freshMeshService + unifiedMeshService = freshUnifiedMeshService + mesh.delegate = this + + // Restart mesh operations with new identity + mesh.startServices() + mesh.sendBroadcastAnnounce() + + Log.d( + TAG, + "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}" + ) } /** @@ -580,7 +1568,7 @@ class ChatViewModel( private fun clearAllMeshServiceData() { try { // Request mesh service to clear all its internal data - meshService.clearAllInternalData() + mesh.clearAllInternalData() Log.d(TAG, "✅ Cleared all mesh service data") } catch (e: Exception) { @@ -594,16 +1582,26 @@ class ChatViewModel( private fun clearAllCryptographicData() { try { // Clear encryption service persistent identity (Ed25519 signing keys) - meshService.clearAllEncryptionData() + mesh.clearAllEncryptionData() // Clear secure identity state (if used) try { - val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val identityManager = SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() - Log.d(TAG, "✅ Cleared secure identity state") + // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) + try { + identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") + } catch (_: Exception) { } + Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") } + + // Clear FavoritesPersistenceService persistent relationships + try { + FavoritesPersistenceService.shared.clearAllFavorites() + Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") + } catch (_: Exception) { } Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { @@ -615,48 +1613,66 @@ class ChatViewModel( * Get participant count for a specific geohash (5-minute activity window) */ fun geohashParticipantCount(geohash: String): Int { - return nostrGeohashService.geohashParticipantCount(geohash) + return geohashViewModel.geohashParticipantCount(geohash) } /** * Begin sampling multiple geohashes for participant activity */ - fun beginGeohashSampling(geohashes: List) { - nostrGeohashService.beginGeohashSampling(geohashes) + fun beginGeohashSampling( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + ) { + geohashViewModel.beginGeohashSampling( + liveLocationGeohashes = liveLocationGeohashes, + userSelectedGeohashes = userSelectedGeohashes + ) } /** * End geohash sampling */ fun endGeohashSampling() { - nostrGeohashService.endGeohashSampling() + geohashViewModel.endGeohashSampling() } /** * Check if a geohash person is teleported (iOS-compatible) */ fun isPersonTeleported(pubkeyHex: String): Boolean { - return nostrGeohashService.isPersonTeleported(pubkeyHex) + return geohashViewModel.isPersonTeleported(pubkeyHex) } /** * Start geohash DM with pubkey hex (iOS-compatible) */ fun startGeohashDM(pubkeyHex: String) { - nostrGeohashService.startGeohashDM(pubkeyHex) { convKey -> - startPrivateChat(convKey) + geohashViewModel.startGeohashDM(pubkeyHex) { convKey -> + showPrivateChatSheet(convKey) + } + } + + fun startGeohashDMByNickname(nickname: String) { + geohashViewModel.startGeohashDMByNickname(nickname) { convKey -> + showPrivateChatSheet(convKey) + } + } + + fun startGeohashDMByShortId(shortId: String) { + geohashViewModel.startGeohashDMByShortId(shortId) { convKey -> + showPrivateChatSheet(convKey) } } fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { - nostrGeohashService.selectLocationChannel(channel) + geohashViewModel.selectLocationChannel(channel) } /** * Block a user in geohash channels by their nickname */ fun blockUserInGeohash(targetNickname: String) { - nostrGeohashService.blockUserInGeohash(targetNickname) + geohashViewModel.blockUserInGeohash(targetNickname) } // MARK: - Navigation Management @@ -668,15 +1684,7 @@ class ChatViewModel( fun hideAppInfo() { state.setShowAppInfo(false) } - - fun showSidebar() { - state.setShowSidebar(true) - } - - fun hideSidebar() { - state.setShowSidebar(false) - } - + /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system @@ -688,11 +1696,6 @@ class ChatViewModel( hideAppInfo() true } - // Close sidebar - state.getShowSidebarValue() -> { - hideSidebar() - true - } // Close password dialog state.getShowPasswordPromptValue() -> { state.setShowPasswordPrompt(false) @@ -700,7 +1703,7 @@ class ChatViewModel( true } // Exit private chat - state.getSelectedPrivateChatPeerValue() != null -> { + state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> { endPrivateChat() true } @@ -714,21 +1717,17 @@ class ChatViewModel( } } - // MARK: - iOS-Compatible Color System + // MARK: - Canonical peer identities /** - * Get consistent color for a mesh peer by ID (iOS-compatible) + * Return the stable identity used by every UI surface to color a mesh peer. */ - fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - // Try to get stable Noise key, fallback to peer ID - val seed = "noise:${peerID.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID) /** - * Get consistent color for a Nostr pubkey (iOS-compatible) + * Return the stable identity used by every UI surface to color a Nostr peer. */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark) - } + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex) + } diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 703a7f13..1cccfb19 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -1,8 +1,11 @@ package com.bitchat.android.ui -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage -import java.util.* +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Handles processing of IRC-style commands @@ -11,7 +14,8 @@ class CommandProcessor( private val state: ChatState, private val messageManager: MessageManager, private val channelManager: ChannelManager, - private val privateChatManager: PrivateChatManager + private val privateChatManager: PrivateChatManager, + private val coroutineScope: CoroutineScope? = null ) { // Available commands list @@ -22,6 +26,7 @@ class CommandProcessor( CommandSuggestion("/hug", emptyList(), "", "send someone a warm hug"), CommandSuggestion("/j", listOf("/join"), "", "join or create a channel"), CommandSuggestion("/m", listOf("/msg"), " [message]", "send private message"), + CommandSuggestion("/pay", emptyList(), " [public]", "send a Cashu ecash token"), CommandSuggestion("/slap", emptyList(), "", "slap someone with a trout"), CommandSuggestion("/unblock", emptyList(), "", "unblock a peer"), CommandSuggestion("/w", emptyList(), null, "see who's online") @@ -29,22 +34,22 @@ class CommandProcessor( // MARK: - Command Processing - fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean { + fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean { if (!command.startsWith("/")) return false val parts = command.split(" ") - val cmd = parts.first() - + val cmd = parts.first().lowercase() when (cmd) { "/j", "/join" -> handleJoinCommand(parts, myPeerID) - "/m", "/msg" -> handleMessageCommand(parts, meshService) + "/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel) + "/pay" -> handlePayCommand(command, meshService, myPeerID, onSendMessage, viewModel) "/w" -> handleWhoCommand(meshService, viewModel) "/clear" -> handleClearCommand() "/pass" -> handlePassCommand(parts, myPeerID) "/block" -> handleBlockCommand(parts, meshService) "/unblock" -> handleUnblockCommand(parts, meshService) - "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage) - "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage) + "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel) + "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel) "/channels" -> handleChannelsCommand() else -> handleUnknownCommand(cmd) } @@ -78,7 +83,7 @@ class CommandProcessor( } } - private fun handleMessageCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleMessageCommand(parts: List, meshService: MeshService, viewModel: ChatViewModel?) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") val peerID = getPeerIDForNickname(targetName, meshService) @@ -90,16 +95,15 @@ class CommandProcessor( if (parts.size > 2) { val messageContent = parts.drop(2).joinToString(" ") val recipientNickname = getPeerNickname(peerID, meshService) - privateChatManager.sendPrivateMessage( + sendPrivateMessage( messageContent, peerID, recipientNickname, state.getNicknameValue(), - getMyPeerID(meshService) - ) { content, peerIdParam, recipientNicknameParam, messageId -> - // This would trigger the actual mesh service send - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) - } + getMyPeerID(meshService), + meshService, + viewModel + ) } else { val systemMessage = BitchatMessage( sender = "system", @@ -130,7 +134,7 @@ class CommandProcessor( } } - private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + private fun handleWhoCommand(meshService: MeshService, viewModel: ChatViewModel? = null) { // Channel-aware who command (matches iOS behavior) val (peerList, contextDescription) = if (viewModel != null) { when (val selectedChannel = viewModel.selectedLocationChannel.value) { @@ -190,6 +194,9 @@ class CommandProcessor( // Clear private chat val peerID = state.getSelectedPrivateChatPeerValue()!! messageManager.clearPrivateMessages(peerID) + // `/clear` removes history but should not navigate away from the chat the + // command was issued in. A later message will repopulate this conversation. + state.setSelectedPrivateChatPeer(peerID) } state.getCurrentChannelValue() != null -> { // Clear channel messages @@ -249,7 +256,7 @@ class CommandProcessor( } } - private fun handleBlockCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleBlockCommand(parts: List, meshService: MeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") privateChatManager.blockPeerByNickname(targetName, meshService) @@ -266,7 +273,7 @@ class CommandProcessor( } } - private fun handleUnblockCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleUnblockCommand(parts: List, meshService: MeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") privateChatManager.unblockPeerByNickname(targetName, meshService) @@ -285,26 +292,34 @@ class CommandProcessor( parts: List, verb: String, object_: String, - meshService: BluetoothMeshService, + meshService: MeshService, myPeerID: String, - onSendMessage: (String, List, String?) -> Unit + onSendMessage: (String, List, String?) -> Unit, + viewModel: ChatViewModel? ) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *" - + + // If we're in a geohash location channel, don't add a local echo here. + // GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata. + val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location + // Send as regular message if (state.getSelectedPrivateChatPeerValue() != null) { val peerID = state.getSelectedPrivateChatPeerValue()!! - privateChatManager.sendPrivateMessage( + sendPrivateMessage( actionMessage, peerID, getPeerNickname(peerID, meshService), state.getNicknameValue(), - myPeerID - ) { content, peerIdParam, recipientNicknameParam, messageId -> - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) - } + myPeerID, + meshService, + viewModel + ) + } else if (isInLocationChannel) { + // Let the transport layer add the echo; just send it out + onSendMessage(actionMessage, emptyList(), null) } else { val message = BitchatMessage( sender = state.getNicknameValue() ?: myPeerID, @@ -350,6 +365,95 @@ class CommandProcessor( ) messageManager.addMessage(systemMessage) } + + private fun handlePayCommand( + command: String, + meshService: MeshService, + myPeerID: String, + onSendMessage: (String, List, String?) -> Unit, + viewModel: ChatViewModel? + ) { + val args = command.trim().split(Regex("\\s+")).drop(1) + if (args.isEmpty()) { + addSystemMessage("usage: /pay [public] — Cashu tokens are bearer instruments") + return + } + + val publicConfirmed = args.lastOrNull()?.equals("public", ignoreCase = true) == true + val rawToken = if (publicConfirmed) args.dropLast(1).joinToString(" ") else args.joinToString(" ") + val token = CashuTokenDecoder.bareToken(rawToken) + val info = token?.let { CashuTokenDecoder.decode(it, strict = true) } + if (token == null || info == null) { + addSystemMessage("invalid cashu token — not sending it") + return + } + + val selectedPeer = state.getSelectedPrivateChatPeerValue() + if (selectedPeer != null) { + privateChatManager.sendPrivateMessage( + token, + selectedPeer, + getPeerNickname(selectedPeer, meshService), + state.getNicknameValue(), + myPeerID + ) { content, peerID, recipientNickname, messageId -> + sendPrivateMessageVia(meshService, content, peerID, recipientNickname, messageId, viewModel) + } + } else { + if (!publicConfirmed) { + addSystemMessage( + "Cashu tokens are bearer instruments. Anyone here can redeem this token. " + + "Confirm with: /pay public" + ) + return + } + val isLocationChannel = + state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location + if (!isLocationChannel) { + val message = BitchatMessage( + sender = state.getNicknameValue() ?: myPeerID, + content = token, + timestamp = Date(), + isRelay = false, + senderPeerID = myPeerID, + channel = state.getCurrentChannelValue() + ) + val channel = state.getCurrentChannelValue() + if (channel != null) channelManager.addChannelMessage(channel, message, myPeerID) + else messageManager.addMessage(message) + } + onSendMessage(token, emptyList(), state.getCurrentChannelValue()) + } + + addSystemMessage( + "sent ${info.displayAmount ?: "Cashu token"} — bearer token; first redeemer wins" + ) + } + + private fun addSystemMessage(content: String) { + val message = BitchatMessage( + sender = "system", + content = content, + timestamp = Date(), + isRelay = false + ) + val selectedPeer = state.getSelectedPrivateChatPeerValue() + val selectedLocationChannel = state.selectedLocationChannel.value + val channel = state.getCurrentChannelValue() + when { + selectedPeer != null -> { + messageManager.addPrivateMessageNoUnread(selectedPeer, message.copy(isPrivate = true)) + } + selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location -> { + messageManager.addChannelMessage( + "geo:${selectedLocationChannel.channel.geohash}", + message + ) + } + channel != null -> channelManager.addChannelMessage(channel, message, null) + else -> messageManager.addMessage(message) + } + } private fun handleUnknownCommand(cmd: String) { val systemMessage = BitchatMessage( @@ -361,6 +465,18 @@ class CommandProcessor( messageManager.addMessage(systemMessage) } + /** + * Dismiss the command and mention popups. The composer clears its field in code + * after a send, which does not run onValueChange, so the popups need an explicit + * clear. + */ + fun clearSuggestions() { + state.setShowCommandSuggestions(false) + state.setCommandSuggestions(emptyList()) + state.setShowMentionSuggestions(false) + state.setMentionSuggestions(emptyList()) + } + // MARK: - Command Autocomplete fun updateCommandSuggestions(input: String) { @@ -397,7 +513,12 @@ class CommandProcessor( emptyList() } - return baseCommands + channelCommands + val isPublicGeohash = + state.getSelectedPrivateChatPeerValue() == null && + state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location + return (baseCommands + channelCommands).filterNot { + isPublicGeohash && it.command == "/pay" + } } private fun filterCommands(commands: List, input: String): List { @@ -417,7 +538,7 @@ class CommandProcessor( // MARK: - Mention Autocomplete - fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + fun updateMentionSuggestions(input: String, meshService: MeshService, viewModel: ChatViewModel? = null) { // Check if input contains @ and we're at the end of a word or at the end of input val atIndex = input.lastIndexOf('@') if (atIndex == -1) { @@ -442,18 +563,28 @@ class CommandProcessor( is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: use Bluetooth mesh peer nicknames - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } is com.bitchat.android.geohash.ChannelID.Location -> { // Location channel: use geohash participants with collision-resistant suffixes - val geohashPeople = viewModel.geohashPeople.value ?: emptyList() + val geohashPeople = viewModel.geohashPeople.value val currentNickname = state.getNicknameValue() + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) geohashPeople.mapNotNull { person -> - val displayName = person.displayName - // Exclude self from suggestions - if (displayName.startsWith("${currentNickname}#")) { + val baseName = splitSuffix(person.displayName).first + val hasNicknameCollision = + baseName.lowercase(Locale.ROOT) in duplicateNames + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + // A unique local nickname can be excluded directly. If it collides, the + // nickname alone cannot identify which row is self, so keep the suffixed + // rows rather than accidentally hiding the other user. + if ( + !hasNicknameCollision && + baseName.equals(currentNickname, ignoreCase = true) + ) { null } else { displayName @@ -463,13 +594,11 @@ class CommandProcessor( } } else { // Fallback to mesh peers if no viewModel available - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } - // Filter nicknames based on the text after @ - val filteredNicknames = peerCandidates.filter { nickname -> - nickname.startsWith(textAfterAt, ignoreCase = true) - }.sorted() + val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt) if (filteredNicknames.isNotEmpty()) { state.setMentionSuggestions(filteredNicknames) @@ -497,19 +626,96 @@ class CommandProcessor( // MARK: - Utility Functions - private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { + private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { - return meshService.getPeerNicknames()[peerID] ?: peerID + private fun getPeerNickname(peerID: String, meshService: MeshService): String { + return meshService.getPeerNicknames()[peerID] + ?: peerID } - private fun getMyPeerID(meshService: BluetoothMeshService): String { + private fun getMyPeerID(meshService: MeshService): String { return meshService.myPeerID } - - private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) { - meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) + + private fun sendPrivateMessage( + content: String, + peerID: String, + recipientNickname: String?, + senderNickname: String?, + myPeerID: String, + meshService: MeshService, + viewModel: ChatViewModel? + ) { + val send: (String, String, String, String) -> Unit = + { messageContent, peerIdParam, recipientNicknameParam, messageId -> + sendPrivateMessageVia( + meshService, + messageContent, + peerIdParam, + recipientNicknameParam, + messageId, + viewModel + ) + } + val scope = coroutineScope + if (scope == null) { + privateChatManager.sendPrivateMessage( + content, + peerID, + recipientNickname, + senderNickname, + myPeerID, + send + ) + } else { + scope.launch { + privateChatManager.sendPrivateMessageDurably( + content, + peerID, + recipientNickname, + senderNickname, + myPeerID, + send + ) + } + } + } + + private fun sendPrivateMessageVia( + meshService: MeshService, + content: String, + peerID: String, + recipientNickname: String, + messageId: String, + viewModel: ChatViewModel? + ) { + if (viewModel != null) { + com.bitchat.android.services.MessageRouter + .getInstance(viewModel.getApplication(), meshService) + .sendPrivate(content, peerID, recipientNickname, messageId) + } else { + meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) + } } } + +/** + * Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced + * a username and is not actionable. Names such as `anon1234` are announced usernames and remain + * valid mention targets. + */ +internal fun filterMentionCandidates( + candidates: List, + query: String +): List { + return candidates.asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .filterNot(::isUnannouncedNickname) + .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } + .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .toList() +} diff --git a/app/src/main/java/com/bitchat/android/ui/ConversationSecurityState.kt b/app/src/main/java/com/bitchat/android/ui/ConversationSecurityState.kt new file mode 100644 index 00000000..5bbdbfe3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ConversationSecurityState.kt @@ -0,0 +1,17 @@ +package com.bitchat.android.ui + +/** + * Resolves the Noise session shown for a private conversation. + * + * Persistent conversations use a canonical contact ID, while live Noise sessions are keyed by + * the currently connected mesh peer ID. Prefer that live identity and retain the conversation ID + * as a fallback for peers whose IDs are already identical. + */ +internal fun resolveConversationSessionState( + conversationID: String, + activeMeshPeerID: String?, + peerSessionStates: Map +): String? { + return activeMeshPeerID?.let(peerSessionStates::get) + ?: peerSessionStates[conversationID] +} diff --git a/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt new file mode 100644 index 00000000..2178d793 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt @@ -0,0 +1,189 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.PrivateMessageArrivalOrder + +/** + * Presence-independent presentation state for every retained private conversation. + */ +internal data class ConversationSummary( + val conversationID: String, + val displayName: String, + val unreadCount: Int, + val latestMessageAt: Long, + val latestActivityOrder: Long, + val latestMessageType: BitchatMessageType, + val latestMessagePreview: String, + val latestMessageIsOutgoing: Boolean = false, + val latestDeliveryStatus: DeliveryStatus? = null, + val transport: DirectMessageTransport, + val nostrPubkey: String?, + val identityAliases: Set, + val isConnected: Boolean = false, + val connectedPeerID: String? = null, + val sourceGeohash: String? = null, + val isPinned: Boolean = false, + val isMuted: Boolean = false, + val draft: String? = null +) + +internal fun buildConversationSummaries( + unreadConversationIDs: Set, + privateChats: Map>, + currentUserIdentifiers: Set, + canonicalize: (String) -> String, + isMessageRead: (BitchatMessage) -> Boolean, + persistedUnreadCounts: Map = emptyMap() +): List { + if (privateChats.isEmpty()) return emptyList() + + val currentUsers = currentUserIdentifiers + .filter(String::isNotBlank) + .mapTo(mutableSetOf()) { it.lowercase() } + val unreadCanonicalIDs = unreadConversationIDs + .mapTo(mutableSetOf()) { canonicalize(it).lowercase() } + val unreadCountsByCanonicalID = persistedUnreadCounts.entries + .groupingBy { canonicalize(it.key).lowercase() } + .fold(0) { total, entry -> total + entry.value } + val aliasesByCanonicalID = linkedMapOf>() + val messagesByCanonicalID = linkedMapOf>() + val displayCanonicalIDByNormalized = linkedMapOf() + + privateChats.forEach { (sourceID, messages) -> + val canonicalID = canonicalize(sourceID) + val normalizedID = canonicalID.lowercase() + displayCanonicalIDByNormalized.putIfAbsent(normalizedID, canonicalID) + aliasesByCanonicalID + .getOrPut(normalizedID) { linkedSetOf() } + .add(sourceID) + messagesByCanonicalID + .getOrPut(normalizedID) { mutableListOf() } + .addAll(messages) + } + + return messagesByCanonicalID.mapNotNull { (normalizedConversationID, sourceMessages) -> + val conversationID = + displayCanonicalIDByNormalized.getValue(normalizedConversationID) + val messages = sourceMessages.distinctBy { it.id } + if (messages.isEmpty()) return@mapNotNull null + + fun activityOrder(message: BitchatMessage): Long = + PrivateMessageArrivalOrder.sequenceOf(message.id) ?: message.timestamp.time + + val latest = messages.maxWithOrNull( + compareBy(::activityOrder).thenBy { it.id } + ) ?: return@mapNotNull null + fun isOutgoing(message: BitchatMessage): Boolean = + message.sender.lowercase() in currentUsers || + message.senderPeerID?.lowercase() in currentUsers + + val incoming = messages.filterNot { + isOutgoing(it) || it.sender == "system" + } + val latestIncoming = incoming.maxWithOrNull( + compareBy(::activityOrder).thenBy { it.id } + ) + val canonicalUnread = conversationID.lowercase() in unreadCanonicalIDs + val persistedUnreadCount = unreadCountsByCanonicalID[conversationID.lowercase()] ?: 0 + val unreadCount = maxOf( + persistedUnreadCount, + if (canonicalUnread && incoming.isNotEmpty()) { + incoming.count { !isMessageRead(it) }.coerceAtLeast(1) + } else { + 0 + } + ) + val aliases = aliasesByCanonicalID[normalizedConversationID].orEmpty() + val nostrPubkey = latestIncoming?.senderNostrPubkey ?: latest.senderNostrPubkey + val isNostrConversation = nostrPubkey != null || + aliases.any(::isNostrConversationKey) || + isNostrConversationKey(conversationID) + val displayName = latestIncoming + ?.sender + ?.takeIf { it.isNotBlank() } + ?: latest.recipientNickname + ?.takeIf { it.isNotBlank() } + ?: latest.sender.takeIf { + it.isNotBlank() && it !in currentUsers && it != "system" + } + ?: conversationID.take(12) + + ConversationSummary( + conversationID = conversationID, + displayName = displayName, + unreadCount = unreadCount, + latestMessageAt = + PrivateMessageArrivalOrder.receivedAtOf(latest.id) ?: latest.timestamp.time, + latestActivityOrder = activityOrder(latest), + latestMessageType = latest.type, + latestMessagePreview = latest.conversationPreview(), + latestMessageIsOutgoing = isOutgoing(latest), + latestDeliveryStatus = latest.deliveryStatus, + transport = if (isNostrConversation) { + DirectMessageTransport.NOSTR + } else { + DirectMessageTransport.MESH + }, + nostrPubkey = nostrPubkey, + identityAliases = (aliases + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } + ) + } +} + +internal fun resolveConversationDisplayName( + fallbackName: String, + connectedPeerID: String?, + peerNicknames: Map, + resolvedContactName: String?, + persistedDisplayName: String? +): String { + fun String?.usableName(): String? = this?.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } + + val liveName = connectedPeerID?.let { peerID -> + peerNicknames[peerID] + ?: peerNicknames.entries + .firstOrNull { (candidateID, _) -> + candidateID.equals(peerID, ignoreCase = true) + } + ?.value + } + return liveName.usableName() + ?: resolvedContactName.usableName() + ?: persistedDisplayName.usableName() + ?: fallbackName +} + +internal fun sortConversationSummaries( + conversations: List +): List = conversations.sortedWith( + compareByDescending { it.isConnected } + .thenByDescending { it.isPinned } + .thenByDescending { it.unreadCount > 0 } + .thenByDescending { it.latestActivityOrder } + .thenBy { it.displayName.lowercase() } + .thenBy { it.conversationID } +) + +private fun isNostrConversationKey(value: String): Boolean = + value.startsWith("nostr_") || value.startsWith("nostr:") + +private fun BitchatMessage.conversationPreview(): String { + val preview = when (type) { + BitchatMessageType.File -> content + .substringAfterLast('/') + .substringAfterLast('\\') + else -> content + } + return preview + .replace(CONVERSATION_PREVIEW_WHITESPACE, " ") + .trim() + .take(MAX_CONVERSATION_PREVIEW_LENGTH) +} + +private val CONVERSATION_PREVIEW_WHITESPACE = Regex("\\s+") +private const val MAX_CONVERSATION_PREVIEW_LENGTH = 240 diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 0ed7d690..8517ec15 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import android.content.Context import android.content.SharedPreferences import android.util.Log +import com.bitchat.android.geohash.DEFAULT_LIVE_LOCATION_ENABLED import com.google.gson.Gson import kotlin.random.Random @@ -54,13 +55,24 @@ class DataManager(private val context: Context) { fun saveLastGeohashChannel(channelData: String) { prefs.edit().putString("last_geohash_channel", channelData).apply() - Log.d(TAG, "Saved last geohash channel: $channelData") + Log.d(TAG, "Saved last geohash channel") } fun clearLastGeohashChannel() { prefs.edit().remove("last_geohash_channel").apply() Log.d(TAG, "Cleared last geohash channel") } + + // MARK: - Location Services State + + fun saveLocationServicesEnabled(enabled: Boolean) { + prefs.edit().putBoolean("location_services_enabled", enabled).commit() + Log.d(TAG, "Saved location services enabled state: $enabled") + } + + fun isLocationServicesEnabled(): Boolean { + return prefs.getBoolean("location_services_enabled", DEFAULT_LIVE_LOCATION_ENABLED) + } // MARK: - Channel Data Management @@ -186,8 +198,10 @@ class DataManager(private val context: Context) { // MARK: - Blocked Users Management + @Synchronized fun loadBlockedUsers() { val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() + _blockedUsers.clear() _blockedUsers.addAll(savedBlockedUsers) } @@ -205,6 +219,7 @@ class DataManager(private val context: Context) { saveBlockedUsers() } + @Synchronized fun isUserBlocked(fingerprint: String): Boolean { return _blockedUsers.contains(fingerprint) } @@ -214,8 +229,10 @@ class DataManager(private val context: Context) { private val _geohashBlockedUsers = mutableSetOf() // Set of nostr pubkey hex val geohashBlockedUsers: Set get() = _geohashBlockedUsers.toSet() + @Synchronized fun loadGeohashBlockedUsers() { val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet() + _geohashBlockedUsers.clear() _geohashBlockedUsers.addAll(savedGeohashBlockedUsers) } @@ -233,6 +250,7 @@ class DataManager(private val context: Context) { saveGeohashBlockedUsers() } + @Synchronized fun isGeohashUserBlocked(pubkeyHex: String): Boolean { return _geohashBlockedUsers.contains(pubkeyHex) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 5ce03bf1..0c0e583f 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,37 +1,34 @@ package com.bitchat.android.ui -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.filled.Email +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import java.util.* import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.colorForPeer +import com.bitchat.android.R +import com.bitchat.android.ui.theme.LocalBitchatPalette +import java.util.* /** - * GeohashPeopleList - iOS-compatible component for displaying geohash participants - * Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers + * Geohash people list — card groups matching location / settings sheet rows. */ -/** - * GeoPerson data class - matches iOS GeoPerson structure exactly - */ data class GeoPerson( val id: String, // pubkey hex (lowercased) - matches iOS - val displayName: String, // nickname with #suffix - matches iOS + val displayName: String, // nickname with #suffix - matches iOS val lastSeen: Date // activity timestamp - matches iOS ) @@ -39,118 +36,236 @@ data class GeoPerson( fun GeohashPeopleList( viewModel: ChatViewModel, onTapPerson: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + excludedIdentityAliases: Set = emptySet() ) { - val colorScheme = MaterialTheme.colorScheme - - // Observe geohash people from ChatViewModel - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val isTeleported by viewModel.isTeleported.observeAsState(false) - val nickname by viewModel.nickname.observeAsState("") - val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - - Column { - // Header matching iOS style - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.LocationOn, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "PEOPLE", - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold - ), - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - if (geohashPeople.isEmpty()) { - // Empty state - matches iOS "nobody around..." - Text( - text = "nobody around...", - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) - ) - } else { - // Get current geohash identity for "me" detection - val myHex = remember(selectedLocationChannel) { - when (val channel = selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> { - try { - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = viewModel.getApplication() - ) - identity.publicKeyHex.lowercase() - } catch (e: Exception) { - Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") - null - } - } - else -> null - } - } - - // Sort people: me first, then by lastSeen (matches iOS exactly) - val orderedPeople = remember(geohashPeople, myHex) { - geohashPeople.sortedWith { a, b -> - when { - myHex != null && a.id == myHex && b.id != myHex -> -1 - myHex != null && b.id == myHex && a.id != myHex -> 1 - else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first - } - } - } + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() + val teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - // Compute base name collisions to decide whether to show hash suffix - val baseNameCounts = remember(geohashPeople) { - val counts = mutableMapOf() - geohashPeople.forEach { person -> - val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName) - counts[b] = (counts[b] ?: 0) + 1 + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val myHex = remember(selectedLocationChannel) { + when (val channel = selectedLocationChannel) { + is com.bitchat.android.geohash.ChannelID.Location -> { + try { + val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( + forGeohash = channel.channel.geohash, + context = viewModel.getApplication() + ) + identity.publicKeyHex.lowercase(Locale.ROOT) + } catch (e: Exception) { + Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") + null } - counts } - - val firstID = orderedPeople.firstOrNull()?.id - - orderedPeople.forEach { person -> + else -> null + } + } + val peopleIncludingSelf = remember(geohashPeople, myHex, nickname) { + if (myHex != null && geohashPeople.none { it.id.equals(myHex, ignoreCase = true) }) { + listOf( + GeoPerson( + id = myHex, + displayName = nickname.ifBlank { "anon" }, + lastSeen = Date(0) + ) + ) + geohashPeople + } else { + geohashPeople + } + } + val visiblePeople = remember(peopleIncludingSelf, excludedIdentityAliases) { + peopleIncludingSelf.filterNot { person -> + val alias = "nostr_${person.id.take(16)}".lowercase() + alias in excludedIdentityAliases + } + } + val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) { + sectionGeohashPeople( + people = visiblePeople, + myId = myHex, + selfIsTeleported = isTeleported, + teleportedIds = teleportedGeo + ) + } + val displayedPeople = remember(sections) { + sections.onLocation + sections.teleportedIn + } + val teleportedPersonIds = remember(sections.teleportedIn) { + sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) } + } + val duplicateBaseNames = remember(displayedPeople) { + duplicateGeohashBaseNames(displayedPeople) + } + + Column(modifier = modifier) { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, displayedPeople.size) + ) + + if (displayedPeople.isEmpty()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Text( + text = stringResource(R.string.nobody_around), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ) + ) + } + } else { + @Composable + fun personRow(person: GeoPerson) { + val isMe = myHex != null && person.id.equals(myHex, ignoreCase = true) + val personIsTeleported = if (isMe) { + isTeleported + } else { + person.id.lowercase(Locale.ROOT) in teleportedPersonIds + } GeohashPersonItem( person = person, - isFirst = person.id == firstID, - isMe = myHex != null && person.id == myHex, + isMe = isMe, hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"), - isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id), - isMyTeleported = person.id == myHex && isTeleported, - nickname = nickname, - colorScheme = colorScheme, + isTeleported = personIsTeleported, viewModel = viewModel, - showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = splitSuffix(person.displayName) + .first + .lowercase(Locale.ROOT) in duplicateBaseNames, onTap = { - if (person.id != myHex) { - // TODO: Re-enable when NIP-17 geohash DM issues are fixed - // Start geohash DM (iOS-compatible) + if (!isMe) { viewModel.startGeohashDM(person.id) onTapPerson() } } ) } + + if (sections.onLocation.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_on_location)) + PeopleCard( + people = sections.onLocation, + row = { personRow(it) } + ) + } + + if (sections.teleportedIn.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_teleported_in)) + PeopleCard( + people = sections.teleportedIn, + row = { personRow(it) } + ) + } + } + } +} + +internal data class GeohashPeopleSections( + val onLocation: List, + val teleportedIn: List +) + +/** + * Names that require a short identity suffix, calculated across both people sections. + * + * Matching is case-insensitive to mirror geohash chat's nickname collision handling. + */ +internal fun duplicateGeohashBaseNames(people: List): Set = + people + .groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) } + .eachCount() + .filterValues { it > 1 } + .keys + +/** + * The same `#abcd` disambiguator used by geohash chat. + * + * Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr + * public key when a collision exists. Preserve an already-announced suffix for compatibility. + */ +internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String { + if (!showHashSuffix) return "" + val announcedSuffix = splitSuffix(person.displayName).second + return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" } +} + +internal fun disambiguatedGeohashDisplayName( + person: GeoPerson, + duplicateBaseNames: Set, +): String { + val baseName = splitSuffix(person.displayName).first + val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames + return baseName + geohashIdentitySuffix(person, showSuffix) +} + +/** + * Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities + * are omitted, while announced names such as `anon1234` remain ordinary participants. Self is + * retained even before a nickname announcement and is always first in the matching section. + */ +internal fun sectionGeohashPeople( + people: List, + myId: String?, + selfIsTeleported: Boolean, + teleportedIds: Set +): GeohashPeopleSections { + val normalizedMyId = myId?.lowercase(Locale.ROOT) + val normalizedTeleportedIds = teleportedIds + .mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) } + fun isSelf(person: GeoPerson): Boolean = + normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId + fun isTeleported(person: GeoPerson): Boolean = + if (isSelf(person)) selfIsTeleported + else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds + + val displayedPeople = people.filter { person -> + isSelf(person) || !isUnannouncedNickname(person.displayName) + } + val ordered = displayedPeople.sortedWith( + compareByDescending(::isSelf) + .thenByDescending { it.lastSeen } + ) + return GeohashPeopleSections( + onLocation = ordered.filterNot(::isTeleported), + teleportedIn = ordered.filter(::isTeleported) + ) +} + +/** One uncapped card of people. The enclosing sheet owns scrolling. */ +@Composable +private fun PeopleCard( + people: List, + row: @Composable (GeoPerson) -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + AnimatedRowColumn(items = people, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + row(person) + } } } } @@ -158,113 +273,98 @@ fun GeohashPeopleList( @Composable private fun GeohashPersonItem( person: GeoPerson, - isFirst: Boolean, isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, - isMyTeleported: Boolean, - nickname: String, - colorScheme: ColorScheme, viewModel: ChatViewModel, showHashSuffix: Boolean, onTap: () -> Unit ) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val statusIconRes = + if (isTeleported) R.drawable.ic_spec_teleport + else R.drawable.ic_spec_on_location_person + + val (baseNameRaw, _) = splitSuffix(person.displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = geohashIdentitySuffix(person, showHashSuffix) + val assignedColor = colorForPeer( + viewModel.peerIdentityForNostrPubkey(person.id), + palette + ) + val baseColor = if (isMe) palette.accentOrange else assignedColor + Row( modifier = Modifier .fillMaxWidth() - .clickable { onTap() } - .padding(horizontal = 24.dp, vertical = 4.dp) - .padding(top = if (isFirst) 10.dp else 0.dp), + .clickable(onClick = onTap) + .padding(horizontal = SheetRowHorizontal, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - // Icon logic matching iOS exactly - if (hasUnreadDM) { - // Unread DM indicator (orange envelope) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = "Unread message", - modifier = Modifier.size(12.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else { - // Face icon with teleportation state - val (iconName, iconColor) = when { - isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me - isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others - isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me - else -> "face.smiling" to colorScheme.onSurface // Regular color for others + PeerAvatar( + name = baseNameRaw, + color = baseColor, + badge = { + Icon( + painter = painterResource(statusIconRes), + contentDescription = if (isTeleported) { + stringResource(R.string.cd_teleported) + } else { + stringResource(R.string.section_on_location) + }, + modifier = Modifier.size(13.dp), + tint = if (isTeleported) palette.accentPurple else colorScheme.primary + ) } - - // Use appropriate Material icon (closest match to iOS SF Symbols) - val icon = when (iconName) { - "face.dashed" -> Icons.Outlined.Explore - else -> Icons.Outlined.LocationOn - } - - Icon( - imageVector = icon, - contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(12.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with suffix handling - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - - // Get consistent peer color (matches iOS color assignment exactly) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor - + ) + + Spacer(modifier = Modifier.width(12.dp)) + Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - // Base name with peer-specific color Text( text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) - - // Suffix (collision-resistant #abcd) in lighter shade + if (suffix.isNotEmpty()) { Text( text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - - // "You" indicator for current user + if (isMe) { Text( - text = " (you)", - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), + text = stringResource(R.string.you_suffix), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, color = baseColor ) } } - - Spacer(modifier = Modifier.width(8.dp)) + + if (hasUnreadDM) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier + .padding(start = 8.dp) + .size(18.dp), + tint = palette.accentOrange + ) + } } } - - diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt new file mode 100644 index 00000000..ce450ded --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -0,0 +1,324 @@ +package com.bitchat.android.ui + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +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.core.content.res.ResourcesCompat +import com.bitchat.android.R +import com.bitchat.android.geohash.Geohash +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.ui.globe.GlobeColors +import com.bitchat.android.ui.globe.GlobeState +import com.bitchat.android.ui.globe.GlobeView +import com.bitchat.android.ui.globe.LandData +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.BitchatTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class GeohashPickerActivity : OrientationAwareActivity() { + + companion object { + const val EXTRA_INITIAL_GEOHASH = "initial_geohash" + const val EXTRA_RESULT_GEOHASH = "result_geohash" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase() + var geohashToFocus: String? = null + var initLat = 20.0 + var initLon = 0.0 + + if (!initialGeohash.isNullOrEmpty()) { + geohashToFocus = initialGeohash + try { + val (lat, lon) = Geohash.decodeToCenter(initialGeohash) + initLat = lat + initLon = lon + } catch (_: Throwable) {} + } else { + // If no initial geohash, try to use the user's coarsest location + val locationManager = LocationChannelManager.getInstance(applicationContext) + val channels = locationManager.availableChannels.value + if (!channels.isNullOrEmpty()) { + val coarsestChannel = channels.minByOrNull { it.geohash.length } + if (coarsestChannel != null) { + geohashToFocus = coarsestChannel.geohash + try { + val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash) + initLat = lat + initLon = lon + } catch (_: Throwable) {} + } + } + } + + val initialPrecision = (geohashToFocus?.length ?: 2).coerceIn(1, 12) + val targetLat = initLat + val targetLon = initLon + + setContent { + BitchatTheme { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val globeState = remember { + GlobeState( + targetLat = targetLat, + targetLon = targetLon, + initialPrecision = initialPrecision, + startZoomedOut = true + ).apply { + introTarget = Triple(targetLat, targetLon, initialPrecision) + } + } + + LaunchedEffect(globeState) { globeState.attach(scope) } + + val land by produceState?>(initialValue = null) { + value = withContext(Dispatchers.IO) { LandData.load(context) } + } + val borders by produceState>(initialValue = emptyList()) { + value = withContext(Dispatchers.IO) { LandData.loadBorders(context) } + } + val cities by produceState>(initialValue = emptyList()) { + value = withContext(Dispatchers.IO) { LandData.loadCities(context) } + } + + val colorScheme = MaterialTheme.colorScheme + val dark = colorScheme.background.luminance() < 0.5f + val globeColors = remember(colorScheme, dark) { + if (dark) { + GlobeColors( + accent = colorScheme.primary, + land = Color(0xFF16241B), + coastline = colorScheme.primary.copy(alpha = 0.45f), + border = colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + oceanCenter = Color(0xFF0A1410), + oceanEdge = Color(0xFF020604), + atmosphere = colorScheme.primary, + graticule = colorScheme.onSurface.copy(alpha = 0.055f), + grid = colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + label = colorScheme.onSurfaceVariant, + labelHalo = colorScheme.background, + star = colorScheme.onSurface + ) + } else { + GlobeColors( + accent = colorScheme.primary, + land = Color(0xFFBCD2C0), + coastline = colorScheme.primary.copy(alpha = 0.5f), + border = colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + oceanCenter = Color(0xFFEAF2EC), + oceanEdge = Color(0xFFD4E2D7), + atmosphere = colorScheme.primary, + graticule = colorScheme.onSurface.copy(alpha = 0.08f), + grid = colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + label = colorScheme.onSurfaceVariant, + labelHalo = colorScheme.background, + star = colorScheme.onSurfaceVariant + ) + } + } + + val labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) } + val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) } + + Box( + Modifier + .fillMaxSize() + .background(colorScheme.background) + ) { + land?.let { rings -> + GlobeView( + state = globeState, + colors = globeColors, + land = rings, + borders = borders, + cities = cities, + labelTypeface = labelTypeface, + labelTypefaceBold = labelTypefaceBold, + modifier = Modifier.fillMaxSize() + ) + } + + // Floating info pill + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 20.dp) + .fillMaxWidth(0.8f), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + shape = RoundedCornerShape(12.dp), + tonalElevation = 3.dp, + shadowElevation = 6.dp + ) { + Text( + text = stringResource(R.string.pan_zoom_instruction), + fontSize = 12.sp, + textAlign = TextAlign.Center, + fontFamily = BitchatFontFamily, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 10.dp) + ) + } + + // Floating bottom controls + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 20.dp, start = 16.dp, end = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Geohash label (monospace, app style) + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + shape = RoundedCornerShape(12.dp), + tonalElevation = 3.dp, + shadowElevation = 6.dp + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = if (globeState.selectedGeohash.isNotEmpty()) "#${globeState.selectedGeohash}" else "select location", + fontSize = BASE_FONT_SIZE.sp, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + if (globeState.selectedGeohash.isNotEmpty()) { + Text( + text = "${levelForLength(globeState.precision).displayName} • ${coverageString(globeState.precision)}", + fontSize = (BASE_FONT_SIZE - 4).sp, + fontFamily = BitchatFontFamily, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Button row + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Decrease precision + Button( + onClick = { globeState.animatePrecision(globeState.precision - 1) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision)) + } + + // Increase precision + Button( + onClick = { globeState.animatePrecision(globeState.precision + 1) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision)) + } + + // Select button + Button( + onClick = { + val gh = globeState.selectedGeohash + if (gh.isNotEmpty()) { + val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) } + setResult(Activity.RESULT_OK, result) + finish() + } + }, + enabled = globeState.selectedGeohash.isNotEmpty(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash)) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.select), + fontSize = (BASE_FONT_SIZE - 2).sp, + fontFamily = BitchatFontFamily + ) + } + } + } + } + } + } + } + + private fun levelForLength(length: Int): GeohashChannelLevel { + return when (length) { + in 0..2 -> GeohashChannelLevel.REGION + in 3..4 -> GeohashChannelLevel.PROVINCE + 5 -> GeohashChannelLevel.CITY + 6 -> GeohashChannelLevel.NEIGHBORHOOD + 7 -> GeohashChannelLevel.BLOCK + else -> GeohashChannelLevel.BUILDING + } + } + + private fun coverageString(precision: Int): String { + val maxMeters = when (precision) { + 2 -> 1_250_000.0 + 3 -> 156_000.0 + 4 -> 39_100.0 + 5 -> 4_890.0 + 6 -> 1_220.0 + 7 -> 153.0 + 8 -> 38.2 + 9 -> 4.77 + 10 -> 1.19 + else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble()) + } + val km = maxMeters / 1000.0 + return when { + km >= 100 -> "~${String.format(java.util.Locale.US, "%.0f", km)} km" + km >= 1 -> "~${String.format(java.util.Locale.US, "%.1f", km)} km" + else -> "~${String.format(java.util.Locale.US, "%.0f", maxMeters)} m" + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt new file mode 100644 index 00000000..63a5c0a7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -0,0 +1,522 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.viewModelScope +import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import com.bitchat.android.nostr.GeohashMessageHandler +import com.bitchat.android.nostr.GeohashRepository +import com.bitchat.android.nostr.NostrBackgroundRuntime +import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.nostr.NostrProtocol +import com.bitchat.android.nostr.NostrRelayManager +import com.bitchat.android.nostr.NostrSubscriptionManager +import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.nostr.GeohashConversationRegistry +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.Date +import kotlinx.coroutines.isActive +import java.util.UUID + +class GeohashViewModel( + application: Application, + private val state: ChatState, + private val messageManager: MessageManager, + private val dataManager: DataManager, + private val notificationManager: NotificationManager +) : AndroidViewModel(application), DefaultLifecycleObserver { + + companion object { private const val TAG = "GeohashViewModel" } + + private val repo = GeohashRepository(application, state, dataManager) + private val uiSubscriptionOwner = "geohash-ui-${UUID.randomUUID()}" + private val subscriptionManager = NostrSubscriptionManager( + application, + owner = uiSubscriptionOwner + ) + private val geohashMessageHandler = GeohashMessageHandler( + application = application, + repo = repo, + scope = viewModelScope, + dataManager = dataManager, + addChannelMessage = messageManager::addChannelMessage + ) + + // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. + private var currentGeohashPresenceSubId: String? = null + private var geoTimer: Job? = null + private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null + private val activeSamplingGeohashes = mutableSetOf() + private val samplingSubscriptionIds = mutableMapOf() + private val liveSamplingSubscriptionGeohashes = mutableSetOf() + private var requestedLiveSamplingGeohashes: Set = emptySet() + private var requestedUserSamplingGeohashes: Set = emptySet() + private var uiSubscriptionsShutdown = false + private val liveLocationRevocationListener: () -> Unit = { + val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet() + revokedLiveGeohashes.forEach { geohash -> + samplingSubscriptionIds.remove(geohash) + activeSamplingGeohashes.remove(geohash) + } + liveSamplingSubscriptionGeohashes.clear() + requestedLiveSamplingGeohashes = emptySet() + } + + // Geohash of the currently selected Location channel (null for Mesh/none). + private var activeChannelGeohash: String? = null + + val geohashPeople: StateFlow> = state.geohashPeople + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val selectedLocationChannel: StateFlow = state.selectedLocationChannel + + init { + LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener) + } + + fun initialize() { + // Observe process lifecycle to manage background sampling + kotlin.runCatching { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + try { + locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + viewModelScope.launch { + locationChannelManager?.selectedChannel?.collect { channel -> + state.setSelectedLocationChannel(channel) + switchLocationChannel(channel) + } + } + viewModelScope.launch { + locationChannelManager?.teleported?.collect { teleported -> + state.setIsTeleported(teleported) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize location channel state: ${e.message}") + state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) + state.setIsTeleported(false) + } + } + + fun panicReset() { + repo.clearAll() + GeohashAliasRegistry.clear() + GeohashConversationRegistry.clear() + subscriptionManager.unsubscribeAllOwned() + currentGeohashPresenceSubId = null + activeChannelGeohash = null + geoTimer?.cancel() + geoTimer = null + try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} + NostrBackgroundRuntime.resetSubscriptions() + try { com.bitchat.android.nostr.NostrRelayManager.getInstance(getApplication()).clearAllOnPanic() } catch (_: Exception) {} + try { com.bitchat.android.nostr.LocationNotesManager.getInstance().stop() } catch (_: Exception) {} + } + + fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { + viewModelScope.launch { + try { + val canUseChannel = locationChannelManager + ?.canUseSelectedLocationChannel(channel) == true + if (!canUseChannel) { + Log.w(TAG, "Blocked message to a stale live-location channel") + return@launch + } + val isLiveDerived = locationChannelManager + ?.isSelectedChannelLiveDerived(channel) == true + val liveLocationToken = locationChannelManager + ?.liveLocationTokenForSelectedChannel(channel) + if (isLiveDerived && liveLocationToken == null) return@launch + val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}" + val pow = PoWPreferenceManager.getCurrentSettings() + val localMsg = com.bitchat.android.model.BitchatMessage( + id = tempId, + sender = nickname ?: myPeerID, + content = content, + timestamp = Date(), + isRelay = false, + senderPeerID = "geohash:${channel.geohash}", + channel = "#${channel.geohash}", + powDifficulty = if (pow.enabled) pow.difficulty else null + ) + messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) + val identity = NostrIdentityBridge.deriveIdentity( + forGeohash = channel.geohash, + context = getApplication() + ) + val teleported = locationChannelManager?.teleported?.value + ?: state.isTeleported.value + val event = NostrProtocol.createEphemeralGeohashEvent( + content, + channel.geohash, + identity, + nickname, + teleported + ) + val relayManager = NostrRelayManager.getInstance(getApplication()) + relayManager.sendEventToGeohash( + event, + channel.geohash, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveLocationToken + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to send geohash message: ${e.message}") + } + } + } + + fun beginGeohashSampling( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + ) { + requestedLiveSamplingGeohashes = liveLocationGeohashes.toSet() + requestedUserSamplingGeohashes = userSelectedGeohashes.toSet() + reconcileSamplingSubscriptions() + } + + private fun reconcileSamplingSubscriptions() { + val currentSet = activeSamplingGeohashes.toSet() + val newSet = GeohashNostrPrivacyPolicy.samplingTargets( + liveLocationGeohashes = requestedLiveSamplingGeohashes, + userSelectedGeohashes = requestedUserSamplingGeohashes, + liveLocationEnabled = LiveLocationPrivacyGate.isEnabled + ) + + val toRemove = currentSet - newSet + val toAdd = newSet - currentSet + val toPromoteToUserSelection = currentSet + .intersect(requestedUserSamplingGeohashes) + .intersect(liveSamplingSubscriptionGeohashes) + + if (toAdd.isEmpty() && toRemove.isEmpty() && toPromoteToUserSelection.isEmpty()) return + + Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed") + + // Remove old subscriptions + toRemove.forEach { geohash -> + unsubscribeSampling(geohash) + activeSamplingGeohashes.remove(geohash) + } + + // A bookmark must remain functional after live access is revoked. Replace a + // live-tagged subscription with an untagged manual subscription immediately. + toPromoteToUserSelection.forEach { geohash -> + unsubscribeSampling(geohash) + if (isAppInForeground()) performSubscribeSampling(geohash) + } + + // Add new subscriptions + activeSamplingGeohashes.addAll(toAdd) + if (isAppInForeground()) { + toAdd.forEach { geohash -> + performSubscribeSampling(geohash) + } + } + } + + fun endGeohashSampling() { + requestedLiveSamplingGeohashes = emptySet() + requestedUserSamplingGeohashes = emptySet() + if (activeSamplingGeohashes.isEmpty()) return + Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)") + + activeSamplingGeohashes.toList().forEach { geohash -> + unsubscribeSampling(geohash) + } + activeSamplingGeohashes.clear() + } + fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash) + fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex) + + fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) { + val convKey = "nostr_${pubkeyHex.take(16)}" + repo.putNostrKeyMapping(convKey, pubkeyHex) + // Record the conversation's geohash using the currently selected location channel (if any) + val current = state.selectedLocationChannel.value + val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash + if (!gh.isNullOrEmpty()) { + repo.setConversationGeohash(convKey, gh) + GeohashConversationRegistry.set(convKey, gh) + } + onStartPrivateChat(convKey) + Log.d(TAG, "🗨️ Started geohash DM") + } + + fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) { + val pubkey = repo.findPubkeyByNickname(nickname) + if (pubkey != null) { + startGeohashDM(pubkey, onStartPrivateChat) + } else { + Log.w(TAG, "Cannot start geohash DM: nickname '$nickname' not found in repo") + // Optionally notify user + } + } + + fun startGeohashDMByShortId(shortId: String, onStartPrivateChat: (String) -> Unit) { + val pubkey = repo.findPubkeyByShortId(shortId) + if (pubkey != null) { + startGeohashDM(pubkey, onStartPrivateChat) + } else { + Log.w(TAG, "Cannot start geohash DM: shortId '$shortId' not found in repo") + } + } + + fun getNostrKeyMapping(): Map = repo.getNostrKeyMapping() + + fun blockUserInGeohash(targetNickname: String) { + val pubkey = repo.findPubkeyByNickname(targetNickname) + if (pubkey != null) { + dataManager.addGeohashBlockedUser(pubkey) + // Refresh people list and counts to remove blocked entry immediately + repo.refreshGeohashPeople() + repo.updateReactiveParticipantCounts() + val sysMsg = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = "blocked $targetNickname in geohash channels", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(sysMsg) + } else { + val sysMsg = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = "user '$targetNickname' not found in current geohash", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(sysMsg) + } + } + + fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { + locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") } + } + + fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) { + val geohash = repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) + ?: return + NostrBackgroundRuntime.ensureConversationDm(geohash) + } + + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { + val foregroundName = repo.displayNameForNostrPubkeyUI(pubkeyHex) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForNostrPubkey(pubkeyHex) + ?: foregroundName + } + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { + val foregroundName = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + ?: foregroundName + } + + fun conversationGeohash(conversationKey: String): String? = + repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) + + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + PeerIdentity.nostr(pubkeyHex) + + private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { + geoTimer?.cancel(); geoTimer = null + currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } + + when (channel) { + is com.bitchat.android.geohash.ChannelID.Mesh -> { + Log.d(TAG, "📡 Switched to mesh channel") + activeChannelGeohash = null + repo.setCurrentGeohash(null) + notificationManager.setCurrentGeohash(null) + notificationManager.clearMeshMentionNotifications() + repo.refreshGeohashPeople() + } + is com.bitchat.android.geohash.ChannelID.Location -> { + if (locationChannelManager?.canUseSelectedLocationChannel(channel.channel) != true) { + Log.w(TAG, "Ignoring a stale live-location channel selection") + switchLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) + return + } + Log.d(TAG, "📍 Switching to geohash channel") + activeChannelGeohash = channel.channel.geohash + repo.setCurrentGeohash(channel.channel.geohash) + repo.refreshGeohashPeople() + notificationManager.setCurrentGeohash(channel.channel.geohash) + notificationManager.clearNotificationsForGeohash(channel.channel.geohash) + try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { } + + try { + val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) + // We don't update participant here anymore; presence loop handles it via Kind 20001 + val teleported = locationChannelManager?.teleported?.value + ?: state.isTeleported.value + if (teleported) repo.markTeleported(identity.publicKeyHex) + } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } + + startGeoParticipantsTimer() + val liveLocationToken = locationChannelManager + ?.liveLocationTokenForSelectedChannel(channel.channel) + + // Presence heartbeat firehose (kind 20001) is the high-volume data hog; only + // run it in the foreground. It is restored in onStart() and torn down in onStop(). + if (isAppInForeground()) { + subscribeChannelPresence(channel.channel.geohash, liveLocationToken) + } + } + null -> { + Log.d(TAG, "📡 No channel selected") + repo.setCurrentGeohash(null) + repo.refreshGeohashPeople() + } + } + } + + /** + * Subscribe to the presence heartbeat firehose (kind 20001) for a geohash channel. + * High-volume; only used to refresh the participant list, so it is torn down in + * onStop() and restored in onStart() to cut background mobile data. + */ + private fun subscribeChannelPresence( + geohash: String, + liveLocationToken: Long? + ) { + val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3600000L, + limit = 200, + id = subId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + liveLocationToken = liveLocationToken + ) + } + + private fun startGeoParticipantsTimer() { + geoTimer = viewModelScope.launch { + while (repo.getCurrentGeohash() != null) { + delay(30000) + repo.refreshGeohashPeople() + } + } + } + + override fun onCleared() { + shutdownUiSubscriptions() + super.onCleared() + } + + fun shutdownUiSubscriptions() { + if (uiSubscriptionsShutdown) return + uiSubscriptionsShutdown = true + subscriptionManager.unsubscribeAllOwned() + geoTimer?.cancel() + geoTimer = null + kotlin.runCatching { + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } + LiveLocationPrivacyGate.removeRevocationListener(liveLocationRevocationListener) + } + + override fun onStart(owner: LifecycleOwner) { + Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming") + // Android permission may have changed while backgrounded. Invalidate the + // process-wide token before restoring any subscription or heartbeat. + locationChannelManager?.syncPermissionState() + + // Restore the presence heartbeat firehose for the selected geohash channel. + // (The chat message stream is kept alive in the background, so it is not restored here.) + val selected = locationChannelManager?.selectedChannel?.value + val selectedLocation = selected as? com.bitchat.android.geohash.ChannelID.Location + if (selectedLocation != null && + selectedLocation.channel.geohash == activeChannelGeohash && + locationChannelManager?.canUseSelectedLocationChannel(selectedLocation.channel) == true + ) { + subscribeChannelPresence( + selectedLocation.channel.geohash, + locationChannelManager + ?.liveLocationTokenForSelectedChannel(selectedLocation.channel) + ) + } + // Resume geohash sampling subscriptions + activeSamplingGeohashes.forEach { performSubscribeSampling(it) } + // Resume the participant-refresh polling timer if a geohash is selected + if (repo.getCurrentGeohash() != null && geoTimer?.isActive != true) { + startGeoParticipantsTimer() + } + } + + override fun onStop(owner: LifecycleOwner) { + Log.d(TAG, "🌍 App backgrounded: pausing geohash presence firehose (keeping message + DM subscriptions)") + // Drop the high-volume presence heartbeat firehose (kind 20001). + // The chat message stream (kind 20000) is intentionally left active so messages still arrive. + currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } + // Drop geohash sampling subscriptions + activeSamplingGeohashes.forEach(::unsubscribeSampling) + // Stop participant-refresh polling + geoTimer?.cancel(); geoTimer = null + // Process-owned message and DM subscriptions remain active. + } + + private fun performSubscribeSampling(geohash: String) { + val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) { + "sampling-${UUID.randomUUID()}" + } + // Sampling only needs participant counts, never message bodies, so it subscribes to + // presence heartbeats only (kind 20001) to keep the payload small. + val subscribe = { + liveSamplingSubscriptionGeohashes.remove(geohash) + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 86400000L, + limit = 200, + id = subscriptionId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + + if (geohash in requestedUserSamplingGeohashes) { + subscribe() + } else if (geohash in requestedLiveSamplingGeohashes) { + val isCurrentLiveTarget = locationChannelManager + ?.availableChannels + ?.value + ?.any { it.geohash == geohash } == true + if (!isCurrentLiveTarget) return + val token = LiveLocationPrivacyGate.captureToken() ?: return + LiveLocationPrivacyGate.runIfAllowed(token) { + liveSamplingSubscriptionGeohashes.add(geohash) + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 86400000L, + limit = 200, + id = subscriptionId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + liveLocationToken = token + ) + } + } + } + + private fun unsubscribeSampling(geohash: String) { + samplingSubscriptionIds.remove(geohash)?.let(subscriptionManager::unsubscribe) + liveSamplingSubscriptionGeohashes.remove(geohash) + } + + private fun isAppInForeground(): Boolean { + return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 6ebeb21d..1269fe6b 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,41 +1,90 @@ package com.bitchat.android.ui +import android.view.HapticFeedbackConstants +import com.bitchat.android.ui.theme.BitchatFontFamily +// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher + + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import kotlin.math.roundToInt import androidx.compose.ui.unit.sp import com.bitchat.android.R +import com.bitchat.android.features.voice.VoiceRecorder import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.text.withStyle +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import androidx.compose.foundation.isSystemInDarkTheme +import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.features.voice.normalizeAmplitudeSample +import com.bitchat.android.features.voice.AudioWaveformExtractor +import com.bitchat.android.ui.media.RealtimeScrollingWaveform +import com.bitchat.android.ui.media.ImagePickerButton +import com.bitchat.android.ui.media.FilePickerButton /** * Input components for ChatScreen @@ -46,41 +95,28 @@ import androidx.compose.foundation.isSystemInDarkTheme * VisualTransformation that styles slash commands with background and color * while preserving cursor positioning and click handling */ -class SlashCommandVisualTransformation : VisualTransformation { +class SlashCommandVisualTransformation( + private val commandColor: Color, + private val commandBackground: Color, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)") - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - - slashCommandRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled slash command - withStyle( - style = SpanStyle( - color = Color(0xFF00FF7F), // Bright green - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - background = Color(0xFF2D2D2D) // Dark gray background - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) - } + val builder = AnnotatedString.Builder(text) + slashCommandRegex.findAll(text.text).forEach { match -> + builder.addStyle( + style = SpanStyle( + color = commandColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + background = commandBackground + ), + start = match.range.first, + end = match.range.last + 1, + ) } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -90,40 +126,56 @@ class SlashCommandVisualTransformation : VisualTransformation { * VisualTransformation that styles mentions with background and color * while preserving cursor positioning and click handling */ -class MentionVisualTransformation : VisualTransformation { +class MentionVisualTransformation( + private val mentionPeerIdentities: Map, + private val palette: BitchatPalette, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val mentionRegex = Regex("@([a-zA-Z0-9_]+)") - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - - mentionRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled mention - withStyle( + val builder = AnnotatedString.Builder(text) + + MENTION_TOKEN_REGEX.findAll(text.text).forEach { match -> + val start = match.range.first + val end = match.range.last + 1 + val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 } + val suffixStart = suffixOffset?.let(start::plus) ?: end + val mentionColor = colorForMention( + mention = match.value, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + + // Keep the whole token on one continuous color-derived chip. + builder.addStyle( + style = SpanStyle( + background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA), + ), + start = start, + end = end, + ) + builder.addStyle( + style = SpanStyle( + color = mentionColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = start, + end = suffixStart, + ) + if (suffixStart < end) { + builder.addStyle( style = SpanStyle( - color = Color(0xFFFF9500), // Orange - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) + color = mentionColor.copy(alpha = SUFFIX_ALPHA), + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = suffixStart, + end = end, + ) } } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -152,126 +204,606 @@ class CombinedVisualTransformation(private val transformations: List Unit +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor + + val container by animateColorAsState( + // A tint rather than a fill. A solid accent disc next to the text you are typing was the + // loudest thing on the screen; at 20% it still reads as "armed" without competing. + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonContainer" + ) + val tint by animateColorAsState( + targetValue = if (isActive) accent else colorScheme.onSurfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonTint" + ) + // A small dip on press. Spring rather than tween so the release overshoots very slightly and + // the button feels physical instead of merely animated. + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.88f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "composerButtonScale" + ) + + Box( + modifier = modifier.size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .background(container, CircleShape) + .semantics { contentDescription?.let { this.contentDescription = it } }, + contentAlignment = Alignment.Center + ) { + content(tint) + } + } +} + @Composable fun MessageInput( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, onSend: () -> Unit, + onSendVoiceNote: (String?, String?, String) -> Unit, + onSendImageNote: (String?, String?, String) -> Unit, + onSendFileNote: (String?, String?, String) -> Unit, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, + showMediaButtons: Boolean, + mentionPeerIdentities: Map = emptyMap(), + recorderFactory: ((String?, String?) -> VoiceRecorder)? = null, + activePublicTalker: String? = null, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } - val hasText = value.text.isNotBlank() // Check if there's text for send button state - - Row( - modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Text input with placeholder - Box( - modifier = Modifier.weight(1f) - ) { - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.primary, - fontFamily = FontFamily.Monospace - ), - cursorBrush = SolidColor(colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text - }), - visualTransformation = CombinedVisualTransformation( - listOf(SlashCommandVisualTransformation(), MentionVisualTransformation()) - ), - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> - isFocused.value = focusState.isFocused - } - ) - - // Show placeholder when there's no text - if (value.text.isEmpty()) { - Text( - text = "type a message...", - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey - modifier = Modifier.fillMaxWidth() - ) - } + val hasText = value.text.isNotBlank() + val focusRequester = remember { FocusRequester() } + var isRecording by remember { mutableStateOf(false) } + var isLiveRecording by remember { mutableStateOf(false) } + var elapsedMs by remember { mutableStateOf(0L) } + var amplitude by remember { mutableStateOf(0) } + val cashuToken = remember(value.text) { + CashuTokenDecoder.bareToken(value.text) + } + + // Slide-to-cancel: while recording, the mic button streams the finger position (root + // coords) up here; the cancel disc beside it reports its bounds. Approaching the disc + // makes it lean toward the finger and blush red; only entering it activates cancel. + var cancelBounds by remember { mutableStateOf(null) } + var cancelFinger by remember { mutableStateOf(null) } + val density = LocalDensity.current + val cancelSlackPx = with(density) { 8.dp.toPx() } + val cancelHover = cancelFinger != null && + cancelBounds?.inflate(cancelSlackPx)?.contains(cancelFinger!!) == true + val cancelCenter = cancelBounds?.center + val cancelProximity: Float + val cancelPull: Offset + val trackedFinger = cancelFinger + if (trackedFinger != null && cancelCenter != null) { + val toFinger = trackedFinger - cancelCenter + val dist = toFinger.getDistance() + val outer = with(density) { 36.dp.toPx() } + val inner = with(density) { 18.dp.toPx() } + cancelProximity = ((outer - dist) / (outer - inner)).coerceIn(0f, 1f) + cancelPull = if (dist > 1f) { + toFinger * (cancelProximity * with(density) { 12.dp.toPx() } / dist) + } else Offset.Zero + } else { + cancelProximity = 0f + cancelPull = Offset.Zero + } + // A firm, physical click each time the finger enters or leaves the cancel target. + val view = LocalView.current + var cancelHoverHapticState by remember { mutableStateOf(false) } + LaunchedEffect(cancelHover, isRecording) { + if (!isRecording) { + cancelHoverHapticState = false + } else if (cancelHover != cancelHoverHapticState) { + view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + cancelHoverHapticState = cancelHover } - - Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing - - // Command quick access button - if (value.text.isEmpty()) { - FilledTonalIconButton( - onClick = { - onValueChange(TextFieldValue(text = "/", selection = TextRange("/".length))) - }, - modifier = Modifier.size(32.dp) - ) { - Text( - text = "/", - textAlign = TextAlign.Center + } + + // Recording is the one state worth shouting about, so it overrides focus. While recording + // the outline also firms up slightly in the same fast sweep — present, but muted. + val borderColor by animateColorAsState( + targetValue = when { + isRecording -> colorScheme.error.copy(alpha = 0.65f) + isFocused.value -> palette.inputOutlineFocused + else -> palette.inputOutline + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerBorder" + ) + val borderWidth by animateDpAsState( + targetValue = if (isRecording) 1.5.dp else 1.dp, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerBorderWidth" + ) + // A barely-there lift on focus. While recording the pill turns into a neutral grey slab + // (NOT the brand-tinted elevation color) so it protrudes from the flat black chat. + val containerColor by animateColorAsState( + targetValue = when { + isRecording -> colorScheme.surfaceVariant.copy(alpha = 0.97f) + else -> (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha) + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerContainer" + ) + + Row( + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom + ) { + // MARK: - The pill. Field and action buttons are one visual object. + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = ComposerMinHeight) + // Grow smoothly as the field wraps to more lines rather than jumping a line at + // a time. + .animateContentSize( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) ) - } - } else { - // Send button with enabled/disabled state - IconButton( - onClick = { if (hasText) onSend() }, // Only execute if there's text - enabled = hasText, // Enable only when there's text - modifier = Modifier.size(32.dp) + .background(containerColor, ComposerShape) + .border(borderWidth, borderColor, ComposerShape), + verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .weight(1f) + .padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp) ) { - // Update send button to match input field colors - Box( + // Always keep the text field mounted to retain focus and avoid IME collapse + BasicTextField( + value = value, + onValueChange = onValueChange, + // Near-white, not terminal green: this is the one place in the app where the + // user is composing rather than reading, and green-on-black is tiring to + // type into. + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = if (cashuToken == null) colorScheme.onSurface else Color.Transparent, + fontFamily = BitchatFontFamily + ), + cursorBrush = SolidColor( + if (isRecording || cashuToken != null) Color.Transparent else colorScheme.onSurface + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + if (hasText) onSend() + }), + singleLine = cashuToken != null, + // Cap the growth so a pasted wall of text cannot swallow the message list. + maxLines = 6, + visualTransformation = remember( + palette, + colorScheme.primary, + mentionPeerIdentities, + ) { + CombinedVisualTransformation( + listOf( + SlashCommandVisualTransformation( + commandColor = colorScheme.primary, + commandBackground = colorScheme.primary.copy(alpha = 0.14f), + ), + MentionVisualTransformation( + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + ) + ) + }, modifier = Modifier - .size(30.dp) - .background( - color = if (!hasText) { - // Disabled state - muted grey - colorScheme.onSurface.copy(alpha = 0.3f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Orange for both private messages and channels when enabled - Color(0xFFFF9500).copy(alpha = 0.75f) - } else if (colorScheme.background == Color.Black) { - Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme - } else { - Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme - }, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.ArrowUpward, - contentDescription = stringResource(id = R.string.send_message), - modifier = Modifier.size(20.dp), - tint = if (!hasText) { - // Disabled state - muted grey icon - colorScheme.onSurface.copy(alpha = 0.5f) - } else if (selectedPrivatePeer != null || currentChannel != null) { - // Black arrow on orange for both private and channel modes - Color.Black - } else if (colorScheme.background == Color.Black) { - Color.Black // Black arrow on bright green in dark theme - } else { - Color.White // White arrow on dark green in light theme + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused.value = focusState.isFocused } + ) + + cashuToken?.let { token -> + CashuPaymentChip( + token = token, + onClick = { focusRequester.requestFocus() }, + showActions = false, ) } + + // Placeholder fades rather than blinking, which matters because it reappears + // every time a message is sent. + val placeholderAlpha by animateFloatAsState( + targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "placeholderAlpha" + ) + if (placeholderAlpha > 0f) { + Text( + text = if ( + selectedPrivatePeer == null && currentChannel == null && activePublicTalker != null + ) "$activePublicTalker is live" else stringResource(R.string.type_a_message_placeholder), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + color = palette.textTertiary, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .alpha(placeholderAlpha) + ) + } + + // Recording visualiser, layered over the (empty) field. + val waveformAlpha by animateFloatAsState( + targetValue = if (isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "waveformAlpha" + ) + if (isRecording) { + Row( + modifier = Modifier + .fillMaxWidth() + // Same content height as the single-line text field, so the pill + // (and the separator above it) does not change size when the + // recording visualizer replaces the field. + .height(22.dp) + .alpha(waveformAlpha), + verticalAlignment = Alignment.CenterVertically + ) { + // Timestamp on the left, clear of the thumb resting on the record + // button; the waveform keeps the remaining width and its history + // scrolls off the left edge while live data streams in from the right. + val secs = (elapsedMs / 1000).toInt() + Text( + text = (if (isLiveRecording) "LIVE · " else "") + + String.format("%02d:%02d", secs / 60, secs % 60), + fontFamily = BitchatFontFamily, + color = colorScheme.error, + fontSize = (BASE_FONT_SIZE - 4).sp + ) + Spacer(Modifier.width(12.dp)) + RealtimeScrollingWaveform( + modifier = Modifier.weight(1f).height(22.dp), + amplitudeNorm = normalizeAmplitudeSample(amplitude) + ) + } + } + } + + // MARK: - Action cluster, inside the pill. + // + // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and + // scales between the two, and SizeTransform animates the width change, so typing the + // first character morphs camera+mic into send instead of snapping. + val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) + val latestChannel = rememberUpdatedState(currentChannel) + val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote) + + AnimatedContent( + targetState = hasText, + transitionSpec = { + ( + fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.7f, + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + ).togetherWith( + fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.7f, + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ) + ) + ) using SizeTransform(clip = false) { _, _ -> + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + } + }, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp), + label = "composerActions" + ) { showSend -> + if (showSend) { + SendButton( + isAccented = latestSelectedPeer.value != null || latestChannel.value != null, + onSend = onSend + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showMediaButtons) { + // The camera steps aside while recording so the microphone is the + // only thing that can be released. + AnimatedVisibility( + visible = !isRecording, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandHorizontally( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkHorizontally( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote( + latestSelectedPeer.value, + latestChannel.value, + outPath + ) + } + ) + } + + // The slide-to-cancel target sits well clear of the record + // button (camera's slot plus a gap), rests as a cancel disc, + // leans toward an approaching finger and snaps red on hover. + AnimatedVisibility( + visible = isRecording, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandHorizontally( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkHorizontally( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RecordingCancelButton( + hover = cancelHover, + proximity = cancelProximity, + pull = cancelPull, + onBounds = { cancelBounds = it } + ) + Spacer(Modifier.width(24.dp)) + } + } + + VoiceRecordButton( + isRecording = isRecording, + recorderFactory = recorderFactory?.let { factory -> + { factory(latestSelectedPeer.value, latestChannel.value) } + }, + courtesyActive = selectedPrivatePeer == null && currentChannel == null && + activePublicTalker != null, + shouldCancel = { pos -> + cancelBounds?.inflate(cancelSlackPx)?.contains(pos) == true + }, + onTrackFinger = { cancelFinger = it }, + onStart = { live -> + isRecording = true + isLiveRecording = live + elapsedMs = 0L + // Keep existing focus to avoid IME collapse, but do not + // force-show the keyboard. + if (isFocused.value) { + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + }, + onAmplitude = { amp, ms -> + amplitude = amp + elapsedMs = ms + }, + onFinish = { path -> + isRecording = false + isLiveRecording = false + // Extract and cache the waveform from the actual audio file + // so it matches the receiver's rendering. + AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> + if (arr != null) { + try { + com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) + } catch (_: Exception) {} + } + } + latestOnSendVoiceNote.value( + latestSelectedPeer.value, + latestChannel.value, + path + ) + }, + // Any capture that ends without a file must clear the recording + // state here too, otherwise the pill stays red with a live + // waveform over an empty field. + onCancel = { + isRecording = false + isLiveRecording = false + amplitude = 0 + elapsedMs = 0L + } + ) + } else { + // No media in this context, so keep an inert send button rather than + // leaving a hole where the action cluster should be. + SendButton(isAccented = false, onSend = {}, enabled = false) + } + } + } } } } + + // Auto-stop handled inside VoiceRecordButton +} + +/** + * Slide-to-cancel target shown beside the record button while capturing. It always shows the + * cancel glyph so the destination is unambiguous; as the finger approaches it leans toward + * it (magnetic pull) and blushes red, and on contact it blooms. Release there cancels; + * sliding back out returns to send mode. All motion is spring-driven so it stays fluid. + */ +@Composable +private fun RecordingCancelButton( + hover: Boolean, + proximity: Float, + pull: Offset, + onBounds: (Rect) -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val pullAnim by animateOffsetAsState( + targetValue = pull, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "cancelPull" + ) + val scale by animateFloatAsState( + targetValue = if (hover) 1.28f else 1f + 0.1f * proximity, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "cancelScale" + ) + val container = androidx.compose.ui.graphics.lerp( + palette.inputButton, + colorScheme.error, + if (hover) 1f else proximity * 0.85f + ) + val tint = androidx.compose.ui.graphics.lerp( + colorScheme.onSurfaceVariant, + colorScheme.onError, + if (hover) 1f else proximity * 0.6f + ) + + Box( + modifier = modifier + .onGloballyPositioned { coords -> + onBounds(Rect(coords.localToRoot(Offset.Zero), coords.size.toSize())) + } + .size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .offset { IntOffset(pullAnim.x.roundToInt(), pullAnim.y.roundToInt()) } + .background(container, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Cancel recording", + tint = tint, + modifier = Modifier.size(ComposerIconSize) + ) + } + } +} + +/** + * Send affordance. Only rendered when there is something to send, so its mere presence is the + * signal; it does not need to shout in the terminal's full-brightness green as well. + */ +@Composable +private fun SendButton( + isAccented: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + ComposerActionSurface( + isActive = enabled, + isPressed = isPressed, + // Private chats and channels keep their orange identity, disc and glyph together. + activeColor = if (isAccented) palette.accentOrange else colorScheme.primary, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onSend() } + ) { tint -> + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(id = R.string.send_message), + modifier = Modifier.size(ComposerIconSize), + tint = tint + ) + } } @Composable @@ -286,8 +818,8 @@ fun CommandSuggestionsBox( modifier = modifier .verticalScroll(rememberScrollState()) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> CommandSuggestionItem( @@ -304,13 +836,14 @@ fun CommandSuggestionItem( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + // Roomier rows: at 3.dp vertical these were below a comfortable tap height. + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -324,11 +857,11 @@ fun CommandSuggestionItem( Text( text = allCommands.joinToString(", "), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 2).sp ) // Show syntax if any @@ -336,10 +869,10 @@ fun CommandSuggestionItem( Text( text = syntax, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.8f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = colorScheme.onSurfaceVariant, + fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -347,34 +880,64 @@ fun CommandSuggestionItem( Text( text = suggestion.description, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp, + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun MentionSuggestionsBox( suggestions: List, + mentionPeerIdentities: Map, onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - - Column( + val palette = LocalBitchatPalette.current + + LazyColumn( modifier = modifier + .heightIn(max = MentionSuggestionsMaxHeight) + .animateContentSize( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + .clip(MentionSuggestionsShape) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape), + contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { - suggestions.forEach { suggestion: String -> + items( + items = suggestions, + key = { suggestion -> suggestion.lowercase() } + ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) } + userColor = colorForMention( + mention = suggestion, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + onClick = { onSuggestionClick(suggestion) }, + modifier = Modifier.animateItem( + fadeInSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + placementSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + fadeOutSpec = tween(BitchatMotion.QUICK_MS) + ) ) } } @@ -383,37 +946,76 @@ fun MentionSuggestionsBox( @Composable fun MentionSuggestionItem( suggestion: String, - onClick: () -> Unit + userColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressedBackground by animateColorAsState( + targetValue = if (isPressed) { + userColor.copy(alpha = 0.10f) + } else { + Color.Transparent + }, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "mentionSuggestionPressedBackground" + ) + val pressedScale by animateFloatAsState( + targetValue = if (isPressed) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "mentionSuggestionPressedScale" + ) + Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + .height(MentionSuggestionRowHeight) + .scale(pressedScale) + .background(pressedBackground) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( - text = "@$suggestion", + text = stringResource(R.string.mention_suggestion_at, suggestion), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold ), - color = Color(0xFFFF9500), // Orange like mentions - fontSize = (BASE_FONT_SIZE - 4).sp + color = userColor, + fontSize = (BASE_FONT_SIZE - 2).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - - Spacer(modifier = Modifier.weight(1f)) - + + Spacer(modifier = Modifier.width(12.dp)) + Text( - text = "mention", + text = stringResource(R.string.mention), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textTertiary, + fontSize = (BASE_FONT_SIZE - 4).sp, + maxLines = 1 ) } } + +/** Mention autocomplete stays compact even in crowded channels. */ +internal const val MaxVisibleMentionSuggestions = 5 +private val MentionSuggestionRowHeight = 48.dp +private val MentionSuggestionsVerticalPadding = 6.dp +private val MentionSuggestionsMaxHeight = + (48 * MaxVisibleMentionSuggestions + 12).dp +private val MentionSuggestionsShape = RoundedCornerShape(8.dp) diff --git a/app/src/main/java/com/bitchat/android/ui/LanguagePreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/LanguagePreferenceManager.kt new file mode 100644 index 00000000..91d2719e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LanguagePreferenceManager.kt @@ -0,0 +1,61 @@ +package com.bitchat.android.ui + +import android.content.Context +import androidx.annotation.XmlRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import com.bitchat.android.R +import org.xmlpull.v1.XmlPullParser +import java.util.Locale + +data class AppLanguage( + val languageTag: String, + val endonym: String, +) + +object LanguagePreferenceManager { + fun currentLanguageTag(): String = + AppCompatDelegate.getApplicationLocales().toLanguageTags() + + fun setLanguage(languageTag: String) { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(languageTag) + ) + } + + fun supportedLanguages( + context: Context, + @XmlRes localeConfig: Int = R.xml.locales_config, + ): List = readLanguageTags(context, localeConfig) + .map { languageTag -> + val locale = Locale.forLanguageTag(languageTag) + AppLanguage( + languageTag = languageTag, + endonym = locale.getDisplayName(locale) + .replaceFirstChar { first -> + if (first.isLowerCase()) first.titlecase(locale) else first.toString() + }, + ) + } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.endonym }) + + internal fun readLanguageTags( + context: Context, + @XmlRes localeConfig: Int, + ): List { + val parser = context.resources.getXml(localeConfig) + return buildList { + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "locale") { + parser.getAttributeValue(ANDROID_NAMESPACE, "name") + ?.takeIf(String::isNotBlank) + ?.let(::add) + } + event = parser.next() + } + } + } + + private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android" +} diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 25d00bd7..57710171 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Link import android.content.Intent import android.net.Uri import androidx.compose.foundation.background @@ -7,8 +9,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -16,11 +16,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.net.URL @@ -42,11 +43,8 @@ fun LinkPreviewPill( ) { val context = LocalContext.current val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // iOS-style colors - val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f) - val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f) + val textColor = colorScheme.secondary + val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f) val borderColor = textColor.copy(alpha = 0.3f) // Parse URL for host extraction @@ -86,7 +84,7 @@ fun LinkPreviewPill( Surface( modifier = Modifier.size(60.dp), shape = RoundedCornerShape(8.dp), - color = Color.Blue.copy(alpha = 0.1f) + color = colorScheme.secondaryContainer ) { Box( modifier = Modifier.fillMaxSize(), @@ -94,9 +92,9 @@ fun LinkPreviewPill( ) { Icon( imageVector = Icons.Outlined.Link, - contentDescription = "Link", + contentDescription = stringResource(com.bitchat.android.R.string.cd_link), modifier = Modifier.size(24.dp), - tint = Color.Blue + tint = colorScheme.secondary ) } } @@ -109,7 +107,7 @@ fun LinkPreviewPill( // Title - matches iOS styling Text( text = displayTitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = BASE_FONT_SIZE.sp, fontWeight = FontWeight.SemiBold, color = textColor, @@ -120,7 +118,7 @@ fun LinkPreviewPill( // Host - matches iOS styling Text( text = displayHost, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = textColor.copy(alpha = 0.6f), maxLines = 1, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 79b752ce..2b4d6514 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,517 +1,1148 @@ package com.bitchat.android.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.outlined.BookmarkBorder +import androidx.compose.material.icons.outlined.Public import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NearbyNotesController +import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import kotlinx.coroutines.launch +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashBookmarksStore import com.bitchat.android.geohash.GeohashChannel import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager -import java.util.* +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** - * Location Channels Sheet for selecting geohash-based location channels - * Direct port from iOS LocationChannelsSheet for 100% compatibility + * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. + * Selection dots and row icons sit in this column so every option lines up with About settings. + */ +private val ChannelLeadingSlot = SheetRowLeadingSlot +private val ChannelLeadingGutter = SheetRowLeadingGutter +private val ChannelRowHorizontal = SheetRowHorizontal +private val ChannelRowVertical = SheetRowVertical +private val ChannelDividerInset = SheetRowDividerInset +/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ +private val ChannelSelectedDot = SheetRowSelectedDot + +/** + * Pause between applying a channel selection and dismissing the sheet. + * + * Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than + * answered by the sheet simply disappearing. + */ +private const val SelectionConfirmDelayMs = 180L + +/** + * Location Channels sheet: grouped card rows matching About → Settings. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun LocationChannelsSheet( isPresented: Boolean, onDismiss: () -> Unit, + onLocationNotesClick: () -> Unit, viewModel: ChatViewModel, modifier: Modifier = Modifier ) { val context = LocalContext.current val locationManager = LocationChannelManager.getInstance(context) - - // Observe location manager state - val permissionState by locationManager.permissionState.observeAsState() - val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) - val selectedChannel by locationManager.selectedChannel.observeAsState() - val teleported by locationManager.teleported.observeAsState(false) - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) - - // CRITICAL FIX: Observe reactive participant counts for real-time updates - val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap()) - - // UI state + val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } + val nearbyNotesController = remember { NearbyNotesController.shared } + val notesManager = remember { LocationNotesManager.getInstance() } + + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val notesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle() + val nearbyNotes by notesManager.notes.collectAsStateWithLifecycle() + val nearbyNotesState by notesManager.state.collectAsStateWithLifecycle() + val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() + val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() + val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle() + + val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() + val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() + val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() + val wifiAwareEnabled by WifiAwareController.enabled.collectAsStateWithLifecycle() + var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } - var isInputFocused by remember { mutableStateOf(false) } - - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = isInputFocused - ) + + val teleportToGeohash: (String) -> Unit = { value -> + val channel = channelForManualGeohash(value) + if (channel != null) { + customError = null + locationManager.selectManual(channel) + onDismiss() + } else { + customGeohash = value.trim().lowercase().replace("#", "") + customError = context.getString(R.string.invalid_geohash) + } + } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() - - // Scroll state for LazyColumn + val listState = rememberLazyListState() - - // iOS system colors (matches iOS exactly) + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "topBarAlpha" + ) + + val mapPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH) + if (!gh.isNullOrBlank()) { + teleportToGeohash(gh) + } + } + } + val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue - + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + + val nearbyChannels = remember(availableChannels) { + availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + } + val selectedChannelOutsideNearby = remember(selectedChannel, nearbyChannels) { + selectedLocationChannelOutsideNearby(selectedChannel, nearbyChannels) + } + val showNearbyLoading = nearbyChannels.isEmpty() && + permissionState == LocationChannelManager.PermissionState.AUTHORIZED && + locationServicesEnabled + val locationNotesSubtitle = when { + !notesRevealed -> stringResource(R.string.nearby_notes_reveal) + nearbyNotesState == LocationNotesManager.State.NO_RELAYS -> + stringResource(R.string.location_notes_relays_unavailable) + nearbyNotesState == LocationNotesManager.State.LOADING -> + stringResource(R.string.loading_location_notes) + nearbyNotes.size == 1 -> stringResource(R.string.nearby_notes_one) + nearbyNotes.size > 1 -> + stringResource(R.string.nearby_notes_many, nearbyNotes.size) + else -> stringResource(R.string.location_notes_empty_title) + } + if (isPresented) { - ModalBottomSheet( + BitchatBottomSheet( + modifier = modifier, onDismissRequest = onDismiss, sheetState = sheetState, - modifier = modifier ) { - Column( - modifier = Modifier - .fillMaxWidth() - .then( - if (isInputFocused) { - Modifier.fillMaxHeight().padding(horizontal = 16.dp, vertical = 24.dp) - } else { - Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - } - ), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Header - Text( - text = "#location channels", - fontSize = 18.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ) - - Text( - text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - - // Permission handling - when (permissionState) { - LocationChannelManager.PermissionState.NOT_DETERMINED -> { - Button( - onClick = { locationManager.enableLocationChannels() }, - colors = ButtonDefaults.buttonColors( - containerColor = standardGreen.copy(alpha = 0.12f), - contentColor = standardGreen - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "get location and my geohashes", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - - LocationChannelManager.PermissionState.DENIED, - LocationChannelManager.PermissionState.RESTRICTED -> { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "location permission denied. enable in settings to use location channels.", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - } - ) { - Text( - text = "open settings", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } - - LocationChannelManager.PermissionState.AUTHORIZED -> { - // Authorized - show channels below - } - - null -> { - // Loading state - CircularProgressIndicator() - } + // Selection is applied immediately so the active dot snaps to the new row, then the + // sheet slides away after a beat. Long enough to register the change, short enough + // that it never feels like waiting. + val animatedDismiss = LocalSheetDismiss.current + val confirmSelectionThenDismiss: () -> Unit = { + coroutineScope.launch { + delay(SelectionConfirmDelayMs) + animatedDismiss?.invoke() ?: onDismiss() } - - // Channel list (iOS-style plain list) + } + + Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.weight(1f) + // Edge-to-edge + adjustResize reports the keyboard as WindowInsets.ime — + // without consuming it here the list stays full-height and the teleport + // field can't scroll above the keyboard. + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - // Mesh option first - item { - ChannelRow( - title = meshTitleWithCount(viewModel), - subtitle = "#bluetooth • ${bluetoothRangeString()}", - isSelected = selectedChannel is ChannelID.Mesh, - titleColor = standardBlue, - titleBold = meshCount(viewModel) > 0, - onClick = { - locationManager.select(ChannelID.Mesh) - onDismiss() - } - ) - } - - // Nearby options - if (availableChannels.isNotEmpty()) { - items(availableChannels) { channel -> - val coverage = coverageString(channel.geohash.length) - val nameBase = locationNames[channel.level] - val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } - val subtitlePrefix = "#${channel.geohash} • $coverage" - // CRITICAL FIX: Use reactive participant count from LiveData - val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 - val highlight = participantCount > 0 - - ChannelRow( - title = geohashTitleWithCount(channel, participantCount), - subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), - isSelected = isChannelSelected(channel, selectedChannel), - titleColor = standardGreen, - titleBold = highlight, - onClick = { - // Selecting a suggested nearby channel is not a teleport - locationManager.setTeleported(false) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } + // Mesh section: icon + title header, offline subtitle, then selection card + item(key = "mesh_card") { + Column { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_range, + title = stringResource(R.string.mesh_title), + subtitle = stringResource(R.string.mesh_section_subtitle), + modifier = Modifier.padding(top = 8.dp) ) - } - } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { - item { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape ) { - CircularProgressIndicator(modifier = Modifier.size(16.dp)) - Text( - text = "finding nearby channels…", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + if (wifiAwareEnabled) { + R.string.location_bluetooth_wifi_subtitle + } else { + R.string.location_bluetooth_subtitle + }, + meshRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) + onDismiss() + } ) } } } - - // Custom geohash teleport (iOS-style inline form) - item { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - color = Color.Transparent - ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(1.dp), - verticalAlignment = Alignment.CenterVertically + + item(key = "location_channels_header") { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_globe, + title = stringResource(R.string.location_channels_heading), + subtitle = stringResource(R.string.location_channels_desc), + modifier = Modifier.padding(top = 20.dp) + ) + } + + selectedChannelOutsideNearby?.let { channel -> + item(key = "teleported_card") { + val coverage = coverageString(channel.geohash.length) + val name = bookmarkNames[channel.geohash] + val subtitle = "#${channel.geohash} • $coverage" + + (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + Column { + AboutSectionLabel(text = stringResource(R.string.cd_teleported)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = geohashHashTitleWithCount( + channel.geohash, + participantCount + ), + subtitle = subtitle, + isSelected = true, + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { + bookmarksStore.toggle(channel.geohash) + } + ) + }, + onClick = { + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } + } + } + + if (bookmarks.isNotEmpty()) { + item(key = "bookmarks_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.bookmarked)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + bookmarks.forEachIndexed { index, gh -> + if (index > 0) SheetCardDivider() + val level = levelForLength(gh.length) + val channel = GeohashChannel(level = level, geohash = gh) + val coverage = coverageString(gh.length) + val name = bookmarkNames[gh] + val subtitle = "#$gh • $coverage" + + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[gh] ?: 0 + + ChannelOptionRow( + title = geohashHashTitleWithCount(gh, participantCount), + subtitle = subtitle, + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = true, + onClick = { bookmarksStore.toggle(gh) } + ) + }, + onClick = { + val inRegional = + availableChannels.any { it.geohash == gh } + locationManager.selectManual( + channel = channel, + teleported = !appLocationEnabled || + availableChannels.isEmpty() || + !inRegional + ) + onDismiss() + } + ) + LaunchedEffect(gh) { + bookmarksStore.resolveNameIfNeeded(gh) + } + } + } + } + } + } + } + + if (locationServicesEnabled && + permissionState == LocationChannelManager.PermissionState.DENIED + ) { + item(key = "permissions") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.location_permission_denied), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.error + ) + TextButton( + onClick = { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts( + "package", + context.packageName, + null + ) + } + context.startActivity(intent) + }, + contentPadding = PaddingValues(0.dp) ) { Text( - text = "#", - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = BitchatFontFamily ) - - BasicTextField( - value = customGeohash, - onValueChange = { newValue -> - // iOS-style geohash validation (base32 characters only) + } + } + } + } + + // Nearby location channels and the control for teleporting somewhere new. + item(key = "channels_card") { + Column { + AboutSectionLabel( + text = stringResource(R.string.location_channels_nearby) + ) + if (!appLocationEnabled) { + SheetDestructiveButton( + text = stringResource(R.string.enable_location_services), + isDestructive = false, + onClick = { locationManager.enableLocationServices() }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + bottom = 10.dp + ) + ) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + if (locationServicesEnabled) { + if (nearbyChannels.isNotEmpty()) { + nearbyChannels.forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val coverage = coverageString(channel.geohash.length) + val nameBase = locationNames[channel.level] + val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } + val subtitlePrefix = "#${channel.geohash} • $coverage" + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + ChannelOptionRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { bookmarksStore.toggle(channel.geohash) } + ) + }, + onClick = { + if (locationManager.selectNearby(channel)) { + onDismiss() + } + } + ) + } + SheetCardDivider() + } else if (showNearbyLoading) { + ChannelLoadingRow() + SheetCardDivider() + } + } + + CustomGeohashRow( + customGeohash = customGeohash, + onGeohashChange = { value -> val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() - val filtered = newValue + customGeohash = value .lowercase() .replace("#", "") .filter { it in allowed } .take(12) - - customGeohash = filtered customError = null }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier - .weight(1f) - .onFocusChanged { focusState -> - isInputFocused = focusState.isFocused - if (focusState.isFocused) { - coroutineScope.launch { - sheetState.expand() - // Scroll to bottom to show input and remove button - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } - } - }, - singleLine = true, - decorationBox = { innerTextField -> - if (customGeohash.isEmpty()) { - Text( - text = "geohash", - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ) - } - innerTextField() - } - ) - - val normalized = customGeohash.trim().lowercase().replace("#", "") - val isValid = validateGeohash(normalized) - - // iOS-style teleport button - Button( - onClick = { - if (isValid) { - val level = levelForLength(normalized.length) - val channel = GeohashChannel(level = level, geohash = normalized) - // Mark this selection as a manual teleport - locationManager.setTeleported(true) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } else { - customError = "invalid geohash" - } + onFocusGained = { + coroutineScope.launch { sheetState.expand() } }, - enabled = isValid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "teleport", - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace - ) - // iOS has a face.dashed icon, use closest Material equivalent - Icon( - imageVector = Icons.Filled.PinDrop, - contentDescription = "Teleport", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurface - ) + onOpenMap = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + val initial = when { + normalized.isNotBlank() -> normalized + selectedChannel is ChannelID.Location -> + (selectedChannel as ChannelID.Location).channel.geohash + else -> "" + } + val intent = Intent(context, GeohashPickerActivity::class.java).apply { + putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) + } + mapPickerLauncher.launch(intent) + }, + onTeleport = { + teleportToGeohash(customGeohash) } - } - } - - customError?.let { error -> - Text( - text = error, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red ) } } - } - } - - // Footer action - remove location access - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { - item { - Button( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Red.copy(alpha = 0.08f), - contentColor = Color(0xFFBF1A1A) - ), - modifier = Modifier.fillMaxWidth() + + AnimatedVisibility( + visible = customError != null, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) ) { + // Held across the exit animation: by the time it plays, the error + // itself has already been cleared. + val shownError = remember(customError) { customError ?: "" } Text( - text = "remove location access", + text = shownError, fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily, + color = colorScheme.error, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) ) } } } + + item(key = "location_notes_card") { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = stringResource(R.string.cd_location_notes), + subtitle = locationNotesSubtitle, + isSelected = false, + participantCount = 0, + titleColor = standardGreen, + leadingIconRes = R.drawable.ic_spec_chat_bubbles, + trailingContent = { + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + }, + onClick = { + locationManager.refreshChannels() + nearbyNotesController.reveal() + coroutineScope.launch { + runCatching { sheetState.hide() } + onDismiss() + onLocationNotesClick() + } + } + ) + } + } + + item(key = "tor_routing") { + val torProvider = remember { ArtiTorManager.getInstance() } + val torAvailable = remember { torProvider.isTorAvailable() } + var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) } + + Column { + AboutSectionLabel(text = stringResource(R.string.about_network)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelSettingsToggleRow( + icon = Icons.Filled.Security, + title = stringResource(R.string.location_tor_routing_title), + subtitle = stringResource(R.string.location_tor_routing_desc), + checked = torMode == TorMode.ON, + enabled = torAvailable, + statusIndicator = { + BitchatBadge(text = stringResource(R.string.badge_recommended)) + }, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode) + } + } + ) + } + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) + ) + } + } + } + + if (appLocationEnabled) { + item(key = "location_toggle") { + SheetDestructiveButton( + text = stringResource(R.string.disable_location_services), + isDestructive = true, + onClick = { locationManager.disableLocationServices() }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ) + ) + } + } } + + BitchatSheetTopBar( + onClose = onDismiss, + modifier = modifier.align(Alignment.TopCenter), + title = { + BitchatSheetTitle( + text = stringResource(R.string.location_channels_title) + ) + } + ) } } } - - // Lifecycle management - LaunchedEffect(isPresented) { + + LifecycleResumeEffect(isPresented, appLocationEnabled, systemLocationEnabled) { if (isPresented) { - // Refresh channels when opening - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { - locationManager.refreshChannels() + val currentPermission = locationManager.syncPermissionState() + if (appLocationEnabled && + systemLocationEnabled && + currentPermission == LocationChannelManager.PermissionState.AUTHORIZED + ) { + // Retain the redesign branch's immediate refresh when the sheet resumes while + // honoring main's independent app/system privacy gates. + locationManager.enableLocationChannels() + locationManager.beginLiveRefresh() } - // Begin periodic refresh while sheet is open - locationManager.beginLiveRefresh() - - // Begin multi-channel sampling for counts - val geohashes = availableChannels.map { it.geohash } - viewModel.beginGeohashSampling(geohashes) - } else { + } + + onPauseOrDispose { locationManager.endLiveRefresh() + } + } + + // Sampling management: update sampling when channels/bookmarks change + LaunchedEffect( + isPresented, + availableChannels, + bookmarks, + appLocationEnabled, + notesRevealed + ) { + if (isPresented) { + val liveLocationGeohashes = geohashesForSampling( + availableChannels = availableChannels, + bookmarks = emptyList(), + notesRevealed = notesRevealed, + ) + viewModel.beginGeohashSampling( + liveLocationGeohashes = liveLocationGeohashes, + userSelectedGeohashes = bookmarks + ) + } else { viewModel.endGeohashSampling() } } - - // React to permission changes - LaunchedEffect(permissionState) { - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { - locationManager.refreshChannels() - } + + DisposableEffect(Unit) { + onDispose { viewModel.endGeohashSampling() } } - - // React to available channels changes - LaunchedEffect(availableChannels) { - val geohashes = availableChannels.map { it.geohash } - viewModel.beginGeohashSampling(geohashes) +} + +/** + * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. + * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). + */ +@Composable +private fun ChannelOptionRow( + title: String, + subtitle: String, + isSelected: Boolean, + participantCount: Int, + titleColor: Color? = null, + titleBold: Boolean = false, + leadingIcon: ImageVector? = null, + leadingIconRes: Int? = null, + trailingContent: (@Composable (() -> Unit))? = null, + onClick: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val (baseTitle, countSuffix) = splitTitleAndCount(title) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + when { + isSelected -> { + Box( + modifier = Modifier + .size(ChannelSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } + leadingIcon != null -> { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + leadingIconRes != null -> { + Icon( + painter = painterResource(leadingIconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = baseTitle, + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: colorScheme.onSurface + ) + countSuffix?.let { count -> + AnimatedCountLabel( + count = participantCount, + text = count, + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } + Text( + text = subtitle, + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + + if (trailingContent != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailingContent() + } } } @Composable -private fun ChannelRow( - title: String, - subtitle: String, - isSelected: Boolean, - titleColor: Color? = null, - titleBold: Boolean = false, +private fun ChannelBookmarkButton( + bookmarked: Boolean, onClick: () -> Unit ) { - // iOS-style list row (plain button, no card background) - Surface( - onClick = onClick, - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth() + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Split title to handle count part with smaller font (iOS style) - val (baseTitle, countSuffix) = splitTitleAndCount(title) - - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = baseTitle, - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal, - color = titleColor ?: MaterialTheme.colorScheme.onSurface - ) - - countSuffix?.let { count -> - Text( - text = count, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - } + Icon( + painter = painterResource( + if (bookmarked) { + R.drawable.ic_spec_bookmark_filled + } else { + R.drawable.ic_spec_bookmark_outline } - - Text( - text = subtitle, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - if (isSelected) { - Text( - text = "✔︎", - fontSize = 16.sp, - fontFamily = FontFamily.Monospace, - color = Color(0xFF32D74B) // iOS green for checkmark - ) + ), + contentDescription = stringResource( + if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark + ), + tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } +} + +@Composable +private fun ChannelLoadingRow() { + val colorScheme = MaterialTheme.colorScheme + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.finding_nearby_channels), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } +} + +/** + * Teleport / custom geohash control unified into the same card as channel options. + */ +@Composable +private fun CustomGeohashRow( + customGeohash: String, + onGeohashChange: (String) -> Unit, + onFocusGained: () -> Unit, + onOpenMap: () -> Unit, + onTeleport: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val coroutineScope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val normalized = customGeohash.trim().lowercase().replace("#", "") + val isValid = validateGeohash(normalized) + // Typing the last character of a valid geohash arms the button; cross-fading both the label + // and its container makes that the moment the row confirms the input is usable. + val teleportColor by animateColorAsState( + targetValue = if (isValid) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportLabel" + ) + val teleportContainer by animateColorAsState( + targetValue = if (isValid) { + colorScheme.primary.copy(alpha = 0.16f) + } else { + colorScheme.surfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportContainer" + ) + val teleportInteraction = remember { MutableInteractionSource() } + val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f) + + Row( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PinDrop, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Text( + text = stringResource(R.string.hash_symbol), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) + + Spacer(modifier = Modifier.width(4.dp)) + + BasicTextField( + value = customGeohash, + onValueChange = onGeohashChange, + textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.primary + ), + cursorBrush = SolidColor(colorScheme.primary), + singleLine = true, + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + if (!focusState.isFocused) return@onFocusChanged + onFocusGained() + // Wait until IME insets have landed so the LazyColumn has shrunk; bringIntoView + // against the full-height viewport leaves the field tucked under the keyboard. + // Re-request once the IME animation settles — early insets are still growing. + coroutineScope.launch { + withTimeoutOrNull(750) { + snapshotFlow { imeInsets.getBottom(density) } + .first { it > 0 } + } + bringIntoViewRequester.bringIntoView() + delay(BitchatMotion.STANDARD_MS.toLong()) + bringIntoViewRequester.bringIntoView() + } + }, + decorationBox = { inner -> + if (customGeohash.isEmpty()) { + Text( + text = stringResource(R.string.geohash_placeholder), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) + } + inner() } + ) + + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onOpenMap), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = stringResource(R.string.cd_open_map), + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + + Surface( + onClick = onTeleport, + enabled = isValid, + shape = RoundedCornerShape(8.dp), + color = teleportContainer, + interactionSource = teleportInteraction, + modifier = Modifier.scale(teleportScale) + ) { + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = BitchatFontFamily, + color = teleportColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) } } } -// MARK: - Helper Functions (matching iOS implementation) +/** Settings-toggle row geometry (icon + title/subtitle + switch), local copy for this sheet. */ +@Composable +private fun ChannelSettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else palette.textTertiary, + modifier = Modifier.size(ChannelLeadingSlot) + ) + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) colorScheme.onSurface else palette.textTertiary + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = colorScheme.primary, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +// MARK: - Helper Functions + +/** Separates the channel name from the animated people-count label in composed titles. */ +private const val TITLE_COUNT_SEP = '\u001F' private fun splitTitleAndCount(title: String): Pair { + val sep = title.indexOf(TITLE_COUNT_SEP) + if (sep != -1) { + return Pair(title.substring(0, sep), title.substring(sep + 1).ifEmpty { null }) + } + // Legacy "[n people]" form val lastBracketIndex = title.lastIndexOf('[') return if (lastBracketIndex != -1) { - val prefix = title.substring(0, lastBracketIndex).trim() - val suffix = title.substring(lastBracketIndex) - Pair(prefix, suffix) + val count = title.substring(lastBracketIndex + 1).removeSuffix("]") + Pair(title.substring(0, lastBracketIndex).trim(), count.ifEmpty { null }) } else { Pair(title, null) } } +@Composable private fun meshTitleWithCount(viewModel: ChatViewModel): String { val meshCount = meshCount(viewModel) - val noun = if (meshCount == 1) "person" else "people" - return "mesh [$meshCount $noun]" + val ctx = LocalContext.current + val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) + val meshLabel = stringResource(R.string.mesh_title) + return "$meshLabel$TITLE_COUNT_SEP$peopleText" } private fun meshCount(viewModel: ChatViewModel): Int { - val myID = viewModel.meshService.myPeerID - return viewModel.connectedPeers.value?.count { peerID -> - peerID != myID - } ?: 0 + val myID = viewModel.myPeerID + return viewModel.connectedPeers.value?.count { it != myID } ?: 0 } +@Composable private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String { - val noun = if (participantCount == 1) "person" else "people" - return "${channel.level.displayName.lowercase()} [$participantCount $noun]" + val ctx = LocalContext.current + val isHighPrecision = channel.level.precision > 5 + val peopleText = if (isHighPrecision && participantCount == 0) { + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") + } else { + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) + } + val levelName = when (channel.level) { + GeohashChannelLevel.BUILDING -> "Building" + GeohashChannelLevel.BLOCK -> stringResource(R.string.location_level_block) + GeohashChannelLevel.NEIGHBORHOOD -> stringResource(R.string.location_level_neighborhood) + GeohashChannelLevel.CITY -> stringResource(R.string.location_level_city) + GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province) + GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region) + } + return "$levelName$TITLE_COUNT_SEP$peopleText" +} + +@Composable +private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String { + val ctx = LocalContext.current + val level = levelForLength(geohash.length) + val isHighPrecision = level.precision > 5 + val peopleText = if (isHighPrecision && participantCount == 0) { + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") + } else { + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) + } + return "#$geohash$TITLE_COUNT_SEP$peopleText" } private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean { @@ -521,12 +1152,39 @@ private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelI } } +/** + * Returns the active location channel when it has no row in the nearby-channel list. + * + * This commonly happens after teleporting to a remote geohash. Keeping the selected channel in the + * main channel card makes its selection and bookmark action available even before it is bookmarked. + */ +internal fun selectedLocationChannelOutsideNearby( + selectedChannel: ChannelID?, + nearbyChannels: List +): GeohashChannel? { + val selected = (selectedChannel as? ChannelID.Location)?.channel ?: return null + return selected.takeUnless { active -> + nearbyChannels.any { nearby -> + nearby.geohash.equals(active.geohash, ignoreCase = true) + } + } +} + private fun validateGeohash(geohash: String): Boolean { if (geohash.isEmpty() || geohash.length > 12) return false val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() return geohash.all { it in allowed } } +internal fun channelForManualGeohash(value: String): GeohashChannel? { + val normalized = value.trim().lowercase().replace("#", "") + if (!validateGeohash(normalized)) return null + return GeohashChannel( + level = levelForLength(normalized.length), + geohash = normalized + ) +} + private fun levelForLength(length: Int): GeohashChannelLevel { return when (length) { in 0..2 -> GeohashChannelLevel.REGION @@ -534,12 +1192,12 @@ private fun levelForLength(length: Int): GeohashChannelLevel { 5 -> GeohashChannelLevel.CITY 6 -> GeohashChannelLevel.NEIGHBORHOOD 7 -> GeohashChannelLevel.BLOCK - else -> GeohashChannelLevel.BLOCK + 8 -> GeohashChannelLevel.BUILDING + else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK } } private fun coverageString(precision: Int): String { - // Approximate max cell dimension at equator for a given geohash length val maxMeters = when (precision) { 2 -> 1_250_000.0 3 -> 156_000.0 @@ -552,10 +1210,7 @@ private fun coverageString(precision: Int): String { 10 -> 1.19 else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble()) } - - // Use metric system for simplicity (could be made locale-aware) - val km = maxMeters / 1000.0 - return "~${formatDistance(km)} km" + return "~${formatDistance(maxMeters / 1000.0)} km" } private fun formatDistance(value: Double): String { @@ -566,15 +1221,6 @@ private fun formatDistance(value: Double): String { } } -private fun bluetoothRangeString(): String { - // Approximate Bluetooth LE range for typical mobile devices - return "~10–50 m" -} +private fun meshRangeString(): String = "~10–50m" -private fun formattedNamePrefix(level: GeohashChannelLevel): String { -// return when (level) { -// GeohashChannelLevel.REGION -> "" -// else -> "~" -// } - return "~" -} +private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt new file mode 100644 index 00000000..098f190b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.bitchat.android.R +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.nostr.LocationNotesManager + +/** + * Location Notes button for MainHeader. + * Mesh-only with location authorized. Tor health tints the glyph with muted colours + * and a slow glow while connecting (via [rememberTorConnectionVisual]). + */ +@Composable +fun LocationNotesButton( + viewModel: ChatViewModel, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val context = LocalContext.current + + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val locationManager = remember { LocationChannelManager.getInstance(context) } + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) + + val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED + val locationEnabled = locationPermissionGranted && locationServicesEnabled + + val notesManager = remember { LocationNotesManager.getInstance() } + val notes by notesManager.notes.collectAsStateWithLifecycle() + val notesCount = notes.size + + // Keep the header quiet until there is at least one nearby note worth opening. + if (selectedLocationChannel is ChannelID.Mesh && locationEnabled && notesCount > 0) { + val contentDescription = stringResource(R.string.cd_location_notes) + val torVisual = rememberTorConnectionVisual(normal = colorScheme.primary) + + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center + ) { + TorAwareHeaderIcon( + painter = painterResource(R.drawable.ic_spec_chat_bubbles), + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = contentDescription + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt new file mode 100644 index 00000000..f3c49480 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -0,0 +1,621 @@ +package com.bitchat.android.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NearbyNotesController +import java.text.SimpleDateFormat +import java.util.* +import java.util.Calendar + +/** + * Location Notes Sheet - EXACT iOS UI match for bitchat + * Matches iOS LocationNotesView.swift exactly in style, colors, fonts, and text + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationNotesSheet( + geohash: String, + locationName: String?, + nickname: String?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val colorScheme = MaterialTheme.colorScheme + val accentGreen = colorScheme.primary + + // Managers + val notesManager = remember { LocationNotesManager.getInstance() } + val locationManager = remember { LocationChannelManager.getInstance(context) } + val nearbyNotesController = remember { NearbyNotesController.shared } + + // State + val notes by notesManager.notes.collectAsStateWithLifecycle() + val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE) + val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle() + val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false) + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) + + // SIMPLIFIED: Get count directly from notes list (no separate counter needed) + val count = notes.size + + // Get location name (building or block) - matches iOS locationNames lookup + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() + val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } + ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } + + // Input field state + var draft by remember { mutableStateOf("") } + val sendButtonEnabled = draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS + + // Scroll state + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + // Refresh location when sheet opens + LaunchedEffect(Unit) { + locationManager.refreshChannels() + } + + // Opening the notes sheet is an explicit reveal. The balanced hold lets + // the mesh timeline keep the shared subscription alive after dismissal. + DisposableEffect( + geohash, + locationEnabled, + permissionState, + nearbyNotesController, + ) { + nearbyNotesController.updateAvailability( + locationEnabled = locationEnabled, + locationAuthorized = + permissionState == LocationChannelManager.PermissionState.AUTHORIZED, + buildingGeohash = geohash, + ) + nearbyNotesController.activate() + nearbyNotesController.reveal() + onDispose { + nearbyNotesController.deactivate() + } + } + + BitchatBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + ) { + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + ) { + item(key = "notes_header") { + LocationNotesHeader( + locationName = displayLocationName, + state = state, + accentGreen = accentGreen, + ) + } + + // Notes content (matches iOS notesContent) + when { + state == LocationNotesManager.State.NO_RELAYS -> { + item { + NoRelaysRow( + onRetry = { notesManager.refresh() } + ) + } + } + state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { + item { + LoadingRow() + } + } + notes.isEmpty() -> { + item { + EmptyRow() + } + } + else -> { + items(notes, key = { it.id }) { note -> + NoteRow(note = note) + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + + // Error row (matches iOS errorRow) + errorMessage?.let { error -> + if (state != LocationNotesManager.State.NO_RELAYS) { + item { + ErrorRow( + message = error, + onDismiss = { notesManager.clearError() } + ) + } + } + } + } + + // TopBar (animated) + BitchatSheetTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + title = { + BitchatSheetTitle( + text = pluralStringResource( + id = R.plurals.location_notes_title, + count = count, + geohash, + count + ) + ) + } + ) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ){ + Column { + // Divider before input (matches iOS overlay) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + thickness = 1.dp + ) + + // Input section (matches iOS inputSection) + LocationNotesInputSection( + draft = draft, + onDraftChange = { draft = it }, + sendButtonEnabled = sendButtonEnabled, + accentGreen = accentGreen, + nickname = nickname, + onSend = { + val content = draft.trim() + if (content.isNotEmpty()) { + notesManager.send(content, nickname) + draft = "" + } + } + ) + } + } + } + } +} + +/** + * Header section - matches iOS headerSection exactly + * Shows: "#geohash • X notes", location name, description + */ +@Composable +private fun LocationNotesHeader( + locationName: String?, + state: LocationNotesManager.State, + accentGreen: Color, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + ) { + // Location name in green (building or block) + locationName?.let { name -> + if (name.isNotEmpty()) { + Text( + text = name, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = accentGreen + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // Description + Text( + text = stringResource(R.string.location_notes_description), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + + // Relays paused message if no relays + if (state == LocationNotesManager.State.NO_RELAYS) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location_notes_relays_unavailable), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} + +/** + * Note row - matches iOS noteRow exactly + * Shows @basename then timestamp, then content below + */ +@Composable +private fun NoteRow(note: LocationNotesManager.Note) { + // Extract baseName (before #suffix like iOS) + val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName + val ts = timestampText(note.createdAt) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + // First row: @nickname and timestamp + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "@$baseName", + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + if (ts.isNotEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = ts, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + + // Second row: content + Text( + text = note.content, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +/** + * No relays row - matches iOS noRelaysRow + */ +@Composable +private fun NoRelaysRow(onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Text( + text = stringResource(R.string.location_notes_no_relays_title), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location_notes_no_relays_desc), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.retry), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onRetry) + ) + } +} + +/** + * Loading row - matches iOS loadingRow + */ +@Composable +private fun LoadingRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.loading_location_notes), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } +} + +/** + * Empty row - matches iOS emptyRow + */ +@Composable +private fun EmptyRow() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Text( + text = stringResource(R.string.location_notes_empty_title), + fontFamily = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location_notes_empty_desc), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } +} + +/** + * Error row - matches iOS errorRow + */ +@Composable +private fun ErrorRow(message: String, onDismiss: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "⚠", + fontSize = 12.sp + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = message, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.dismiss), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onDismiss) + ) + } +} + +/** + * Input section - matches main chat input exactly + */ +@Composable +private fun LocationNotesInputSection( + draft: String, + onDraftChange: (String) -> Unit, + sendButtonEnabled: Boolean, + accentGreen: Color, + nickname: String?, + onSend: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = colorScheme.background) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + if (!nickname.isNullOrBlank()) { + val baseName = nickname.split("#", limit = 2).firstOrNull() ?: nickname + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "@$baseName", + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Spacer(modifier = Modifier.height(4.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Text input with placeholder overlay (matches main chat exactly) + Box( + modifier = Modifier.weight(1f) + ) { + androidx.compose.foundation.text.BasicTextField( + value = draft, + onValueChange = onDraftChange, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.primary, + fontFamily = BitchatFontFamily + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Send + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onSend = { if (sendButtonEnabled) onSend() } + ), + modifier = Modifier.fillMaxWidth() + ) + + // Placeholder when empty (matches main chat) + if (draft.isEmpty()) { + Text( + text = stringResource(R.string.location_notes_input_placeholder), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + color = colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Send button - circular with icon (matches main chat exactly) + IconButton( + onClick = { if (sendButtonEnabled) onSend() }, + enabled = sendButtonEnabled, + modifier = Modifier.size(32.dp) + ) { + Box( + modifier = Modifier + .size(30.dp) + .background( + color = if (!sendButtonEnabled) { + colorScheme.onSurface.copy(alpha = 0.3f) + } else { + accentGreen.copy(alpha = 0.75f) + }, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(R.string.send_message), + modifier = Modifier.size(20.dp), + tint = if (!sendButtonEnabled) { + colorScheme.onSurface.copy(alpha = 0.5f) + } else { + colorScheme.onPrimary + } + ) + } + } + } + } +} + +/** + * Timestamp text - matches iOS timestampText exactly + * Shows relative time for < 7 days, absolute date otherwise + */ +private fun timestampText(createdAt: Int): String { + val date = Date(createdAt * 1000L) + val now = Date() + + // Calculate days difference + val calendar = Calendar.getInstance() + calendar.time = date + val dateDay = calendar.get(Calendar.DAY_OF_YEAR) + val dateYear = calendar.get(Calendar.YEAR) + + calendar.time = now + val nowDay = calendar.get(Calendar.DAY_OF_YEAR) + val nowYear = calendar.get(Calendar.YEAR) + + val daysDiff = if (dateYear == nowYear) { + nowDay - dateDay + } else { + // Simplified: just check if less than 7 days by timestamp + val diff = (now.time - date.time) / (1000 * 60 * 60 * 24) + diff.toInt() + } + + return if (daysDiff < 7) { + // Relative formatting (abbreviated) + val diffMillis = now.time - date.time + val diffSeconds = diffMillis / 1000 + + when { + diffSeconds < 60 -> "" // Don't show "just now" in iOS + diffSeconds < 3600 -> { + val minutes = (diffSeconds / 60).toInt() + "${minutes}m ago" + } + diffSeconds < 86400 -> { + val hours = (diffSeconds / 3600).toInt() + "${hours}h ago" + } + else -> { + val days = (diffSeconds / 86400).toInt() + "${days}d ago" + } + } + } else { + // Absolute date formatting + val sameYear = dateYear == nowYear + val formatter = if (sameYear) { + SimpleDateFormat("MMM d", Locale.getDefault()) + } else { + SimpleDateFormat("MMM d, y", Locale.getDefault()) + } + formatter.format(date) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt new file mode 100644 index 00000000..9a8cf9e7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -0,0 +1,157 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.R + +/** + * Presenter component for LocationNotesSheet + * Handles sheet presentation logic with proper error states + * Extracts this logic from ChatScreen for better separation of concerns + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationNotesSheetPresenter( + viewModel: ChatViewModel, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val locationManager = remember { LocationChannelManager.getInstance(context) } + val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + + // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash + val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash + + if (buildingGeohash != null) { + // Get location name from locationManager + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() + val locationName = locationNames[GeohashChannelLevel.BUILDING] + ?: locationNames[GeohashChannelLevel.BLOCK] + + LocationNotesSheet( + geohash = buildingGeohash, + locationName = locationName, + nickname = nickname, + onDismiss = onDismiss + ) + } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && isLoadingLocation) { + LocationNotesAcquiringSheet(onDismiss = onDismiss) + } else { + // No building geohash available - show error state (matches iOS) + LocationNotesErrorSheet( + onDismiss = onDismiss, + locationManager = locationManager + ) + } +} + +/** + * Loading sheet when location is being acquired + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationNotesAcquiringSheet( + onDismiss: () -> Unit +) { + BitchatBottomSheet( + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Acquiring Location", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(24.dp)) + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Please wait while your location is being determined", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Error sheet when location is unavailable + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationNotesErrorSheet( + onDismiss: () -> Unit, + locationManager: LocationChannelManager +) { + BitchatBottomSheet( + onDismissRequest = onDismiss, + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 80.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Location Unavailable", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Location permission is required for notes", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { + // UNIFIED FIX: Enable location services first (user toggle) + locationManager.enableLocationServices() + // Then request location channels (which will also request permission if needed) + locationManager.enableLocationChannels() + locationManager.refreshChannels() + }) { + Text("Enable Location") + } + } + + BitchatSheetTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + title = { + BitchatSheetTitle( + text = stringResource(R.string.cd_location_notes).uppercase() + ) + } + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt new file mode 100644 index 00000000..d5db98bb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -0,0 +1,807 @@ +package com.bitchat.android.ui + +import android.util.Log +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.mesh.PrivateMediaPreparation +import java.util.Date +import java.security.MessageDigest +import java.util.UUID +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class LegacyPrivateMediaConsentRequest( + val requestId: String, + val recipientNickname: String, + val fileName: String, + val warning: String +) + +/** + * Handles media file sending operations (voice notes, images, generic files) + * Separated from ChatViewModel for better separation of concerns + */ +class MediaSendingManager( + private val state: ChatState, + private val messageManager: MessageManager, + private val channelManager: ChannelManager, + private val scope: CoroutineScope, + private val mediaWorkDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val getMeshService: () -> MeshService +) { + // Helper to get current mesh service (may change after panic clear) + private val meshService: MeshService + get() = getMeshService() + companion object { + private const val TAG = "MediaSendingManager" + private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES + private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L + } + + // Track in-flight transfer progress: transferId -> messageId and reverse + private val transferMessageMap = mutableMapOf() + private val messageTransferMap = mutableMapOf() + private val pendingConsentLock = Any() + private val _legacyPrivateMediaConsent = MutableStateFlow(null) + val legacyPrivateMediaConsent: StateFlow = + _legacyPrivateMediaConsent.asStateFlow() + + private data class PendingPrivateMedia( + val request: LegacyPrivateMediaConsentRequest, + val conversationID: String, + val recipientMeshPeerID: String, + val filePacket: BitchatFilePacket, + val filePath: String, + val messageType: BitchatMessageType, + val transferId: String + ) + + private var pendingPrivateMedia: PendingPrivateMedia? = null + + private data class PendingAutomaticPrivateMedia( + val requestId: String, + val conversationID: String, + val recipientMeshPeerID: String, + val filePacket: BitchatFilePacket, + val filePath: String, + val messageType: BitchatMessageType, + val transferId: String, + val allowLegacyFallback: Boolean + ) + + private var pendingAutomaticPrivateMedia: PendingAutomaticPrivateMedia? = null + private var evaluatingAutomaticRequestId: String? = null + private var automaticRetryRequestedFor: String? = null + private var pendingAutomaticTimeoutRequestId: String? = null + + /** + * Enforce the send-size cap with a user-visible failure posted to the + * conversation the user is sending from. Returns true if the file is + * oversized and the send was aborted. + */ + private fun rejectIfOversized( + file: java.io.File, + toPeerIDOrNull: String?, + channelOrNull: String? + ): Boolean { + val size = file.length() + if (size <= MAX_FILE_SIZE) return false + Log.e(TAG, "❌ File too large: $size bytes (max: $MAX_FILE_SIZE)") + val sizeMb = size / (1024 * 1024) + val maxMb = MAX_FILE_SIZE / (1024 * 1024) + val text = "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)" + when { + toPeerIDOrNull != null -> { + val sys = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false + ) + messageManager.addPrivateMessageNoUnread(toPeerIDOrNull, sys) + } + channelOrNull != null -> { + val sys = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false + ) + messageManager.addChannelMessage(channelOrNull, sys) + } + else -> messageManager.addSystemMessage(text) + } + return true + } + + /** + * Send a voice note (audio file) + */ + fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + scope.launch { + sendVoiceNoteAsync(toPeerIDOrNull, channelOrNull, filePath) + } + } + + private suspend fun sendVoiceNoteAsync( + toPeerIDOrNull: String?, + channelOrNull: String?, + filePath: String + ) { + try { + val filePacket = withContext(mediaWorkDispatcher) { + val file = java.io.File(filePath) + if (!file.exists()) { + Log.e(TAG, "Voice note file does not exist") + return@withContext null + } + + if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) { + return@withContext null + } + + BitchatFilePacket( + fileName = file.name, + fileSize = file.length(), + mimeType = "audio/mp4", + content = file.readBytes() + ) + } ?: return + + if (toPeerIDOrNull != null) { + sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio) + } else { + sendPublicFile(channelOrNull, filePacket, filePath, BitchatMessageType.Audio) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to send voice note: ${e.message}") + } + } + + /** + * Send an image file + */ + fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + scope.launch { + sendImageNoteAsync(toPeerIDOrNull, channelOrNull, filePath) + } + } + + private suspend fun sendImageNoteAsync( + toPeerIDOrNull: String?, + channelOrNull: String?, + filePath: String + ) { + try { + val filePacket = withContext(mediaWorkDispatcher) { + val file = java.io.File(filePath) + if (!file.exists()) { + Log.e(TAG, "Image file does not exist") + return@withContext null + } + + if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) { + return@withContext null + } + + BitchatFilePacket( + fileName = file.name, + fileSize = file.length(), + mimeType = "image/jpeg", + content = file.readBytes() + ) + } ?: return + + if (toPeerIDOrNull != null) { + sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image) + } else { + sendPublicFile(channelOrNull, filePacket, filePath, BitchatMessageType.Image) + } + } catch (e: Exception) { + Log.e(TAG, "Image send failed: ${e.message}", e) + } + } + + /** + * Send a generic file + */ + fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + scope.launch { + sendFileNoteAsync(toPeerIDOrNull, channelOrNull, filePath) + } + } + + private suspend fun sendFileNoteAsync( + toPeerIDOrNull: String?, + channelOrNull: String?, + filePath: String + ) { + try { + val filePacket = withContext(mediaWorkDispatcher) { + val file = java.io.File(filePath) + if (!file.exists()) { + Log.e(TAG, "File does not exist") + return@withContext null + } + + if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) { + return@withContext null + } + + // Use the real MIME type based on extension; fallback to octet-stream + val mimeType = try { + com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name) + } catch (_: Exception) { + "application/octet-stream" + } + + // Try to preserve the original file name if our copier prefixed it earlier + val originalName = run { + val name = file.name + val base = name.substringBeforeLast('.') + val ext = name.substringAfterLast('.', "").let { + if (it.isNotBlank()) ".${it}" else "" + } + val stripped = Regex("^send_\\d+_(.+)$") + .matchEntire(base) + ?.groupValues + ?.getOrNull(1) + ?: base + stripped + ext + } + + BitchatFilePacket( + fileName = originalName, + fileSize = file.length(), + mimeType = mimeType, + content = file.readBytes() + ) + } ?: return + + val messageType = when { + filePacket.mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image + filePacket.mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio + else -> BitchatMessageType.File + } + + if (toPeerIDOrNull != null) { + sendPrivateFile(toPeerIDOrNull, filePacket, filePath, messageType) + } else { + sendPublicFile(channelOrNull, filePacket, filePath, messageType) + } + } catch (e: Exception) { + Log.e(TAG, "File send failed: ${e.message}", e) + } + } + + /** + * Send a file privately (encrypted) + */ + private suspend fun sendPrivateFile( + toPeerID: String, + filePacket: BitchatFilePacket, + filePath: String, + messageType: BitchatMessageType + ) { + val payload = withContext(mediaWorkDispatcher) { filePacket.encode() } + ?: run { + Log.e(TAG, "Failed to encode file packet for private send") + return + } + + val transferId = withContext(mediaWorkDispatcher) { + sha256Hex(payload) + } + val recipient = PrivateMediaRecipientResolver.resolve(toPeerID, meshService) + ?: run { + addPrivateMediaSystemMessage( + toPeerID, + "Private media was not sent because this conversation has no active mesh route." + ) + return + } + + val pending = PendingAutomaticPrivateMedia( + requestId = UUID.randomUUID().toString(), + conversationID = recipient.conversationID, + recipientMeshPeerID = recipient.meshPeerID, + filePacket = filePacket, + filePath = filePath, + messageType = messageType, + transferId = transferId, + allowLegacyFallback = false + ) + if (!reserveAutomaticPending(pending)) { + addPrivateMediaSystemMessage( + recipient.conversationID, + "Private media was not sent because another secure media send is still pending." + ) + return + } + evaluateAutomaticPending(pending) + } + + /** + * Consume consent exactly once, then re-run policy and final-packet + * admission. A capability pin appearing while the dialog was open upgrades + * this send to encrypted rather than forcing the legacy path. + */ + fun approveLegacyPrivateMedia(requestId: String) { + scope.launch { + approveLegacyPrivateMediaAsync(requestId) + } + } + + private suspend fun approveLegacyPrivateMediaAsync(requestId: String) { + val pending = consumePendingConsent(requestId) ?: return + val automatic = PendingAutomaticPrivateMedia( + requestId = UUID.randomUUID().toString(), + conversationID = pending.conversationID, + recipientMeshPeerID = pending.recipientMeshPeerID, + filePacket = pending.filePacket, + filePath = pending.filePath, + messageType = pending.messageType, + transferId = pending.transferId, + allowLegacyFallback = true + ) + if (!reserveAutomaticPending(automatic)) { + addPrivateMediaSystemMessage( + pending.conversationID, + "Private media was not sent because another secure media send is still pending." + ) + return + } + evaluateAutomaticPending(automatic) + } + + fun cancelLegacyPrivateMedia(requestId: String) { + consumePendingConsent(requestId) + } + + fun clearPendingPrivateMediaConsent() { + synchronized(pendingConsentLock) { + pendingPrivateMedia = null + pendingAutomaticPrivateMedia = null + evaluatingAutomaticRequestId = null + automaticRetryRequestedFor = null + pendingAutomaticTimeoutRequestId = null + _legacyPrivateMediaConsent.value = null + } + } + + /** Retry the exact first-send intent after peer-state proof or watchdog resolution. */ + fun retryPendingPrivateMedia(peerID: String) { + scope.launch { retryPendingPrivateMediaOnScope(peerID) } + } + + private suspend fun retryPendingPrivateMediaOnScope(peerID: String) { + val pending = synchronized(pendingConsentLock) { + pendingAutomaticPrivateMedia + ?.takeIf { it.recipientMeshPeerID == peerID } + } ?: return + evaluateAutomaticPending(pending) + } + + private suspend fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) { + val acquired = synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) { + return@synchronized false + } + if (evaluatingAutomaticRequestId == pending.requestId) { + automaticRetryRequestedFor = pending.requestId + return@synchronized false + } + evaluatingAutomaticRequestId = pending.requestId + true + } + if (!acquired) return + + while (true) { + val preparation = try { + withContext(mediaWorkDispatcher) { + meshService.prepareFilePrivate( + recipientPeerID = pending.recipientMeshPeerID, + file = pending.filePacket, + transferId = pending.transferId, + allowLegacyFallback = pending.allowLegacyFallback + ) + } + } catch (error: Exception) { + PrivateMediaPreparation.Rejected( + error.message ?: "Secure private-media preparation failed" + ) + } + val stillCurrent = synchronized(pendingConsentLock) { + pendingAutomaticPrivateMedia?.requestId == pending.requestId + } + if (stillCurrent) handlePrivatePreparation(preparation, pending) + + val rerun = synchronized(pendingConsentLock) { + if (evaluatingAutomaticRequestId == pending.requestId) { + evaluatingAutomaticRequestId = null + } + val requested = automaticRetryRequestedFor == pending.requestId && + pendingAutomaticPrivateMedia?.requestId == pending.requestId + if (automaticRetryRequestedFor == pending.requestId) { + automaticRetryRequestedFor = null + } + if (requested) evaluatingAutomaticRequestId = pending.requestId + requested + } + if (!rerun) return + } + } + + private suspend fun handlePrivatePreparation( + preparation: PrivateMediaPreparation, + pending: PendingAutomaticPrivateMedia + ) { + when (preparation) { + is PrivateMediaPreparation.Ready -> { + clearAutomaticPending(pending.requestId) + commitPreparedPrivateFile( + preparation, + pending.conversationID, + pending.recipientMeshPeerID, + pending.filePath, + pending.messageType, + pending.transferId + ) + } + + is PrivateMediaPreparation.RequiresLegacyConsent -> { + clearAutomaticPending(pending.requestId) + if (pending.allowLegacyFallback) { + Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted") + addPrivateMediaSystemMessage( + pending.conversationID, + "Private media was not sent because its security policy changed." + ) + return + } + val nickname = try { + meshService.getPeerNicknames()[pending.recipientMeshPeerID] + } catch (_: Exception) { + null + } ?: pending.recipientMeshPeerID.take(8) + val request = LegacyPrivateMediaConsentRequest( + requestId = UUID.randomUUID().toString(), + recipientNickname = nickname, + fileName = pending.filePacket.fileName, + warning = preparation.warning + ) + synchronized(pendingConsentLock) { + if (pendingPrivateMedia != null) { + Log.w(TAG, "A legacy private-media consent prompt is already pending") + return + } + pendingPrivateMedia = PendingPrivateMedia( + request, + pending.conversationID, + pending.recipientMeshPeerID, + pending.filePacket, + pending.filePath, + pending.messageType, + pending.transferId + ) + _legacyPrivateMediaConsent.value = request + } + } + + PrivateMediaPreparation.NeedsHandshake -> { + ensureAutomaticPendingTimeout(pending) + Log.d(TAG, "Private media needs a Noise handshake; retaining first-send intent") + try { + meshService.initiateNoiseHandshake(pending.recipientMeshPeerID) + } catch (e: Exception) { + Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}") + } + } + + PrivateMediaPreparation.AwaitingPeerState -> { + ensureAutomaticPendingTimeout(pending) + Log.d(TAG, "Private media is waiting for authenticated peer state; first-send intent retained") + } + + is PrivateMediaPreparation.Rejected -> { + clearAutomaticPending(pending.requestId) + Log.w(TAG, "Private media not sent: ${preparation.reason}") + addPrivateMediaSystemMessage( + pending.conversationID, + "Private media was not sent: ${preparation.reason}" + ) + } + } + } + + private fun reserveAutomaticPending(pending: PendingAutomaticPrivateMedia): Boolean = + synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia != null) return@synchronized false + pendingAutomaticPrivateMedia = pending + true + } + + private fun ensureAutomaticPendingTimeout(pending: PendingAutomaticPrivateMedia) { + val shouldStart = synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId != pending.requestId || + pendingAutomaticTimeoutRequestId == pending.requestId + ) return@synchronized false + pendingAutomaticTimeoutRequestId = pending.requestId + true + } + if (!shouldStart) return + scope.launch { + delay(PENDING_PRIVATE_MEDIA_TIMEOUT_MS) + val expired = synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) false + else { + pendingAutomaticPrivateMedia = null + if (automaticRetryRequestedFor == pending.requestId) { + automaticRetryRequestedFor = null + } + if (pendingAutomaticTimeoutRequestId == pending.requestId) { + pendingAutomaticTimeoutRequestId = null + } + true + } + } + if (expired) { + addPrivateMediaSystemMessage( + pending.conversationID, + "Private media was not sent because secure session setup timed out." + ) + } + } + } + + private fun clearAutomaticPending(requestId: String) { + synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId == requestId) { + pendingAutomaticPrivateMedia = null + } + if (automaticRetryRequestedFor == requestId) automaticRetryRequestedFor = null + if (pendingAutomaticTimeoutRequestId == requestId) { + pendingAutomaticTimeoutRequestId = null + } + } + } + + private fun addPrivateMediaSystemMessage(peerID: String, text: String) { + messageManager.addPrivateMessageNoUnread( + peerID, + BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + isPrivate = true, + senderPeerID = peerID + ) + ) + } + + private fun consumePendingConsent(requestId: String): PendingPrivateMedia? { + return synchronized(pendingConsentLock) { + val pending = pendingPrivateMedia + if (pending?.request?.requestId != requestId) return@synchronized null + pendingPrivateMedia = null + _legacyPrivateMediaConsent.value = null + pending + } + } + + private suspend fun commitPreparedPrivateFile( + preparation: PrivateMediaPreparation.Ready, + conversationID: String, + recipientMeshPeerID: String, + filePath: String, + messageType: BitchatMessageType, + transferId: String + ) { + if (preparation.transfer.transferId != transferId) { + Log.e(TAG, "Prepared private-media transfer ID changed; send aborted") + return + } + + val msg = BitchatMessage( + id = UUID.randomUUID().toString().uppercase(), + sender = state.getNicknameValue() ?: "me", + content = filePath, + type = messageType, + timestamp = Date(), + isRelay = false, + isPrivate = true, + recipientNickname = try { + meshService.getPeerNicknames()[recipientMeshPeerID] + } catch (_: Exception) { + null + }, + senderPeerID = meshService.myPeerID + ) + + // Preparation already built and admitted the exact final packet. Map + // progress before commit so the first asynchronous event cannot race us. + if (!messageManager.addPrivateMessageDurably(conversationID, msg, forceRead = true)) { + Log.e(TAG, "Prepared private-media message could not be persisted; send aborted") + addPrivateMediaSystemMessage( + conversationID, + "Private media was not sent because the conversation could not be saved." + ) + return + } + synchronized(transferMessageMap) { + transferMessageMap[transferId] = msg.id + messageTransferMap[msg.id] = transferId + } + messageManager.updateMessageDeliveryStatus( + msg.id, + com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100) + ) + + if (!preparation.transfer.commit()) { + synchronized(transferMessageMap) { + transferMessageMap.remove(transferId) + messageTransferMap.remove(msg.id) + } + messageManager.updateMessageDeliveryStatus( + msg.id, + com.bitchat.android.model.DeliveryStatus.Failed( + "Prepared transfer could not be committed" + ) + ) + Log.w(TAG, "Prepared private-media commit failed; local echo marked failed") + addPrivateMediaSystemMessage( + conversationID, + "Private media was not sent because the prepared transfer could not be committed." + ) + return + } + } + + /** + * Send a file publicly (broadcast or channel) + */ + private suspend fun sendPublicFile( + channelOrNull: String?, + filePacket: BitchatFilePacket, + filePath: String, + messageType: BitchatMessageType + ) { + val payload = withContext(mediaWorkDispatcher) { filePacket.encode() } + ?: run { + Log.e(TAG, "Failed to encode file packet for broadcast send") + return + } + + val transferId = withContext(mediaWorkDispatcher) { + sha256Hex(payload) + } + + val message = BitchatMessage( + id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message + sender = state.getNicknameValue() ?: meshService.myPeerID, + content = filePath, + type = messageType, + timestamp = Date(), + isRelay = false, + senderPeerID = meshService.myPeerID, + channel = channelOrNull + ) + + if (!channelOrNull.isNullOrBlank()) { + channelManager.addChannelMessage(channelOrNull, message, meshService.myPeerID) + } else { + messageManager.addMessage(message) + } + + synchronized(transferMessageMap) { + transferMessageMap[transferId] = message.id + messageTransferMap[message.id] = transferId + } + + // Seed progress so animations start immediately + messageManager.updateMessageDeliveryStatus( + message.id, + com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100) + ) + + withContext(mediaWorkDispatcher) { + meshService.sendFileBroadcast(filePacket) + } + } + + /** + * Cancel a media transfer by message ID + */ + fun cancelMediaSend(messageId: String) { + val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] } + if (transferId != null) { + val cancelled = meshService.cancelFileTransfer(transferId) + if (cancelled) { + // Try to remove cached local file for this message (if any) + runCatching { findMessagePathById(messageId)?.let { java.io.File(it).delete() } } + + // Remove the message from chat upon explicit cancel + messageManager.removeMessageById(messageId) + synchronized(transferMessageMap) { + transferMessageMap.remove(transferId) + messageTransferMap.remove(messageId) + } + } + } + } + + private fun findMessagePathById(messageId: String): String? { + // Search main timeline + state.getMessagesValue().firstOrNull { it.id == messageId }?.content?.let { return it } + // Search private chats + state.getPrivateChatsValue().values.forEach { list -> + list.firstOrNull { it.id == messageId }?.content?.let { return it } + } + // Search channel messages + state.getChannelMessagesValue().values.forEach { list -> + list.firstOrNull { it.id == messageId }?.content?.let { return it } + } + return null + } + + /** + * Update progress for a transfer + */ + fun updateTransferProgress(transferId: String, messageId: String) { + synchronized(transferMessageMap) { + transferMessageMap[transferId] = messageId + messageTransferMap[messageId] = transferId + } + } + + /** + * Handle transfer progress events + */ + fun handleTransferProgressEvent(evt: com.bitchat.android.mesh.TransferProgressEvent) { + val msgId = synchronized(transferMessageMap) { transferMessageMap[evt.transferId] } + if (msgId != null) { + if (evt.failed) { + messageManager.updateMessageDeliveryStatus( + msgId, + com.bitchat.android.model.DeliveryStatus.Failed("transfer could not be sent") + ) + synchronized(transferMessageMap) { + val msgIdRemoved = transferMessageMap.remove(evt.transferId) + if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved) + } + } else if (evt.completed) { + messageManager.updateMessageDeliveryStatus( + msgId, + com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date()) + ) + synchronized(transferMessageMap) { + val msgIdRemoved = transferMessageMap.remove(evt.transferId) + if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved) + } + } else { + messageManager.updateMessageDeliveryStatus( + msgId, + com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(evt.sent, evt.total) + ) + } + } + } + + private fun sha256Hex(bytes: ByteArray): String = try { + val md = MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 30a7ec21..c6f7eb49 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -1,9 +1,11 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.BluetoothMeshDelegate -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.ui.NotificationTextUtils +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.ContactDirectory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Date @@ -20,12 +22,12 @@ class MeshDelegateHandler( private val coroutineScope: CoroutineScope, private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String, - private val getMeshService: () -> BluetoothMeshService + private val getMeshService: () -> MeshService, + private val markMessageReadLocally: (messageID: String) -> Unit = {} ) : BluetoothMeshDelegate { override fun didReceiveMessage(message: BitchatMessage) { coroutineScope.launch { - // FIXED: Deduplicate messages from dual connection paths val messageKey = messageManager.generateMessageKey(message) if (messageManager.isMessageProcessed(messageKey)) { return@launch // Duplicate message, ignore @@ -43,34 +45,51 @@ class MeshDelegateHandler( onHapticFeedback() if (message.isPrivate) { - // Private message - privateChatManager.handleIncomingPrivateMessage(message) - - // Reactive read receipts: Send immediately if user is currently viewing this chat - message.senderPeerID?.let { senderPeerID -> - sendReadReceiptIfFocused(senderPeerID) - } - - // Show notification with enhanced information - now includes senderPeerID - message.senderPeerID?.let { senderPeerID -> - // Use nickname if available, fall back to sender or senderPeerID - val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID - notificationManager.showPrivateMessageNotification( - senderPeerID = senderPeerID, - senderNickname = senderNickname, - messageContent = message.content - ) + if (message.sender == "system") { + // System notices (e.g. "x favorited you"): no unread badge, read receipt or push + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = true) + } else { + // Private message + privateChatManager.handleIncomingPrivateMessage(message) + + // Reactive read receipts: if chat is focused, send immediately for this message + message.senderPeerID?.let { senderPeerID -> + sendReadReceiptIfFocused(message) + } + + // Show notification with enhanced information - now includes senderPeerID + message.senderPeerID?.let { senderPeerID -> + // Use nickname if available, fall back to sender or senderPeerID + val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID + val preview = NotificationTextUtils.buildPrivateMessagePreview(message) + notificationManager.showPrivateMessageNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = preview + ) + } } } else if (message.channel != null) { - // Channel message + // Channel message: AppStateStore is the source of truth for list; only manage unread if (state.getJoinedChannelsValue().contains(message.channel)) { - channelManager.addChannelMessage(message.channel, message, message.senderPeerID) + val channel = message.channel + val viewingClassic = state.getCurrentChannelValue() == channel + val viewingGeohash = try { + if (channel.startsWith("geo:")) { + val geo = channel.removePrefix("geo:") + val selected = state.selectedLocationChannel.value + selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true) + } else false + } catch (_: Exception) { false } + if (!viewingClassic && !viewingGeohash) { + val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap() + currentUnread[channel] = (currentUnread[channel] ?: 0) + 1 + state.setUnreadChannelMessages(currentUnread) + } } } else { - // Public message - messageManager.addMessage(message) - - // Check for mentions in mesh chat + // Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state + // Still run mention detection/notifications checkAndTriggerMeshMentionNotification(message) } @@ -83,95 +102,47 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - state.setConnectedPeers(peers) - state.setIsConnected(peers.isNotEmpty()) - notificationManager.showActiveUserNotification(peers) - // Flush router outbox for any peers that just connected (and their noiseHex aliases) - runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } + processPeerUpdate(peers.distinct()) + } + } - // Clean up channel members who disconnected - channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) + private suspend fun processPeerUpdate(mergedPeers: List) { + state.setConnectedPeers(mergedPeers) + state.setIsConnected(mergedPeers.isNotEmpty()) + + // Flush router outbox for any peers that just connected (and their noiseHex aliases) + runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) } - // Handle chat view migration based on current selection and new peer list - state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> - val isNostrAlias = currentPeer.startsWith("nostr_") - val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) - val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$")) + // Clean up channel members who disconnected + channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID()) - if (isNostrAlias || isNoiseHex) { - // Reverse case: Nostr/offline chat is open, and peer may have come online on mesh. - // Resolve canonical target (prefer connected mesh peer if available) - val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( - selectedPeerID = currentPeer, - connectedPeers = peers, - meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> peers.contains(pid) }, - nostrPubHexForAlias = { alias -> - // Best-effort: derive pub hex from favorites mapping - val prefix = alias.removePrefix("nostr_") - val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } - favs.firstNotNullOfOrNull { rel -> - rel.peerNostrPublicKey?.let { s -> - runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> - if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null - } - } - }?.takeIf { it.startsWith(prefix, ignoreCase = true) } - }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } - ) - if (canonical != currentPeer) { - // Merge conversations and switch selection to the live mesh peer (or noiseHex) - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer)) - state.setSelectedPrivateChatPeer(canonical) - } - } else if (isMeshEphemeral && !peers.contains(currentPeer)) { - // Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex) - val favoriteRel = try { - val info = getPeerInfo(currentPeer) - val noiseKey = info?.noisePublicKey - if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - } else null - } catch (_: Exception) { null } + runCatching { com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() } - if (favoriteRel?.isMutual == true) { - val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - if (noiseHex != currentPeer) { - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( - state = state, - targetPeerID = noiseHex, - keysToMerge = listOf(currentPeer) - ) - state.setSelectedPrivateChatPeer(noiseHex) - } - } else { - privateChatManager.cleanupDisconnectedPeer(currentPeer) - } - } + state.getSelectedPrivateChatPeerValue()?.let { currentPeer -> + val canonical = ContactDirectory.canonicalConversationId(currentPeer) + if (canonical != currentPeer) { + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = canonical, + keysToMerge = ContactDirectory.aliasesForConversation(currentPeer).toList() + ) + state.setSelectedPrivateChatPeer(canonical) } + } - // Global unification: for each connected peer, merge any offline/stable conversations - // (noiseHex or nostr_) into the connected peer's chat so there is only one chat per identity. - peers.forEach { pid -> - try { - val info = getPeerInfo(pid) - val noiseKey = info?.noisePublicKey ?: return@forEach - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } - - // Derive temp nostr key from favorites npub - val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) - val tempNostrKey: String? = try { - if (npub != null) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) - if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null - } else null - } catch (_: Exception) { null } - - unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) - } catch (_: Exception) { } + state.getPrivateChatSheetPeerValue()?.let { sheetPeer -> + val canonical = ContactDirectory.canonicalConversationId(sheetPeer) + if (canonical != sheetPeer) { + state.setPrivateChatSheetPeer(canonical) } } + + mergedPeers.forEach { pid -> + try { + val canonical = ContactDirectory.canonicalConversationId(pid) + unifyChatsIntoPeer(canonical, ContactDirectory.aliasesForConversation(pid).toList()) + } catch (_: Exception) { } + } } /** @@ -198,6 +169,14 @@ class MeshDelegateHandler( messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date())) } } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return channelManager.decryptChannelMessage(encryptedContent, channel) @@ -256,23 +235,55 @@ class MeshDelegateHandler( * Uses same logic as notification system - send read receipt if user is currently * viewing the private chat with this sender AND app is in foreground. */ - private fun sendReadReceiptIfFocused(senderPeerID: String) { + private fun sendReadReceiptIfFocused(message: BitchatMessage) { // Get notification manager's focus state (mirror the notification logic) val isAppInBackground = notificationManager.getAppBackgroundState() val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer() // Send read receipt if user is currently focused on this specific chat - val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID - + val senderPeerID = message.senderPeerID + val senderConversationID = senderPeerID?.let { ContactDirectory.canonicalConversationId(it) } + val focusedConversationID = currentPrivateChatPeer?.let { ContactDirectory.canonicalConversationId(it) } + val shouldSendReadReceipt = !isAppInBackground && + senderConversationID != null && + focusedConversationID == senderConversationID + if (shouldSendReadReceipt) { - android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID") - privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService()) + android.util.Log.d( + "MeshDelegateHandler", + "Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})" + ) + // UI focus is the source of truth for local read state. Transport acceptance is a + // separate fact and may remain retryable when the peer disconnects. + try { markMessageReadLocally(message.id) } catch (_: Exception) { } + + val nickname = state.getNicknameValue().ifBlank { "unknown" } + val mesh = getMeshService() + try { + val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID + ?: senderPeerID.takeIf { + com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it) + } + if (meshPeerID != null && + mesh.getPeerInfo(meshPeerID)?.isConnected == true && + mesh.hasEstablishedSession(meshPeerID) + ) { + mesh.sendReadReceipt(message.id, meshPeerID, nickname) + } + } catch (_: Exception) { } + + // Ensure unread badge is cleared for this peer immediately. + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + val changed = current.remove(senderPeerID) or current.remove(senderConversationID) + if (changed) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } } else { android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } } - - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager /** * Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping) @@ -280,4 +291,5 @@ class MeshDelegateHandler( fun getPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? { return getMeshService().getPeerInfo(peerID) } + } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt new file mode 100644 index 00000000..c6233e1c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -0,0 +1,1977 @@ +package com.bitchat.android.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import android.text.format.DateUtils +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.favorites.FavoriteRelationship +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.colorForPeer +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.nostr.GeohashConversationRegistry +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay + + +/** + * Sheet components for ChatScreen + * Extracted from ChatScreen.kt for better organization + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MeshPeerListSheet( + isPresented: Boolean, + viewModel: ChatViewModel, + onDismiss: () -> Unit, + onShowVerification: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val conversations by viewModel.conversations.collectAsStateWithLifecycle() + val conversationStoreState by viewModel.conversationStoreState.collectAsStateWithLifecycle() + val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle() + val geohashPeopleCount = geohashPeople.size + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } + val directPeerIdentityIDs = remember(peerDirect) { + peerDirect + .asSequence() + .filter { (_, isDirect) -> isDirect } + .mapTo(mutableSetOf()) { (peerID, _) -> peerID.lowercase() } + } + val wifiAwareIdentityIDs = remember(wifiAwarePeerIDs) { + wifiAwarePeerIDs.mapTo(mutableSetOf()) { it.lowercase() } + } + val conversationIdentityAliases = remember(conversations) { + conversations + .flatMapTo(mutableSetOf()) { it.identityAliases } + } + val visibleConnectedPeers = connectedPeers.filterNot { peerID -> + val aliases = runCatching { + ContactDirectory.aliasesForConversation(peerID) + }.getOrDefault(setOf(peerID)) + aliases.any { it.lowercase() in conversationIdentityAliases } + } + var pendingConversationDelete by remember { + mutableStateOf(null) + } + var conversationQuery by rememberSaveable { mutableStateOf("") } + val filteredConversations = remember(conversations, conversationQuery) { + val query = conversationQuery.trim() + if (query.isEmpty()) conversations else conversations.filter { conversation -> + conversation.displayName.contains(query, ignoreCase = true) || + conversation.latestMessagePreview.contains(query, ignoreCase = true) || + conversation.draft?.contains(query, ignoreCase = true) == true + } + } + val onlineConversations = remember(filteredConversations) { + filteredConversations.filter(ConversationSummary::isConnected) + } + val offlineConversations = remember(filteredConversations) { + filteredConversations.filterNot(ConversationSummary::isConnected) + } + val sheetScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + // Bottom sheet state + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + // Scroll state for animated top bar + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + if (isPresented) { + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp) + ) { + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> visibleConnectedPeers.count { it != viewModel.myPeerID } + } + + item(key = "private_conversations_header") { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_envelope, + title = stringResource(R.string.conversations), + modifier = Modifier.padding(top = 8.dp) + ) + } + + if (conversations.size >= CONVERSATION_SEARCH_THRESHOLD) { + item(key = "private_conversations_search") { + OutlinedTextField( + value = conversationQuery, + onValueChange = { conversationQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + placeholder = { + Text( + stringResource(R.string.search_conversations), + fontFamily = BitchatFontFamily + ) + }, + leadingIcon = { + Icon(Icons.Outlined.Search, contentDescription = null) + }, + trailingIcon = if (conversationQuery.isNotEmpty()) { + { + IconButton(onClick = { conversationQuery = "" }) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.clear) + ) + } + } + } else { + null + }, + shape = RoundedCornerShape(14.dp) + ) + } + } + + when { + conversationStoreState is + com.bitchat.android.services.ConversationStoreState.Loading && + conversations.isEmpty() -> { + item(key = "private_conversations_loading") { + ConversationSectionStatus( + icon = { CircularProgressIndicator(Modifier.size(20.dp)) }, + text = stringResource(R.string.loading_conversations) + ) + } + } + + conversationStoreState is + com.bitchat.android.services.ConversationStoreState.Error && + conversations.isEmpty() -> { + item(key = "private_conversations_error") { + ConversationSectionStatus( + icon = { + Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + text = stringResource(R.string.conversation_storage_error) + ) + } + } + + conversations.isEmpty() -> { + item(key = "private_conversations_empty") { + ConversationSectionStatus( + icon = { + Icon( + painterResource(R.drawable.ic_spec_envelope), + contentDescription = null + ) + }, + text = stringResource(R.string.no_conversations_yet) + ) + } + } + + filteredConversations.isEmpty() -> { + item(key = "private_conversations_no_results") { + ConversationSectionStatus( + icon = { + Icon(Icons.Outlined.SearchOff, contentDescription = null) + }, + text = stringResource(R.string.no_conversation_results) + ) + } + } + } + + if (onlineConversations.isNotEmpty()) { + item(key = "private_conversations_online_label") { + ConversationGroupLabel( + text = stringResource(R.string.online_conversations) + ) + } + itemsIndexed( + items = onlineConversations, + key = { _, conversation -> + "conversation:${conversation.conversationID}" + } + ) { index, conversation -> + ConversationSwipeItem( + conversation = conversation, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, + viewModel = viewModel, + isFirst = index == 0, + isLast = index == onlineConversations.lastIndex, + onPrivateChatStart = { conversationID -> + viewModel.showPrivateChatSheet(conversationID) + onDismiss() + }, + onDeleteRequested = { pendingConversationDelete = it }, + onReadStateRequested = { item, isRead -> + sheetScope.launch { + viewModel.setConversationRead(item.conversationID, isRead) + } + }, + modifier = Modifier.animateItem() + ) + } + } + + if (offlineConversations.isNotEmpty()) { + item(key = "private_conversations_offline_label") { + ConversationGroupLabel( + text = stringResource(R.string.offline_conversations) + ) + } + itemsIndexed( + items = offlineConversations, + key = { _, conversation -> + "conversation:${conversation.conversationID}" + } + ) { index, conversation -> + ConversationSwipeItem( + conversation = conversation, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, + viewModel = viewModel, + isFirst = index == 0, + isLast = index == offlineConversations.lastIndex, + onPrivateChatStart = { conversationID -> + viewModel.showPrivateChatSheet(conversationID) + onDismiss() + }, + onDeleteRequested = { pendingConversationDelete = it }, + onReadStateRequested = { item, isRead -> + sheetScope.launch { + viewModel.setConversationRead(item.conversationID, isRead) + } + }, + modifier = Modifier.animateItem() + ) + } + } + + // Channels section + if (joinedChannels.isNotEmpty()) { + item(key = "channels_section") { + Column { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_chat_bubbles, + title = stringResource(R.string.channels), + modifier = Modifier.padding( + top = if (conversations.isNotEmpty()) 20.dp else 8.dp + ) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = MaterialTheme.colorScheme.surface, + shape = AboutCardShape + ) { + Column { + joinedChannels.toList().forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + if (channel.startsWith("@")) { + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + } else { + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + }, + ) + } + } + } + } + } + } + + // People / geohash participants + item(key = "people_section") { + when (selectedLocationChannel) { + is ChannelID.Location -> { + GeohashPeopleList( + viewModel = viewModel, + onTapPerson = onDismiss, + excludedIdentityAliases = conversationIdentityAliases, + modifier = Modifier.padding( + top = if ( + joinedChannels.isNotEmpty() || + conversations.isNotEmpty() + ) 20.dp else 8.dp + ) + ) + } + + else -> { + PeopleSection( + modifier = Modifier.padding( + top = if ( + joinedChannels.isNotEmpty() || + conversations.isNotEmpty() + ) 20.dp else 8.dp + ), + connectedPeers = visibleConnectedPeers, + peerNicknames = peerNicknames, + peerRSSI = peerRSSI, + nickname = nickname, + colorScheme = colorScheme, + selectedPrivatePeer = selectedPrivatePeer, + wifiAwarePeerIDs = wifiAwarePeerIDs, + peopleCount = peopleCount, + excludedIdentityAliases = conversationIdentityAliases, + viewModel = viewModel, + onPrivateChatStart = { peerID -> + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + ) + } + } + } + } + + // TopBar (animated) + BitchatSheetTopBar( + title = { + BitchatSheetTitle(text = stringResource(id = R.string.your_network)) + }, + backgroundAlpha = topBarAlpha, + actions = { + if (selectedLocationChannel !is ChannelID.Location) { + IconButton( + onClick = onShowVerification, + modifier = Modifier.size(44.dp) + ) { + Icon( + imageVector = Icons.Outlined.QrCode, + contentDescription = stringResource(R.string.verify_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + } + }, + onClose = onDismiss, + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) + } + } + + pendingConversationDelete?.let { conversation -> + val deleteFailedMessage = stringResource(R.string.conversation_delete_failed) + val deletedMessage = stringResource( + R.string.conversation_deleted, + conversation.displayName + ) + val undoLabel = stringResource(R.string.undo) + val restoreFailedMessage = + stringResource(R.string.conversation_restore_failed) + AlertDialog( + onDismissRequest = { pendingConversationDelete = null }, + icon = { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null + ) + }, + title = { + Text( + text = stringResource(R.string.delete_conversation_title), + fontFamily = BitchatFontFamily + ) + }, + text = { + Text( + text = stringResource( + R.string.delete_conversation_message, + conversation.displayName + ), + fontFamily = BitchatFontFamily + ) + }, + confirmButton = { + TextButton( + onClick = { + pendingConversationDelete = null + sheetScope.launch { + val deletion = viewModel.deletePrivateConversation( + conversation.conversationID + ) + if (deletion == null) { + snackbarHostState.showSnackbar( + message = deleteFailedMessage + ) + return@launch + } + val result = snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = undoLabel, + withDismissAction = true, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + if (!viewModel.restoreDeletedConversation(deletion)) { + snackbarHostState.showSnackbar( + restoreFailedMessage + ) + } + } + } + } + ) { + Text( + text = stringResource(R.string.delete), + color = MaterialTheme.colorScheme.error, + fontFamily = BitchatFontFamily + ) + } + }, + dismissButton = { + TextButton(onClick = { pendingConversationDelete = null }) { + Text( + text = stringResource(android.R.string.cancel), + fontFamily = BitchatFontFamily + ) + } + } + ) + } + } +} + +/** Icon size for trailing actions on peer rows (matches settings glyph scale). */ +private val PeerRowIconSize = 22.dp +private const val CONVERSATION_SEARCH_THRESHOLD = 8 + +@Composable +private fun ChannelRow( + channel: String, + isSelected: Boolean, + unreadCount: Int, + colorScheme: ColorScheme, + onChannelClick: () -> Unit, + onLeaveChannel: () -> Unit, +) { + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onChannelClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } else if (unreadCount > 0) { + UnreadBadge(count = unreadCount, colorScheme = colorScheme) + } else { + Text( + text = "#", + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = palette.textTertiary + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Text( + text = channel, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + CloseButton(onClick = onLeaveChannel) + } +} + + + +@Composable +fun PeopleSection( + modifier: Modifier = Modifier, + connectedPeers: List, + peerNicknames: Map, + peerRSSI: Map, + nickname: String, + colorScheme: ColorScheme, + selectedPrivatePeer: String?, + wifiAwarePeerIDs: Set = emptySet(), + peopleCount: Int = 0, + excludedIdentityAliases: Set = emptySet(), + viewModel: ChatViewModel, + onPrivateChatStart: (String) -> Unit +) { + val context = LocalContext.current + val identityStateManager = remember(context) { + SecureIdentityStateManager(context.applicationContext) + } + + val palette = LocalBitchatPalette.current + + Column(modifier = modifier) { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, peopleCount) + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical) + ) + } + + // Observe reactive state for favorites and fingerprints + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + + // Reactive favorite computation for all peers + val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + val fingerprint = peerFingerprints[peerID] + if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) + } + } + + // Same "they favorited us" signal the private-chat header uses for orange outline stars. + val peerTheyFavoritedUsStates = remember(peerFavoritedUs, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + val fingerprint = peerFingerprints[peerID] + if (fingerprint != null && peerFavoritedUs.contains(fingerprint)) { + true + } else { + try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.theyFavoritedUs == true + } catch (_: Exception) { + false + } + } + } + } + + val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + } + + // Build mapping of connected peerID -> Noise key hex to unify with offline favorites. + val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> + try { + viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.hexEncodedString() + ?: identityStateManager.getCachedNoiseKey(pid) + } catch (_: Exception) { null } + }.filterValues { it != null }.mapValues { it.value!! } + + val nostrHexByPeerID: Map = connectedPeers.associateWith { pid -> + try { + FavoritesPersistenceService.shared + .findNostrPubkeyForPeerID(pid) + ?.let { ContactIdentityResolver.nostrPubkeyHex(it) } + } catch (_: Exception) { null } + }.filterValues { it != null }.mapValues { it.value!! } + + val connectedNoiseHexes = noiseHexByPeerID.values.map { it.lowercase() }.toSet() + val connectedNostrHexes = nostrHexByPeerID.values.map { it.lowercase() }.toSet() + + fun isFavoriteMappedToConnected(favorite: FavoriteRelationship): Boolean { + val noiseHex = ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey).lowercase() + if (connectedNoiseHexes.contains(noiseHex)) return true + + val nostrHex = favorite.peerNostrPublicKey + ?.let { ContactIdentityResolver.nostrPubkeyHex(it) } + ?.lowercase() + return nostrHex != null && connectedNostrHexes.contains(nostrHex) + } + + Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") + + // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical + val sortedPeers = connectedPeers.sortedWith( + compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first + .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) + .thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first + .thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical + ) + + // Build a map of base name counts across all people shown in the list (connected + offline + nostr) + // Helper to compute display name used for a given key + fun computeDisplayNameForPeerId(key: String): String { + return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12))) + } + + val baseNameCounts = mutableMapOf() + + // Connected peers + sortedPeers.forEach { pid -> + val dn = computeDisplayNameForPeerId(pid) + val (b, _) = splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + + // Offline favorites (exclude ones mapped to connected) + val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites() + offlineFavorites.forEach { fav -> + val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) + if ( + favPeerID.lowercase() !in excludedIdentityAliases && + !isFavoriteMappedToConnected(fav) + ) { + val dn = peerNicknames[favPeerID] ?: fav.peerNickname + val (b, _) = splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + } + + // Every row this card will show, in final order, so the animated list can key on identity + // and animate reordering. Offline favourites are appended after the connected peers. + // Collected once for the whole card rather than once per row. + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() + + val offlineFavoriteRows = offlineFavorites.filterNot { favorite -> + val favoriteNoiseKey = ContactIdentityResolver.noiseKeyHex( + favorite.peerNoisePublicKey + ) + favoriteNoiseKey.lowercase() in excludedIdentityAliases || + isFavoriteMappedToConnected(favorite) + } + val rowKeys: List = sortedPeers + + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } + + AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey -> + Column { + if (rowIndex > 0) SheetCardDivider() + val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey } + if (connectedPeerForRow != null) { + val peerID = connectedPeerForRow + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val isFavorite = peerFavoriteStates[peerID] ?: false + val theyFavoritedUs = peerTheyFavoritedUsStates[peerID] ?: false + val isVerified = peerVerifiedStates[peerID] ?: false + // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below + + val noiseHex = noiseHexByPeerID[peerID] + val meshUnread = hasUnreadPrivateMessages.contains(conversationID) || hasUnreadPrivateMessages.contains(peerID) + val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false + val combinedHasUnread = meshUnread || nostrUnread + val combinedUnreadCount = ( + privateChats[conversationID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0 + ) + ( + if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0 + ) + + val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))) + val (bName, _) = splitSuffix(displayName) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } + PeerItem( + peerID = peerID, + displayName = displayName, + isDirect = isDirectLive, + isWifiAware = peerID in wifiAwarePeerIDs, + isConnected = true, + isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer, + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, + isVerified = isVerified, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(peerID) }, + unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0, + showNostrGlobe = false, + showHashSuffix = showHash + ) + } else { + // Offline favourite: still worth showing, reachable over Nostr. + val fav = offlineFavoriteRows.first { + ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey + } + val favPeerID = rowKey + + val nostrConvKey: String? = try { + FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) + ?.let { ContactIdentityResolver.nostrAliasForPubkey(it) } + } catch (_: Exception) { null } + + val conversationID = ContactDirectory.canonicalConversationId(favPeerID) + val hasUnread = hasUnreadPrivateMessages.contains(conversationID) || + hasUnreadPrivateMessages.contains(favPeerID) || + (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) + + val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key + val dn = peerNicknames[favPeerID] ?: fav.peerNickname + val (bName, _) = splitSuffix(dn) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints) + + val unreadCount = ( + privateChats[conversationID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(conversationID) } ?: 0 + ) + ( + if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 + ) + + PeerItem( + peerID = favPeerID, + displayName = dn, + isDirect = false, + isConnected = false, + isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, + isFavorite = true, + theyFavoritedUs = fav.theyFavoritedUs, + isVerified = isVerified, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, + unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, + showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), + showHashSuffix = showHash + ) + } + } + } + } + } + + } +} + +@Composable +private fun ConversationSectionStatus( + icon: @Composable () -> Unit, + text: String +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = MaterialTheme.colorScheme.surface, + shape = AboutCardShape + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + icon() + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun ConversationGroupLabel(text: String) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding + 4.dp) + .padding(top = 12.dp, bottom = 6.dp), + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationSwipeItem( + conversation: ConversationSummary, + directPeerIdentityIDs: Set, + wifiAwareIdentityIDs: Set, + viewModel: ChatViewModel, + isFirst: Boolean, + isLast: Boolean, + onPrivateChatStart: (String) -> Unit, + onDeleteRequested: (ConversationSummary) -> Unit, + onReadStateRequested: (ConversationSummary, Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val hapticFeedback = LocalHapticFeedback.current + val deleteDescription = stringResource(R.string.delete_conversation_action) + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val favoriteTargetID = conversation.connectedPeerID ?: conversation.conversationID + val favoriteRelationship = remember( + conversation.identityAliases, + favoritePeers, + peerFavoritedUs + ) { + conversation.identityAliases + .asSequence() + .mapNotNull { alias -> + runCatching { + FavoritesPersistenceService.shared.getFavoriteStatus(alias) + }.getOrNull() + } + .firstOrNull() + } + val fingerprint = conversation.connectedPeerID + ?.let(peerFingerprints::get) + ?: conversation.identityAliases + .asSequence() + .mapNotNull(peerFingerprints::get) + .firstOrNull() + ?: ContactIdentityResolver.fingerprintFromContactConversationId( + conversation.conversationID + ) + ?: favoriteRelationship?.peerNoisePublicKey?.let { + ContactIdentityResolver.fingerprintHex(it) + } + val isFavorite = if (fingerprint != null) { + fingerprint in favoritePeers + } else { + viewModel.isFavorite(favoriteTargetID) + } + val theyFavoritedUs = + (fingerprint != null && fingerprint in peerFavoritedUs) || + favoriteRelationship?.theyFavoritedUs == true + val isVerified = fingerprint != null && fingerprint in verifiedFingerprints + val dismissState = rememberSwipeToDismissBoxState() + val shape = RoundedCornerShape( + topStart = if (isFirst) 14.dp else 0.dp, + topEnd = if (isFirst) 14.dp else 0.dp, + bottomStart = if (isLast) 14.dp else 0.dp, + bottomEnd = if (isLast) 14.dp else 0.dp + ) + + LaunchedEffect(dismissState.currentValue, conversation.conversationID) { + when (dismissState.currentValue) { + SwipeToDismissBoxValue.StartToEnd -> { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onReadStateRequested(conversation, conversation.unreadCount > 0) + dismissState.reset() + } + + SwipeToDismissBoxValue.EndToStart -> { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onDeleteRequested(conversation) + dismissState.reset() + } + + SwipeToDismissBoxValue.Settled -> Unit + } + } + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .clip(shape), + color = colorScheme.surface, + shape = shape + ) { + Column { + if (!isFirst) SheetCardDivider() + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = true, + enableDismissFromEndToStart = true, + backgroundContent = { + val markRead = conversation.unreadCount > 0 + val startToEnd = + dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd + Row( + modifier = Modifier + .fillMaxSize() + .background( + if (startToEnd) { + colorScheme.secondaryContainer + } else { + colorScheme.errorContainer + } + ) + .padding(horizontal = SheetRowHorizontal), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (startToEnd) { + Arrangement.Start + } else { + Arrangement.End + } + ) { + Icon( + imageVector = if (startToEnd) { + if (markRead) Icons.Outlined.MarkEmailRead + else Icons.Outlined.MarkEmailUnread + } else { + Icons.Outlined.Delete + }, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (startToEnd) { + stringResource( + if (markRead) R.string.mark_read else R.string.mark_unread + ) + } else { + stringResource(R.string.delete) + }, + style = MaterialTheme.typography.labelMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold + ) + ) + } + } + ) { + ConversationRow( + conversation = conversation, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, + viewModel = viewModel, + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, + isVerified = isVerified, + deleteDescription = deleteDescription, + onClick = { onPrivateChatStart(conversation.conversationID) }, + onTogglePinned = { + viewModel.toggleConversationPinned(conversation.conversationID) + }, + onToggleMuted = { + viewModel.toggleConversationMuted(conversation.conversationID) + }, + onReadStateRequested = { + onReadStateRequested(conversation, conversation.unreadCount > 0) + }, + onDeleteRequested = { onDeleteRequested(conversation) } + ) + } + } + } +} + +@Composable +private fun ConversationRow( + conversation: ConversationSummary, + directPeerIdentityIDs: Set, + wifiAwareIdentityIDs: Set, + viewModel: ChatViewModel, + isFavorite: Boolean, + theyFavoritedUs: Boolean, + isVerified: Boolean, + deleteDescription: String, + onClick: () -> Unit, + onTogglePinned: () -> Unit, + onToggleMuted: () -> Unit, + onReadStateRequested: () -> Unit, + onDeleteRequested: () -> Unit +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + var showActions by remember { mutableStateOf(false) } + val liveIdentityIDs = conversation.identityAliases + + listOfNotNull(conversation.connectedPeerID?.lowercase()) + val isWifiAware = liveIdentityIDs.any(wifiAwareIdentityIDs::contains) + val isDirect = liveIdentityIDs.any(directPeerIdentityIDs::contains) || + conversation.connectedPeerID?.let { peerID -> + runCatching { + viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true + }.getOrDefault(false) + } == true + val connectionDescription = meshConnectionDescription( + isWifiAware = isWifiAware, + isDirect = isDirect + ) + val basePreview = when { + !conversation.draft.isNullOrBlank() -> stringResource( + R.string.conversation_draft_preview, + conversation.draft + ) + conversation.latestMessageType == BitchatMessageType.Image -> + stringResource(R.string.notification_sent_image) + conversation.latestMessageType == BitchatMessageType.Audio -> + stringResource(R.string.notification_sent_voice) + conversation.latestMessageType == BitchatMessageType.File -> + conversation.latestMessagePreview + .takeIf(String::isNotBlank) + ?.let { "📎 $it" } + ?: stringResource(R.string.notification_sent_file) + else -> conversation.latestMessagePreview.ifBlank { "…" } + } + val messagePreview = if ( + conversation.latestMessageIsOutgoing && conversation.draft.isNullOrBlank() + ) { + stringResource(R.string.conversation_you_preview, basePreview) + } else { + basePreview + } + val presenceDescription = when { + conversation.isConnected -> connectionDescription + conversation.transport == DirectMessageTransport.NOSTR -> + stringResource(R.string.offline_reachable_via_nostr) + else -> stringResource(R.string.offline_not_in_mesh) + } + val peerIdentity = conversation.nostrPubkey + ?.let(viewModel::peerIdentityForNostrPubkey) + ?: viewModel.peerIdentityForMeshPeer(conversation.conversationID) + val assignedColor = colorForPeer(peerIdentity, palette) + val (baseNameRaw, suffix) = splitSuffix(conversation.displayName) + val relativeTime by produceState( + initialValue = conversationRelativeTime(conversation.latestMessageAt), + conversation.latestMessageAt + ) { + while (true) { + value = conversationRelativeTime(conversation.latestMessageAt) + delay(DateUtils.MINUTE_IN_MILLIS) + } + } + val unreadDescription = if (conversation.unreadCount > 0) { + stringResource(R.string.conversation_unread_count, conversation.unreadCount) + } else { + stringResource(R.string.conversation_read) + } + val pinDescription = stringResource( + if (conversation.isPinned) R.string.unpin_conversation else R.string.pin_conversation + ) + val muteDescription = stringResource( + if (conversation.isMuted) R.string.unmute_conversation else R.string.mute_conversation + ) + val readActionDescription = stringResource( + if (conversation.unreadCount > 0) R.string.mark_read else R.string.mark_unread + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .background(colorScheme.surface) + .semantics(mergeDescendants = true) { + contentDescription = listOf( + conversation.displayName, + unreadDescription, + messagePreview, + relativeTime, + presenceDescription + ).joinToString(", ") + stateDescription = presenceDescription + customActions = listOf( + CustomAccessibilityAction(readActionDescription) { + onReadStateRequested() + true + }, + CustomAccessibilityAction(pinDescription) { + onTogglePinned() + true + }, + CustomAccessibilityAction(muteDescription) { + onToggleMuted() + true + }, + CustomAccessibilityAction(deleteDescription) { + onDeleteRequested() + true + } + ) + } + .clickable(onClick = onClick) + .padding( + horizontal = SheetRowHorizontal, + vertical = 10.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + PeerAvatar( + name = baseNameRaw, + color = assignedColor, + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, + isVerified = isVerified, + badge = { + when { + conversation.isConnected -> Icon( + painter = painterResource( + conversationTransportIcon( + isReachedOverInternet = false, + isWifiAware = isWifiAware, + isDirect = isDirect + ) + ), + contentDescription = connectionDescription, + modifier = Modifier.size(13.dp), + tint = colorScheme.primary + ) + + conversation.transport == DirectMessageTransport.NOSTR -> Icon( + painter = painterResource(R.drawable.ic_spec_globe), + contentDescription = stringResource( + R.string.offline_reachable_via_nostr + ), + modifier = Modifier.size(13.dp), + tint = palette.accentPurple + ) + + else -> Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.offline_not_in_mesh), + modifier = Modifier.size(11.dp), + tint = palette.textTertiary + ) + } + } + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = truncateNickname(baseNameRaw), + style = MaterialTheme.typography.bodyLarge.copy( + fontFamily = BitchatFontFamily, + fontWeight = if (conversation.unreadCount > 0) { + FontWeight.Bold + } else { + FontWeight.Medium + } + ), + color = assignedColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (suffix.isNotEmpty()) { + Text( + text = suffix, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium + ), + color = assignedColor.copy(alpha = SUFFIX_ALPHA) + ) + } + if (conversation.isPinned) { + Icon( + Icons.Filled.PushPin, + contentDescription = pinDescription, + modifier = Modifier.size(14.dp), + tint = palette.textTertiary + ) + } + if (conversation.isMuted) { + Icon( + Icons.Outlined.NotificationsOff, + contentDescription = muteDescription, + modifier = Modifier.size(14.dp), + tint = palette.textTertiary + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = messagePreview, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily, + fontWeight = if (!conversation.draft.isNullOrBlank()) { + FontWeight.Medium + } else { + FontWeight.Normal + } + ), + color = if (!conversation.draft.isNullOrBlank()) { + palette.accentOrange + } else { + palette.textTertiary + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Text( + text = stringResource( + R.string.conversation_preview_timestamp, + relativeTime + ), + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = BitchatFontFamily + ), + color = palette.textTertiary, + maxLines = 1 + ) + } + } + + UnreadBadge( + count = conversation.unreadCount, + colorScheme = colorScheme, + modifier = Modifier.padding(start = 4.dp) + ) + + Box { + IconButton( + onClick = { showActions = true }, + modifier = Modifier.size(48.dp) + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.conversation_actions), + tint = palette.textTertiary + ) + } + DropdownMenu( + expanded = showActions, + onDismissRequest = { showActions = false } + ) { + DropdownMenuItem( + text = { Text(pinDescription) }, + leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, + onClick = { + showActions = false + onTogglePinned() + } + ) + DropdownMenuItem( + text = { Text(muteDescription) }, + leadingIcon = { + Icon(Icons.Outlined.NotificationsOff, contentDescription = null) + }, + onClick = { + showActions = false + onToggleMuted() + } + ) + DropdownMenuItem( + text = { Text(readActionDescription) }, + leadingIcon = { + Icon( + if (conversation.unreadCount > 0) { + Icons.Outlined.MarkEmailRead + } else { + Icons.Outlined.MarkEmailUnread + }, + contentDescription = null + ) + }, + onClick = { + showActions = false + onReadStateRequested() + } + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.delete), + color = colorScheme.error + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.Delete, + contentDescription = null, + tint = colorScheme.error + ) + }, + onClick = { + showActions = false + onDeleteRequested() + } + ) + } + } + } +} + +private fun conversationRelativeTime(timestamp: Long): String = + DateUtils.getRelativeTimeSpanString( + timestamp, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + +@Composable +private fun PeerItem( + peerID: String, + displayName: String, + isDirect: Boolean, + isWifiAware: Boolean = false, + isConnected: Boolean = true, + isSelected: Boolean, + isFavorite: Boolean, + theyFavoritedUs: Boolean = false, + isVerified: Boolean, + colorScheme: ColorScheme, + viewModel: ChatViewModel, + onItemClick: () -> Unit, + unreadCount: Int = 0, + showNostrGlobe: Boolean = false, + showHashSuffix: Boolean = true +) { + val currentNickname by viewModel.nickname.collectAsStateWithLifecycle() + val connectionDescription = meshConnectionDescription( + isWifiAware = isWifiAware, + isDirect = isDirect + ) + // Split display name for hashtag suffix support (iOS-compatible) + val (baseNameRaw, suffixRaw) = splitSuffix(displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = if (showHashSuffix) suffixRaw else "" + val isMe = displayName == "You" || peerID == currentNickname + + // Get consistent peer color (iOS-compatible) + val palette = LocalBitchatPalette.current + val assignedColor = colorForPeer( + viewModel.peerIdentityForMeshPeer(peerID), + palette + ) + val baseColor = if (isMe) palette.accentOrange else assignedColor + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onItemClick) + .padding(horizontal = SheetRowHorizontal, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PeerAvatar( + name = baseNameRaw, + color = baseColor, + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, + isVerified = isVerified, + badge = { + when { + isConnected -> Icon( + painter = painterResource( + conversationTransportIcon( + isReachedOverInternet = false, + isWifiAware = isWifiAware, + isDirect = isDirect + ) + ), + contentDescription = connectionDescription, + modifier = Modifier.size(13.dp), + tint = colorScheme.primary + ) + + showNostrGlobe -> Icon( + painter = painterResource(R.drawable.ic_spec_globe), + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(13.dp), + tint = palette.accentPurple + ) + + else -> Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.cd_offline_favorite), + modifier = Modifier.size(11.dp), + tint = palette.textTertiary + ) + } + } + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = baseName, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (suffix.isNotEmpty()) { + Text( + text = suffix, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) + ) + } + } + + UnreadBadge( + count = unreadCount, + colorScheme = colorScheme, + modifier = Modifier.padding(start = 4.dp) + ) + + if (isSelected) { + Box( + modifier = Modifier + .padding(start = 8.dp) + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } + } +} + +@Composable +private fun meshConnectionDescription( + isWifiAware: Boolean, + isDirect: Boolean +): String = stringResource( + when { + isWifiAware -> R.string.cd_direct_wifi_aware + isDirect -> R.string.cd_direct_bluetooth + else -> R.string.cd_routed_mesh + } +) + +/** + * Reusable unread badge component for both channels and private messages + */ +@Composable +private fun UnreadBadge( + count: Int, + colorScheme: ColorScheme, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + // Scale/fade in and out so a badge appearing while the sheet is open is noticed, and one + // clearing does not just blink away. + AnimatedVisibility( + visible = count > 0, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.5f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.5f, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ), + modifier = modifier + ) { + Box( + modifier = Modifier + .background( + color = palette.accentOrange, + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp), + contentAlignment = Alignment.Center + ) { + AnimatedCountLabel( + count = count, + text = if (count > 99) "99+" else count.toString(), + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = BitchatFontFamily, + color = Color.Black + ) + } + } +} + +/** + * Convert RSSI value (dBm) to signal strength percentage (0-100) + * RSSI typically ranges from -30 (excellent) to -100 (very poor) + * Maps to 0-100 scale where: + * - 0-32: No signal (0 bars) + * - 33-65: Weak (1 bar) + * - 66-98: Good (2 bars) + * - 99-100: Excellent (3 bars) + */ +private fun convertRSSIToSignalStrength(rssi: Int?): Int { + if (rssi == null) return 0 + + return when { + rssi >= -40 -> 100 // Excellent signal + rssi >= -55 -> 85 // Very good signal + rssi >= -70 -> 70 // Good signal + rssi >= -85 -> 50 // Fair signal + rssi >= -100 -> 25 // Poor signal + else -> 0 // Very poor or no signal + } +} + +/** + * Nested Private Chat Sheet - iOS-style nested bottom sheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivateChatSheet( + isPresented: Boolean, + peerID: String, + viewModel: ChatViewModel, + onDismiss: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val contactResolution = remember(peerID, connectedPeers, favoritePeers) { + ContactDirectory.resolve(peerID) + } + val activeMeshPeerID = contactResolution.meshPeerID + val isWifiAware = activeMeshPeerID in wifiAwareConnected.keys || peerID in wifiAwareConnected.keys + + // Start private chat when screen opens + LaunchedEffect(peerID) { + viewModel.startPrivateChat(peerID) + } + + val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") + val favoriteRelationship = remember(peerID, favoritePeers, peerFavoritedUs) { + try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + } + val isDirect = activeMeshPeerID?.let { peerDirectMap[it] } == true || peerDirectMap[peerID] == true + val isConnected = activeMeshPeerID?.let { connectedPeers.contains(it) } == true || connectedPeers.contains(peerID) || isDirect + val isNostrReachableFavorite = + !isConnected && favoriteRelationship?.isMutual == true && favoriteRelationship.peerNostrPublicKey != null + + // Compute display name and title text reactively + val displayName = remember(peerID, peerNicknames, favoriteRelationship) { + peerNicknames[peerID] + ?: activeMeshPeerID?.let { peerNicknames[it] } + ?: contactResolution.displayName + ?: favoriteRelationship?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + ?: viewModel.resolvePeerDisplayNameForFingerprint(peerID) + } + val titleText = remember(peerID, peerNicknames, favoriteRelationship) { + if (isNostrPeer) { + val gh = GeohashConversationRegistry.get(peerID) ?: "geohash" + val fullPubkey = GeohashAliasRegistry.get(peerID) ?: "" + val name = if (fullPubkey.isNotEmpty()) { + viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) + } else { + peerNicknames[peerID] ?: "Unknown" + } + "#$gh/@$name" + } else { + displayName + } + } + + val conversationID = contactResolution.conversationID + val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList() + val sessionState = resolveConversationSessionState( + conversationID = peerID, + activeMeshPeerID = activeMeshPeerID, + peerSessionStates = peerSessionStates + ) + val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] } + ?: peerFingerprints[peerID] + ?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID) + val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) { + if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) + } + val theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) { + (fingerprint != null && peerFavoritedUs.contains(fingerprint)) || + favoriteRelationship?.theyFavoritedUs == true + } + + // Celebrate being favorited: a springy wobble of the header star. Springs rather than + // keyframed tweens, matching the app's press feedback, so the settle overshoots slightly. + val starWobbleRotation = remember { Animatable(0f) } + val starWobbleScale = remember { Animatable(1f) } + var previousTheyFavoritedUs by remember { mutableStateOf(null) } + LaunchedEffect(theyFavoritedUs) { + val wasFavoritedUs = previousTheyFavoritedUs + previousTheyFavoritedUs = theyFavoritedUs + if (theyFavoritedUs && wasFavoritedUs == false) { + starWobbleRotation.snapTo(-16f) + starWobbleScale.snapTo(1.35f) + launch { + starWobbleRotation.animateTo( + targetValue = 0f, + animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessMedium) + ) + } + launch { + starWobbleScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ) + ) + } + } + } + + val isVerified = remember(peerID, verifiedFingerprints) { + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + + val palette = LocalBitchatPalette.current + // Three-state star: grey outline (no relation), orange outline (they favorited us), + // filled orange (we favorited them, mutual or not). + val favoriteStarTint by animateColorAsState( + targetValue = when { + isFavorite || theyFavoritedUs -> palette.accentOrange + else -> colorScheme.onSurfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "favoriteStarTint" + ) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + if (isPresented) { + BitchatBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.height(ChatHeaderHeight)) + + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + + // Messages list + var forceScrollToBottom by remember { mutableStateOf(false) } + var isScrolledUp by remember { mutableStateOf(false) } + + MessagesList( + messages = messages, + currentUserNickname = nickname, + meshService = viewModel.meshServiceFacade, + modifier = Modifier.weight(1f), + conversationKey = "dm:$peerID", + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { /* handle mention */ }, + onMessageLongPress = { /* handle long press */ }, + onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) }, + onImageClick = { _, _, _ -> /* handle image click */ } + ) + + // Input section. No divider here: ChatInputSection draws its own fade and + // hairline. + var messageText by remember(peerID) { + mutableStateOf( + androidx.compose.ui.text.input.TextFieldValue( + viewModel.conversationDraft(peerID) + ) + ) + } + + ChatInputSection( + messageText = messageText, + onMessageTextChange = { newText -> + messageText = newText + viewModel.setConversationDraft(peerID, newText.text) + // Do not update the shared suggestion state here: this sheet + // renders its own popups as hidden, so an update only leaves + // a stale popup behind for the main composer. + }, + onSend = { + if (messageText.text.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.text.trim()) { accepted -> + if (accepted) { + messageText = + androidx.compose.ui.text.input.TextFieldValue("") + viewModel.setConversationDraft(peerID, "") + forceScrollToBottom = !forceScrollToBottom + } + } + } + }, + onSendVoiceNote = { peer, channel, path -> + viewModel.sendVoiceNote(peer, channel, path) + }, + onSendImageNote = { peer, channel, path -> + viewModel.sendImageNote(peer, channel, path) + }, + onSendFileNote = { peer, channel, path -> + viewModel.sendFileNote(peer, channel, path) + }, + recorderFactory = viewModel::createVoiceRecorder, + showCommandSuggestions = false, + commandSuggestions = emptyList(), + showMentionSuggestions = false, + mentionSuggestions = emptyList(), + onCommandSuggestionClick = { }, + onMentionSuggestionClick = { }, + selectedPrivatePeer = peerID, + currentChannel = null, + nickname = nickname, + colorScheme = colorScheme, + showMediaButtons = true + ) + } + + // Header. Built from the same tokens as the main chat header rather than a + // TopAppBar, so moving between the timeline and a conversation does not shift the + // bar's height, insets or type. + Surface( + modifier = Modifier.align(Alignment.TopCenter), + color = colorScheme.background + ) { + ConversationHeader( + leadingIconRes = conversationTransportIcon( + isReachedOverInternet = isNostrPeer || isNostrReachableFavorite, + isWifiAware = isWifiAware, + isDirect = isDirect + ), + leadingIconTint = colorScheme.primary, + leadingContentDescription = when { + isNostrPeer || isNostrReachableFavorite -> + stringResource(R.string.cd_nostr_reachable) + else -> null + }, + title = titleText + ) { + ConversationHeaderAction( + onClick = { viewModel.toggleFavorite(peerID) }, + contentDescription = if (isFavorite) { + stringResource(R.string.cd_remove_favorite) + } else { + stringResource(R.string.cd_add_favorite) + } + ) { + Icon( + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = null, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + rotationZ = starWobbleRotation.value + scaleX = starWobbleScale.value + scaleY = starWobbleScale.value + }, + tint = favoriteStarTint + ) + } + + if (isVerified) { + ConversationHeaderStatus { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource( + R.string.fingerprint_verified_label + ), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.primary + ) + } + } + + // Keep the lock nearest the close action: from right to left the security + // cluster reads close, encryption, verification, then favorite. + if (!isNostrPeer && !isNostrReachableFavorite) { + ConversationHeaderAction( + onClick = { viewModel.showSecurityVerificationSheet() }, + contentDescription = stringResource(R.string.verify_title) + ) { + Box(contentAlignment = Alignment.Center) { + NoiseSessionIcon( + sessionState = sessionState, + modifier = Modifier.size(HeaderIconSize) + ) + } + } + } + + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() }) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index a27bbdad..8eb76c44 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,69 +1,261 @@ package com.bitchat.android.ui +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState - - -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import android.content.Intent -import android.net.Uri +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.DeliveryStatus -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.ui.media.FileMessageItem +import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.ChatUiModeManager +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeer +import kotlinx.coroutines.delay import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale + + +// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer /** * Message display components for ChatScreen * Extracted from ChatScreen.kt for better organization */ +/** How far a newly arrived message travels up into place. */ +private val MessageEntrySlide = 14.dp + +/** + * Entry motion for a new message: quick, with just enough damping to settle rather than snap. + * Runs entirely on a graphics layer, so it costs a transform and nothing else. + */ +private val MessageEntrySpec: AnimationSpec = + spring(dampingRatio = 0.85f, stiffness = 1200f) + +/** + * Motion for messages being pushed out of the way by an arrival. Softer than the entry so the + * conversation glides up while the new message itself lands crisply. + */ +private val MessagePlacementSpec: FiniteAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold +) + +/** Removals are not worth dwelling on. */ +private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS) + +/** + * How long placement animation stays armed after the list gains or loses a message. + * + * Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut + * short. + */ +private const val PlacementArmWindowMs = 600L + +/** + * Above this many simultaneous arrivals, entry animations are skipped. + * + * A history sync or a channel switch can append hundreds of messages in one frame. Animating each + * would spend the entire frame budget on motion nobody asked to see, so a burst is adopted + * silently and only conversational-pace arrivals animate. + */ +internal const val MaxAnimatedArrivals = 6 + +/** + * Remembers which message ids have already been seen, so genuine arrivals can be told apart from + * items merely scrolling back into view. + * + * This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items + * on demand, so animating on first composition would replay the animation for every old message + * the user scrolled back to. + */ +internal class MessageArrivalTracker { + val known = HashSet() + var seeded = false +} + +/** + * Ids that should animate in on this composition pass. + * + * Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after* + * the frame's composition, by which point a new message's item has already composed and would + * have missed its cue. + */ +internal fun MessageArrivalTracker.arrivals(messages: List): Set { + if (!seeded) { + // First load adopts everything silently. A whole screenful animating on open reads as a + // glitch, not a flourish. + messages.forEach { known.add(it.id) } + seeded = true + return emptySet() + } + + // A list with nothing in common with the last one is a different conversation, not a burst of + // arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it + // silently rather than sliding in every message at once. + val isWholesaleReplacement = + messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known } + + // `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass. + val added = messages.filter { known.add(it.id) } + + if (known.size > messages.size) { + // Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot + // grow without bound and so re-added messages animate again. + known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id }) + } + + return when { + isWholesaleReplacement -> emptySet() + added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() + else -> added.mapTo(HashSet(added.size)) { it.id } + } +} + @Composable fun MessagesList( messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, modifier: Modifier = Modifier, + mentionPeerIdentities: Map? = null, + /** + * Extra inset on top of the list's own gutters. + * + * The chat screen's bars are translucent and the list scrolls underneath them, so the caller + * has to reserve room for their heights here rather than by shrinking the viewport. + */ + contentPadding: PaddingValues = PaddingValues(0.dp), + /** + * Identity of the conversation being shown — a channel, a geohash, a peer. + * + * Everything below that is per-conversation state is keyed on this. Without it, switching + * channels reused the previous conversation's scroll offset, follow flag and seen-message set, + * so the new channel opened at a stale position and then animated itself into place. + */ + conversationKey: Any? = null, forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, - onMessageLongPress: ((BitchatMessage) -> Unit)? = null + onMessageLongPress: ((BitchatMessage) -> Unit)? = null, + onCancelTransfer: ((BitchatMessage) -> Unit)? = null, + onImageClick: ((String, List, Int) -> Unit)? = null ) { - val listState = rememberLazyListState() - + val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) { + mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages) + } + + // Collected once here so individual rows never subscribe to the preference flow; a mode + // switch simply recomposes the list against the new layout. + val bubbles by ChatUiModeManager.modeFlow.collectAsState() + + // A fresh scroll position per conversation. Sharing one state meant a switch inherited the + // previous channel's offset and then had to correct itself, which is what the jump was. + // + // Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved + // offset on every switch, so a conversation always opens on its newest message instead of + // wherever the reader happened to be some time ago, with unseen messages below them. + val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) { + LazyListState() + } + // Track if this is the first time messages are being loaded - var hasScrolledToInitialPosition by remember { mutableStateOf(false) } + var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) } + var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) } - // Smart scroll: auto-scroll to bottom for initial load, then only when user is at or near the bottom + // Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { - val layoutInfo = listState.layoutInfo - val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 - - // With reverseLayout=true and reversed data, index 0 is the latest message at the bottom val isFirstLoad = !hasScrolledToInitialPosition - val isNearLatest = firstVisibleIndex <= 2 - - if (isFirstLoad || isNearLatest) { - listState.animateScrollToItem(0) + if (isFirstLoad || followIncomingMessages) { + listState.scrollToItem(0) if (isFirstLoad) { hasScrolledToInitialPosition = true } @@ -72,13 +264,14 @@ fun MessagesList( } // Track whether user has scrolled away from the latest messages - val isAtLatest by remember { + val isAtLatest by remember(listState) { derivedStateOf { val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 firstVisibleIndex <= 2 } } LaunchedEffect(isAtLatest) { + followIncomingMessages = isAtLatest onScrolledUpChanged?.invoke(!isAtLatest) } @@ -86,25 +279,113 @@ fun MessagesList( LaunchedEffect(forceScrollToBottom) { if (messages.isNotEmpty()) { // With reverseLayout=true and reversed data, latest is at index 0 - listState.animateScrollToItem(0) + followIncomingMessages = true + listState.scrollToItem(0) } } + // Recomputed only when the list actually gains or loses a message, and synchronously, so the + // arriving item can read its cue during the same composition pass in which it first appears. + // Reset per conversation, so a switch adopts the incoming messages silently instead of + // treating a whole channel's backlog as brand-new arrivals and sliding each one in. + val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() } + val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) { + arrivalTracker.arrivals(messages) + } + + // Placement animation exists to soften insertions and removals. But *any* relayout moves every + // item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer + // growing a line — and animating those made the whole conversation lurch. So it is armed only + // briefly around a genuine change to the list, and is otherwise off, letting items track the + // viewport exactly. + var placementArmed by remember(conversationKey) { mutableStateOf(false) } + var previousMessageCount by remember(conversationKey) { mutableStateOf(null) } + LaunchedEffect(conversationKey, messages.size) { + val previous = previousMessageCount + previousMessageCount = messages.size + // Skip the first composition: the list settling into its initial padding is not a change + // worth animating. + if (previous == null || previous == messages.size) return@LaunchedEffect + placementArmed = true + delay(PlacementArmWindowMs) + placementArmed = false + } + + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + // Wider side gutters than the old 12.dp: the redesign trades a little line length for + // a much calmer edge, and long monospace lines were running into the screen bezel. + contentPadding = PaddingValues( + start = 16.dp + contentPadding.calculateStartPadding(layoutDirection), + end = 16.dp + contentPadding.calculateEndPadding(layoutDirection), + top = 8.dp + contentPadding.calculateTopPadding(), + bottom = 12.dp + contentPadding.calculateBottomPadding() + ), + // Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm; + // a new speaker gets additional separation from the visible sender row's top inset. + verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier, reverseLayout = true ) { - items(messages.asReversed()) { message -> - MessageItem( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress - ) + val reversed = messages.asReversed() + itemsIndexed( + items = reversed, + key = { _, message -> message.id } + ) { reversedIndex, message -> + // reverseLayout renders index 0 at the bottom, so the chronological predecessor of + // this row lives at a *higher* original index offset. Resolve against the original + // list rather than the reversed view to keep the grouping logic readable. + val originalIndex = messages.lastIndex - reversedIndex + val previous = messages.getOrNull(originalIndex - 1) + val isGrouped = MessageGrouping.shouldGroup(previous, message) + + // Decided once per item instance, so an item recycling back into view during a scroll + // never re-animates. Items that are not arriving skip the animation machinery + // entirely: no Animatable, no coroutine, and no extra render layer per row. + val isArriving = remember(message.id) { message.id in enteringIds } + val entryModifier = if (isArriving) { + val entry = remember(message.id) { Animatable(0f) } + LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) } + // A draw-time transform only: no measure, no layout, and no recomposition of the + // message content on any frame of the animation. + Modifier.graphicsLayer { + val progress = entry.value + alpha = progress + translationY = (1f - progress) * MessageEntrySlide.toPx() + } + } else { + Modifier + } + + MessageItem( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + mentionPeerIdentities = resolvedMentionPeerIdentities, + showSender = !isGrouped, + bubbles = bubbles.isBubbles, + topSpacing = MessageGrouping.topSpacingFor( + isGrouped = isGrouped, + isFirstInList = originalIndex == 0 + ), + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick, + modifier = Modifier + // Animates the shift when a neighbour is inserted or removed: this is what + // makes the conversation glide up instead of jumping. + .animateItem( + // Entry fade is handled by entryModifier, together with the slide, so the + // two cannot drift out of step. + fadeInSpec = null, + placementSpec = if (placementArmed) MessagePlacementSpec else null, + fadeOutSpec = MessageFadeOutSpec + ) + .then(entryModifier) + ) } } } @@ -114,204 +395,884 @@ fun MessagesList( fun MessageItem( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, + messages: List = emptyList(), + mentionPeerIdentities: Map = emptyMap(), + showSender: Boolean = true, + bubbles: Boolean = false, + topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, - onMessageLongPress: ((BitchatMessage) -> Unit)? = null + onMessageLongPress: ((BitchatMessage) -> Unit)? = null, + onCancelTransfer: ((BitchatMessage) -> Unit)? = null, + onImageClick: ((String, List, Int) -> Unit)? = null, + modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - + val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) } + Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - // Create a custom layout that combines selectable text with clickable nickname areas - MessageTextWithClickableNicknames( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = Modifier.weight(1f) - ) - - // Delivery status for private messages - if (message.isPrivate && message.sender == currentUserNickname) { + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Top + ) { + // Provide a small end padding for own private messages so overlay doesn't cover text. + // Bubble mode draws the status beneath the bubble instead, so no inset is needed. + val endPad = if (!bubbles && message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp + // Create a custom layout that combines selectable text with clickable nickname areas + MessageTextWithClickableNicknames( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + showSender = showSender, + bubbles = bubbles, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick, + modifier = Modifier + .weight(1f) + .padding(end = endPad) + ) + } + + // Delivery status for private messages (overlay, non-displacing). Bubble mode aligns + // own messages to the end edge where this overlay lives, so it renders below instead. + if (!bubbles && message.isPrivate && message.sender == currentUserNickname) { message.deliveryStatus?.let { status -> + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 2.dp) + ) { + DeliveryStatusIcon(status = status) + } + } + } + } + + // Bubble mode: text and media bubbles carry the marker inline, trailing the timestamp. + // File rows have no bubble shell, so their marker stays beneath the end-aligned row. + if (bubbles && message.type == BitchatMessageType.File && + message.isPrivate && message.sender == currentUserNickname + ) { + message.deliveryStatus?.let { status -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, end = 4.dp), + contentAlignment = Alignment.CenterEnd + ) { DeliveryStatusIcon(status = status) } } } - + // Link previews removed; links are now highlighted inline and clickable within the message text } } @OptIn(ExperimentalFoundationApi::class) @Composable -private fun MessageTextWithClickableNicknames( + private fun MessageTextWithClickableNicknames( + message: BitchatMessage, + messages: List, + currentUserNickname: String, + meshService: MeshService, + mentionPeerIdentities: Map, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, + showSender: Boolean, + bubbles: Boolean = false, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((BitchatMessage) -> Unit)?, + onCancelTransfer: ((BitchatMessage) -> Unit)?, + onImageClick: ((String, List, Int) -> Unit)?, + modifier: Modifier = Modifier + ) { + val palette = LocalBitchatPalette.current + + // Image special rendering + if (message.type == BitchatMessageType.Image) { + com.bitchat.android.ui.media.ImageMessageItem( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + showSender = showSender, + bubbles = bubbles, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick, + modifier = modifier + ) + return + } + + // Voice note special rendering + if (message.type == BitchatMessageType.Audio) { + com.bitchat.android.ui.media.AudioMessageItem( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + showSender = showSender, + bubbles = bubbles, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + modifier = modifier + ) + return + } + + // File special rendering + if (message.type == BitchatMessageType.File) { + val path = message.content.trim() + // Derive sending progress if applicable + val (overrideProgress, _) = when (val st = message.deliveryStatus) { + is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> { + if (st.total > 0 && st.reached < st.total) { + (st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending + } else null to null + } + else -> null to null + } + Column( + modifier = modifier.fillMaxWidth(), + // Bubble mode aligns self-authored file rows to the end side, mirroring text bubbles. + horizontalAlignment = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) { + Alignment.End + } else { + Alignment.Start + }, + ) { + // Header: nickname + timestamp line above the file, identical styling to text messages + val headerText = formatMessageHeaderAnnotatedString( + message = message, + currentUserNickname = currentUserNickname, + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender + ) + val haptic = LocalHapticFeedback.current + AnnotatedClickableText( + text = headerText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = { onMessageLongPress?.invoke(message) }, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurface, + ) + + // Try to load the file packet from the path + val packet = try { + val file = java.io.File(path) + if (file.exists()) { + // Create a temporary BitchatFilePacket for display + // In a real implementation, this would be stored with the packet metadata + com.bitchat.android.model.BitchatFilePacket( + fileName = file.name, + fileSize = file.length(), + mimeType = com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name), + content = file.readBytes() + ) + } else null + } catch (e: Exception) { + null + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) { + Arrangement.End + } else { + Arrangement.Start + } + ) { + Box { + if (packet != null) { + if (overrideProgress != null) { + // Show sending animation while in-flight + com.bitchat.android.ui.media.FileSendingAnimation( + fileName = packet.fileName, + progress = overrideProgress, + modifier = Modifier.fillMaxWidth() + ) + } else { + // Static file display with open/save dialog + FileMessageItem( + packet = packet, + onFileClick = { + // handled inside FileMessageItem via dialog + } + ) + } + + // Cancel button overlay during sending + val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is DeliveryStatus.PartiallyDelivered) + if (showCancel) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(22.dp) + .background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape) + .clickable { onCancelTransfer?.invoke(message) }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.cd_cancel), + tint = colorScheme.onSurface, + modifier = Modifier.size(14.dp) + ) + } + } + } else { + Text( + text = stringResource(R.string.file_unavailable), + fontFamily = BitchatFontFamily, + color = palette.textTertiary + ) + } + } + } + } + return + } + + val cashuTokens = remember(message.content) { + CashuTokenDecoder.extractTokens(message.content) + } + if (cashuTokens.isNotEmpty() && message.sender != "system") { + CashuMessageContent( + message = message, + tokens = cashuTokens, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + bubbles = bubbles, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + modifier = modifier + ) + return + } + + if (message.sender == "system") { + // Background narration: `// Tor started. Routing all chats…` + val annotatedText = remember(message, colorScheme.onSurface) { + formatSystemMessage( + message = message, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter + ) + } + + val haptic = LocalHapticFeedback.current + Text( + text = annotatedText, + modifier = modifier.pointerInput(message) { + detectTapGestures( + onLongPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongPress?.invoke(message) + } + ) + }, + fontFamily = BitchatFontFamily, + softWrap = true, + overflow = TextOverflow.Visible, + style = ChatVisualTokens.SystemActionStyle.copy( + color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha), + ) + ) + } else { + TextMessageLayout( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + showSender = showSender, + bubbles = bubbles, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + modifier = modifier, + ) + } +} + +@Composable +internal fun TextMessageLayout( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, + mentionPeerIdentities: Map = emptyMap(), colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showSender: Boolean = true, + bubbles: Boolean = false, + bodyContent: String = message.content, ) { - val annotatedText = formatMessageAsAnnotatedString( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter - ) - - // Check if this message was sent by self to avoid click interactions on own nickname - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") - + val palette = LocalBitchatPalette.current + val myPeerId = meshService.myPeerID + val displayMessage = remember(message, bodyContent) { + if (bodyContent == message.content) message else message.copy(content = bodyContent) + } + val senderText = remember(message, currentUserNickname, myPeerId, palette) { + formatTextMessageSender( + message = message, + currentUserNickname = currentUserNickname, + myPeerID = myPeerId, + palette = palette, + ) + } + val isSelf = message.isFromSelf(currentUserNickname, myPeerId) val haptic = LocalHapticFeedback.current val context = LocalContext.current - var textLayoutResult by remember { mutableStateOf(null) } - Text( - text = annotatedText, - modifier = modifier.pointerInput(message) { - detectTapGestures( - onTap = { position -> - val layout = textLayoutResult ?: return@detectTapGestures - val offset = layout.getOffsetForPosition(position) - // Nickname click only when not self - if (!isSelf && onNicknameClick != null) { - val nicknameAnnotations = annotatedText.getStringAnnotations( - tag = "nickname_click", - start = offset, - end = offset - ) - if (nicknameAnnotations.isNotEmpty()) { - val nickname = nicknameAnnotations.first().item - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onNicknameClick.invoke(nickname) - return@detectTapGestures - } - } - // Geohash teleport (all messages) - val geohashAnnotations = annotatedText.getStringAnnotations( - tag = "geohash_click", - start = offset, - end = offset - ) - if (geohashAnnotations.isNotEmpty()) { - val geohash = geohashAnnotations.first().item - try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance( - context - ) - val level = when (geohash.length) { - in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION - in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE - 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY - 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD - else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - } - val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase()) - locationManager.setTeleported(true) - locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel)) - } catch (_: Exception) { } + val handleLongPress: () -> Unit = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongPress?.invoke(message) + } + + // The timestamp trails the body rather than occupying its own column, so a short message + // no longer reserves a full-width row for eight grey characters. Self bubbles leave the + // timestamp to their meta cluster (see BubbleTextMessageLayout). + val bodyText = remember( + displayMessage, + currentUserNickname, + palette, + colorScheme.onSurface, + colorScheme.secondary, + mentionPeerIdentities, + timeFormatter, + bubbles, + isSelf + ) { + formatTextMessageBody( + message = displayMessage, + currentUserNickname = currentUserNickname, + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, + includeTimestamp = !bubbles || !isSelf, + ) + } + + if (bubbles) { + BubbleTextMessageLayout( + message = message, + senderText = senderText, + bodyText = bodyText, + isSelf = isSelf, + showSender = showSender, + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onLongPress = handleLongPress, + modifier = modifier, + ) + return + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING), + ) { + if (showSender) { + AnnotatedClickableText( + text = senderText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && !isSelf && onNicknameClick != null) { haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - return@detectTapGestures - } - // URL open (all messages) - val urlAnnotations = annotatedText.getStringAnnotations( - tag = "url_click", - start = offset, - end = offset - ) - if (urlAnnotations.isNotEmpty()) { - val raw = urlAnnotations.first().item - val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw" - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } catch (_: Exception) { } - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - return@detectTapGestures + onNicknameClick.invoke(item) + true + } else { + false } }, - onLongPress = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onMessageLongPress?.invoke(message) + onLongPress = handleLongPress, + modifier = Modifier + .fillMaxWidth() + .padding(top = MessageGrouping.SENDER_TOP_PADDING), + fontFamily = BitchatFontFamily, + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = MessageSenderTextStyle, + ) + } + + AnnotatedClickableText( + text = bodyText, + annotationTags = listOf("geohash_click", "url_click"), + onAnnotationClick = { tag, item -> + when (tag) { + "geohash_click" -> { + navigateToGeohash(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } + + "url_click" -> { + openMessageUrl(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } + + else -> false + } + }, + onLongPress = handleLongPress, + fontFamily = BitchatFontFamily, + softWrap = true, + overflow = TextOverflow.Visible, + style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), + ) + } +} + +/** + * Classic messenger rendering of a text message: a rounded bubble that hugs its content, own + * messages on the right and everyone else on the left, with the corner on the speaker's side + * tightened into a subtle tail. + * + * The bubble is washed with the author's stable peer colour — the same identity-derived colour + * the `@name` label and mention chips already use — so the speaker stays identifiable at a + * glance without touching any surface, background, or theme colour. Body text keeps the + * standard `onSurface` tone; only the bubble shell carries the identity. + */ +@Composable +private fun BubbleTextMessageLayout( + message: BitchatMessage, + senderText: AnnotatedString, + bodyText: AnnotatedString, + isSelf: Boolean, + showSender: Boolean, + timeFormatter: SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onLongPress: () -> Unit, + modifier: Modifier = Modifier, +) { + val palette = LocalBitchatPalette.current + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + + val authorColor = remember(message, isSelf, palette) { + if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette) + } + + val corner = ChatVisualTokens.BubbleCornerRadius + val tail = ChatVisualTokens.BubbleTailRadius + val bubbleShape = if (isSelf) { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner) + } else { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail) + } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start, + ) { + // Cap the bubble at a fraction of the row so long messages wrap instead of touching the + // opposite edge, while short ones hug their content. + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val maxBubbleWidth = maxWidth * ChatVisualTokens.BubbleMaxWidthFraction + val density = LocalDensity.current + val textCapPx = with(density) { + (maxBubbleWidth - ChatVisualTokens.BubblePaddingHorizontal * 2 - 2.dp).toPx() + } + var bodyLayout by remember { mutableStateOf(null) } + var clusterSize by remember { mutableStateOf(IntSize.Zero) } + + // The meta cluster (timestamp, then delivery checks for own private messages) rides + // flush-right on the body's last line when that line has room for it, and drops + // below the text only when it does not. Placement is computed from the laid-out + // text, so wrapping is never influenced by the cluster: no early wraps, no slack + // carved out of the first lines, no minimum bubble width. + val metaGapPx = with(density) { 8.dp.toPx() } + val metaPlan = remember(bodyLayout, clusterSize, textCapPx) { + val layout = bodyLayout ?: return@remember null + if (layout.lineCount == 0 || clusterSize.width <= 0) return@remember null + val lastLineRight = layout.getLineRight(layout.lineCount - 1) + if (lastLineRight + metaGapPx + clusterSize.width <= textCapPx) { + BubbleMetaPlan( + widthPx = maxOf(layout.size.width.toFloat(), lastLineRight + metaGapPx + clusterSize.width), + reserveOwnLine = false, + ) + } else { + BubbleMetaPlan( + widthPx = maxOf(layout.size.width.toFloat(), clusterSize.width.toFloat()), + reserveOwnLine = true, + ) + } + } + + Box( + modifier = Modifier + .align(if (isSelf) Alignment.CenterEnd else Alignment.CenterStart) + .widthIn(max = maxBubbleWidth) + .border( + width = 1.dp, + color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha), + shape = bubbleShape + ) + .background( + color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha), + shape = bubbleShape + ) + .padding( + horizontal = ChatVisualTokens.BubblePaddingHorizontal, + vertical = ChatVisualTokens.BubblePaddingVertical, + ) + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + // The sender's name heads the first bubble of their run, like classic group + // messengers, instead of floating above it. Own bubbles never show a name — + // the end side is attribution enough. Continuation bubbles skip it too. + if (showSender && !isSelf) { + AnnotatedClickableText( + text = senderText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && !isSelf && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = onLongPress, + fontFamily = BitchatFontFamily, + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = MessageSenderTextStyle, + ) + } + + Box( + modifier = if (isSelf && metaPlan != null) { + Modifier.width(with(density) { metaPlan!!.widthPx.toDp() }) + } else { + Modifier + } + ) { + AnnotatedClickableText( + text = bodyText, + annotationTags = listOf("geohash_click", "url_click"), + onAnnotationClick = { tag, item -> + when (tag) { + "geohash_click" -> { + navigateToGeohash(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } + + "url_click" -> { + openMessageUrl(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } + + else -> false + } + }, + onLongPress = onLongPress, + modifier = Modifier.padding( + bottom = if (metaPlan?.reserveOwnLine == true) { + with(density) { clusterSize.height.toDp() } + } else { + 0.dp + } + ), + fontFamily = BitchatFontFamily, + softWrap = true, + overflow = TextOverflow.Visible, + style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface), + onTextLayout = { bodyLayout = it }, + ) + + if (isSelf) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.BottomEnd) + .onSizeChanged { clusterSize = it } + .graphicsLayer { alpha = if (metaPlan != null) 1f else 0f }, + ) { + Text( + text = formatTextMessageMetadata(message, timeFormatter), + fontFamily = BitchatFontFamily, + ) + if (message.isPrivate) { + message.deliveryStatus?.let { status -> + Spacer(Modifier.width(4.dp)) + DeliveryStatusIcon(status = status) + } + } + } + } + } + } + } + } + } +} + +private data class BubbleMetaPlan( + val widthPx: Float, + val reserveOwnLine: Boolean, +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CashuMessageContent( + message: BitchatMessage, + tokens: List, + currentUserNickname: String, + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, + bubbles: Boolean = false, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier +) { + val remainingText = tokens.fold(message.content) { text, token -> + text.replace("cashu://$token", "", ignoreCase = true) + .replace("cashu:$token", "", ignoreCase = true) + .replace(token, "") + }.trim() + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + TextMessageLayout( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + bubbles = bubbles, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + bodyContent = remainingText, + ) + tokens.forEach { token -> CashuPaymentChip(token) } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun CashuPaymentChip( + token: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + showActions: Boolean = true, +) { + val context = LocalContext.current + val info = remember(token) { CashuTokenDecoder.decode(token) } + val primaryLabel = listOfNotNull(info?.displayAmount, info?.mintHost) + .joinToString(" · ") + .ifEmpty { stringResource(R.string.cashu_pay_via) } + var showMenu by remember { mutableStateOf(false) } + + Box { + Row( + modifier = modifier + .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.25f), RoundedCornerShape(12.dp)) + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.45f), + RoundedCornerShape(12.dp) + ) + .combinedClickable( + onClick = onClick ?: { redeemCashu(context, token, preferWallet = true) }, + onLongClick = if (showActions) { + { showMenu = true } + } else { + null + } + ) + .semantics { + contentDescription = buildString { + append(context.getString(R.string.cashu_payment_description)) + append(": ") + append(primaryLabel) + info?.memo?.let { append(", $it") } + } + } + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("🥜") + Column { + Text( + primaryLabel, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + info?.memo?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + DropdownMenu( + expanded = showActions && showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.cashu_copy_token)) }, + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Cashu token", token)) + showMenu = false } ) - }, - fontFamily = FontFamily.Monospace, - softWrap = true, - overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface - ), - onTextLayout = { result -> textLayoutResult = result } - ) + DropdownMenuItem( + text = { Text(stringResource(R.string.cashu_redeem_wallet)) }, + onClick = { + showMenu = false + redeemCashu(context, token, preferWallet = true) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.cashu_redeem_web)) }, + onClick = { + showMenu = false + redeemCashu(context, token, preferWallet = false) + } + ) + } + } +} + +private fun redeemCashu(context: Context, token: String, preferWallet: Boolean) { + val wallet = CashuTokenDecoder.walletUri(token) + val web = CashuTokenDecoder.webRedeemUri(token) ?: return + if (preferWallet && wallet != null) { + val walletIntent = Intent(Intent.ACTION_VIEW, Uri.parse(wallet)) + try { + context.startActivity(walletIntent) + return + } catch (_: ActivityNotFoundException) { + // No wallet registered for cashu:, so use the explicit web fallback. + } + } + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) } +} + +/** + * Per-check target colours for the delivery marker. + * + * Both checks always render — grey (disabled) until an acknowledgement turns them on — so a + * status change recolours in place and never reflows text around it. Read receipts use the + * app's primary green rather than a separate accent. [status] == null yields the all-grey + * baseline used while a message is still being sent. + */ +private fun deliveryCheckColors(status: DeliveryStatus?, colorScheme: ColorScheme): Pair { + val grey = colorScheme.onSurface.copy(alpha = 0.35f) + val green = colorScheme.primary + return when (status) { + is DeliveryStatus.Read -> green to green + is DeliveryStatus.Delivered -> green to grey + is DeliveryStatus.PartiallyDelivered -> green to grey + is DeliveryStatus.Failed -> colorScheme.error to colorScheme.error + else -> grey to grey + } +} + +/** Acknowledgement progress ordering, used to fire the pop only when the state advances. */ +private fun deliveryCheckRank(status: DeliveryStatus): Int = when (status) { + is DeliveryStatus.Read -> 3 + is DeliveryStatus.Delivered -> 2 + is DeliveryStatus.PartiallyDelivered -> 2 + is DeliveryStatus.Failed -> 1 + else -> 0 } @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme - - when (status) { - is DeliveryStatus.Sending -> { - Text( - text = "○", - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Sent -> { - // Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity) - Text( - text = "○", - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Delivered -> { - // Single check for Delivered (matches iOS expectations) - Text( - text = "✓", - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.8f) - ) - } - is DeliveryStatus.Read -> { - Text( - text = "✓✓", - fontSize = 10.sp, - color = Color(0xFF007AFF), // Blue - fontWeight = FontWeight.Bold - ) - } - is DeliveryStatus.Failed -> { - Text( - text = "⚠", - fontSize = 10.sp, - color = Color.Red.copy(alpha = 0.8f) - ) - } - is DeliveryStatus.PartiallyDelivered -> { - Text( - text = "✓${status.reached}/${status.total}", - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) + val (firstTarget, secondTarget) = deliveryCheckColors(status, colorScheme) + val first by animateColorAsState( + targetValue = firstTarget, + animationSpec = tween(BitchatMotion.QUICK_MS), + label = "firstCheckColor", + ) + val second by animateColorAsState( + targetValue = secondTarget, + animationSpec = tween(BitchatMotion.QUICK_MS), + label = "secondCheckColor", + ) + + // Snappy micro pop when the state advances to (more) acknowledged. Keyed on the rank, not + // the instance, because Delivered/Read carry timestamps that would retrigger it otherwise. + val scale = remember { Animatable(1f) } + LaunchedEffect(deliveryCheckRank(status)) { + if (deliveryCheckRank(status) >= 2) { + scale.snapTo(1.3f) + scale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 900f)) } } + + val text = remember(first, second) { + androidx.compose.ui.text.buildAnnotatedString { + pushStyle(androidx.compose.ui.text.SpanStyle(color = first)) + append("✓") + pop() + pushStyle(androidx.compose.ui.text.SpanStyle(color = second)) + append("✓") + pop() + } + } + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + modifier = Modifier.graphicsLayer { + scaleX = scale.value + scaleY = scale.value + } + ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt new file mode 100644 index 00000000..9b3278c4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -0,0 +1,84 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.ChatVisualTokens + +/** + * Consecutive-message grouping for the chat surface. + * + * The redesign suppresses the `@sender` label on runs of messages from the same author so the + * eye only has to register a name when the speaker actually changes. The transcript uses one + * consistent item rhythm; new groups get their additional separation from the visible sender + * row's own top padding. + */ +object MessageGrouping { + + /** Space above the first message of a group (i.e. one that renders its sender label). */ + val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing + + /** Space above a continuation message inside an existing group. */ + val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing + + /** Gap between the sender label and the first line of the body. */ + val SENDER_TO_BODY_SPACING: Dp = ChatVisualTokens.SenderToBodySpacing + + /** Top inset inside a visible sender row. */ + val SENDER_TOP_PADDING: Dp = ChatVisualTokens.SenderTopPadding + + /** + * Messages further apart than this always start a new group, even from the same sender: + * a reply an hour later is a new thought, not a continuation. + */ + const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L + + private const val SYSTEM_SENDER = "system" + + /** + * Whether [current] should be rendered as a continuation of [previous], hiding its sender + * label. + * + * [previous] is the message immediately *before* [current] in chronological order. Note the + * message list renders with `reverseLayout = true`, so callers must be careful to resolve + * the chronological predecessor rather than the visually preceding item. + */ + fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean { + if (previous == null) return false + + // System/action lines never participate in grouping in either direction: they are + // narration, and folding a real message into them would attribute it to "system". + if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false + + // Never group across the public/private boundary or between different channels. + if (previous.isPrivate != current.isPrivate) return false + if (previous.channel != current.channel) return false + + if (!isSameSender(previous, current)) return false + + val elapsed = current.timestamp.time - previous.timestamp.time + return elapsed in 0..GROUPING_WINDOW_MS + } + + /** + * Identity comparison. Peer IDs are authoritative when both messages carry one, because two + * different peers can share a nickname. Falls back to the display name (which includes the + * `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages. + */ + private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean { + val previousPeerID = previous.senderPeerID + val currentPeerID = current.senderPeerID + return if (previousPeerID != null && currentPeerID != null) { + previousPeerID.equals(currentPeerID, ignoreCase = true) + } else { + previous.sender == current.sender + } + } + + /** Top padding for a message given whether it continues the previous author's run. */ + fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when { + isFirstInList -> 0.dp + isGrouped -> GROUPED_SPACING + else -> NEW_GROUP_SPACING + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt new file mode 100644 index 00000000..2caff0fe --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt @@ -0,0 +1,51 @@ +package com.bitchat.android.ui + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.model.BitchatMessage + +internal fun BitchatMessage.isFromSelf( + currentUserNickname: String, + myPeerId: String, +): Boolean = + senderPeerID == myPeerId || + sender == currentUserNickname || + sender.startsWith("$currentUserNickname#") + +internal fun normalizeMessageUrl(rawUrl: String): String = + if ( + rawUrl.startsWith("http://", ignoreCase = true) || + rawUrl.startsWith("https://", ignoreCase = true) + ) { + rawUrl + } else { + "https://$rawUrl" + } + +internal fun openMessageUrl(context: Context, rawUrl: String): Boolean = + runCatching { + val intent = Intent(Intent.ACTION_VIEW, normalizeMessageUrl(rawUrl).toUri()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }.isSuccess + +internal fun channelForGeohash(geohash: String): GeohashChannel { + val level = when (geohash.length) { + in 0..2 -> GeohashChannelLevel.REGION + in 3..4 -> GeohashChannelLevel.PROVINCE + 5 -> GeohashChannelLevel.CITY + 6 -> GeohashChannelLevel.NEIGHBORHOOD + else -> GeohashChannelLevel.BLOCK + } + return GeohashChannel(level, geohash.lowercase()) +} + +internal fun navigateToGeohash(context: Context, geohash: String): Boolean = + runCatching { + val locationManager = LocationChannelManager.getInstance(context) + locationManager.selectManual(channelForGeohash(geohash)) + }.isSuccess diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index c1e58963..31e7d1af 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.ContactDirectory import java.util.* import java.util.Collections @@ -10,11 +11,11 @@ import java.util.Collections */ class MessageManager(private val state: ChatState) { - // Message deduplication - FIXED: Prevent duplicate messages from dual connection paths + // Message deduplication for duplicate deliveries from multiple local transports. private val processedUIMessages = Collections.synchronizedSet(mutableSetOf()) private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf()) - private val MESSAGE_DEDUP_TIMEOUT = 30000L // 30 seconds - private val SYSTEM_EVENT_DEDUP_TIMEOUT = 5000L // 5 seconds + private val MESSAGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.MESSAGE_DEDUP_TIMEOUT_MS // 30 seconds + private val SYSTEM_EVENT_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.SYSTEM_EVENT_DEDUP_TIMEOUT_MS // 5 seconds // MARK: - Public Message Management @@ -22,10 +23,24 @@ class MessageManager(private val state: ChatState) { val currentMessages = state.getMessagesValue().toMutableList() currentMessages.add(message) state.setMessages(currentMessages) + // Reflect into process-wide store so snapshot replacements don't drop local outgoing messages + try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { } + } + + // Log a system message into the main chat (visible to user) + fun addSystemMessage(text: String) { + val sys = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false + ) + addMessage(sys) } fun clearMessages() { state.setMessages(emptyList()) + state.setChannelMessages(emptyMap()) } // MARK: - Channel Message Management @@ -40,9 +55,21 @@ class MessageManager(private val state: ChatState) { channelMessageList.add(message) currentChannelMessages[channel] = channelMessageList state.setChannelMessages(currentChannelMessages) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { } - // Update unread count if not currently in this channel - if (state.getCurrentChannelValue() != channel) { + // Update unread count if not currently viewing this channel + // Consider both classic channels (state.currentChannel) and geohash location channel selection + val viewingClassicChannel = state.getCurrentChannelValue() == channel + val viewingGeohashChannel = try { + if (channel.startsWith("geo:")) { + val geo = channel.removePrefix("geo:") + val selected = state.selectedLocationChannel.value + selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true) + } else false + } catch (_: Exception) { false } + + if (!viewingClassicChannel && !viewingGeohashChannel) { val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap() currentUnread[channel] = (currentUnread[channel] ?: 0) + 1 state.setUnreadChannelMessages(currentUnread) @@ -74,53 +101,122 @@ class MessageManager(private val state: ChatState) { // MARK: - Private Message Management fun addPrivateMessage(peerID: String, message: BitchatMessage) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val accepted = try { + com.bitchat.android.services.AppStateStore.addPrivateMessage( + conversationID, + message + ) + } catch (_: Exception) { + false + } + if (!accepted) return + publishAcceptedPrivateMessage(conversationID, message, forceRead = false) + } + + /** + * Adds a private message only after its database transaction has completed. + * + * Outgoing sends and non-mesh transports use this path so the local echo is never shown or + * transmitted unless it can survive an immediate process death. + */ + suspend fun addPrivateMessageDurably( + peerID: String, + message: BitchatMessage, + forceRead: Boolean = false + ): Boolean { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val accepted = try { + com.bitchat.android.services.AppStateStore.addPrivateMessageDurably( + peerID = conversationID, + msg = message, + forceRead = forceRead + ) + } catch (_: Exception) { + false + } + if (!accepted) return false + publishAcceptedPrivateMessage(conversationID, message, forceRead) + return true + } + + private fun publishAcceptedPrivateMessage( + conversationID: String, + message: BitchatMessage, + forceRead: Boolean + ) { val currentPrivateChats = state.getPrivateChatsValue().toMutableMap() - if (!currentPrivateChats.containsKey(peerID)) { - currentPrivateChats[peerID] = mutableListOf() + if (!currentPrivateChats.containsKey(conversationID)) { + currentPrivateChats[conversationID] = mutableListOf() } - val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf() + val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) - currentPrivateChats[peerID] = chatMessages - state.setPrivateChats(currentPrivateChats) + currentPrivateChats[conversationID] = chatMessages + state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) // Mark as unread if not currently viewing this chat - if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) { + val selectedConversationID = state.getSelectedPrivateChatPeerValue() + ?.let(ContactDirectory::canonicalConversationId) + if ( + !forceRead && + selectedConversationID != conversationID && + message.sender != state.getNicknameValue() + ) { val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() - currentUnread.add(peerID) + currentUnread.add(conversationID) state.setUnreadPrivateMessages(currentUnread) } } // Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store) fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) { - val currentPrivateChats = state.getPrivateChatsValue().toMutableMap() - if (!currentPrivateChats.containsKey(peerID)) { - currentPrivateChats[peerID] = mutableListOf() + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val accepted = try { + com.bitchat.android.services.AppStateStore.addPrivateMessage( + peerID = conversationID, + msg = message, + forceRead = true + ) + } catch (_: Exception) { + false } - val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf() - chatMessages.add(message) - currentPrivateChats[peerID] = chatMessages - state.setPrivateChats(currentPrivateChats) + if (!accepted) return + publishAcceptedPrivateMessage(conversationID, message, forceRead = true) } fun clearPrivateMessages(peerID: String) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + com.bitchat.android.services.AppStateStore.deletePrivateConversation(conversationID) val updatedChats = state.getPrivateChatsValue().toMutableMap() - updatedChats[peerID] = emptyList() + updatedChats.keys.removeAll { key -> + ContactDirectory.canonicalConversationId(key) + .equals(conversationID, ignoreCase = true) + } state.setPrivateChats(updatedChats) + clearPrivateUnreadMessages(conversationID) } fun initializePrivateChat(peerID: String) { - if (state.getPrivateChatsValue().containsKey(peerID)) return + val conversationID = ContactDirectory.canonicalConversationId(peerID) + if (state.getPrivateChatsValue().containsKey(conversationID)) return val updatedChats = state.getPrivateChatsValue().toMutableMap() - updatedChats[peerID] = emptyList() + updatedChats[conversationID] = emptyList() state.setPrivateChats(updatedChats) } - fun clearPrivateUnreadMessages(peerID: String) { + fun clearPrivateUnreadMessages( + peerID: String, + aliases: Set = emptySet() + ) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet() - updatedUnread.remove(peerID) + val normalizedAliases = (aliases + peerID + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } + updatedUnread.removeAll { unreadID -> + unreadID.lowercase() in normalizedAliases + } state.setUnreadPrivateMessages(updatedUnread) } @@ -184,6 +280,28 @@ class MessageManager(private val state: ChatState) { // MARK: - Delivery Status Updates + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 + } + + private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { + // A send failure may replace an in-flight state, but never a confirmed delivery/read. + return when { + new is DeliveryStatus.Failed && + old !is DeliveryStatus.Delivered && + old !is DeliveryStatus.Read -> new + old is DeliveryStatus.Failed -> new + statusPriority(new) >= statusPriority(old) -> new + else -> old + } + } + fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) { // Update in private chats val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap() @@ -193,22 +311,32 @@ class MessageManager(private val state: ChatState) { val updatedMessages = messages.toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - updatedPrivateChats[peerID] = updatedMessages - updated = true + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + updatedPrivateChats[peerID] = updatedMessages + updated = true + } } } if (updated) { state.setPrivateChats(updatedPrivateChats) + // Keep process-wide store in sync to prevent snapshot overwrites resetting status + try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { } } // Update in main messages val updatedMessages = state.getMessagesValue().toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - state.setMessages(updatedMessages) + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + state.setMessages(updatedMessages) + } } // Update in channel messages @@ -217,12 +345,62 @@ class MessageManager(private val state: ChatState) { val channelMessagesList = messages.toMutableList() val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID } if (channelMessageIndex >= 0) { - channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status) - updatedChannelMessages[channel] = channelMessagesList + val current = channelMessagesList[channelMessageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus) + updatedChannelMessages[channel] = channelMessagesList + } } } state.setChannelMessages(updatedChannelMessages) } + + // Remove a message from all locations (main timeline, private chats, channels) + fun removeMessageById(messageID: String) { + // Main timeline + run { + val list = state.getMessagesValue().toMutableList() + val idx = list.indexOfFirst { it.id == messageID } + if (idx >= 0) { + list.removeAt(idx) + state.setMessages(list) + } + } + // Private chats + run { + val chats = state.getPrivateChatsValue().toMutableMap() + var changed = false + chats.keys.toList().forEach { key -> + val msgs = chats[key]?.toMutableList() ?: mutableListOf() + val idx = msgs.indexOfFirst { it.id == messageID } + if (idx >= 0) { + msgs.removeAt(idx) + chats[key] = msgs + changed = true + } + } + if (changed) { + state.setPrivateChats(chats.filterValues { it.isNotEmpty() }) + com.bitchat.android.services.AppStateStore.removePrivateMessage(messageID) + } + } + // Channels + run { + val chans = state.getChannelMessagesValue().toMutableMap() + var changed = false + chans.keys.toList().forEach { ch -> + val msgs = chans[ch]?.toMutableList() ?: mutableListOf() + val idx = msgs.indexOfFirst { it.id == messageID } + if (idx >= 0) { + msgs.removeAt(idx) + chans[ch] = msgs + changed = true + } + } + if (changed) state.setChannelMessages(chans) + } + } // MARK: - Utility Functions diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index a36e71fe..2b77075d 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -1,18 +1,30 @@ package com.bitchat.android.ui +import android.Manifest import android.app.NotificationChannel -import android.app.NotificationManager +import android.app.NotificationManager as AndroidNotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.content.LocusIdCompat +import androidx.core.graphics.drawable.IconCompat import com.bitchat.android.MainActivity import com.bitchat.android.R -import com.bitchat.android.util.NotificationIntervalManager +import com.bitchat.android.service.ConversationNotificationReceiver +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ConversationListPreferences +import java.util.Collections +import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap /** @@ -23,12 +35,10 @@ import java.util.concurrent.ConcurrentHashMap * - Support for mention notifications in geohash chats * - Support for first message notifications in geohash chats * - Proper notification management and cleanup - * - Active peers notification */ class NotificationManager( private val context: Context, - private val notificationManager: NotificationManagerCompat, - private val notificationIntervalManager: NotificationIntervalManager + private val notificationManager: NotificationManagerCompat ) { companion object { @@ -41,8 +51,7 @@ class NotificationManager( private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000 private const val SUMMARY_NOTIFICATION_ID = 999 private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998 - private const val ACTIVE_PEERS_NOTIFICATION_ID = 997 - private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = 300_000L + private const val MAX_MESSAGES_IN_NOTIFICATION = 25 // Intent extras for notification handling const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat" @@ -50,9 +59,33 @@ class NotificationManager( const val EXTRA_PEER_ID = "peer_id" const val EXTRA_SENDER_NICKNAME = "sender_nickname" const val EXTRA_GEOHASH = "geohash" + const val ACTION_REPLY_TO_CONVERSATION = + "com.bitchat.android.action.REPLY_TO_CONVERSATION" + const val ACTION_MARK_CONVERSATION_READ = + "com.bitchat.android.action.MARK_CONVERSATION_READ" + const val KEY_TEXT_REPLY = "conversation_reply_text" + + private val liveManagers: MutableSet = + Collections.newSetFromMap(WeakHashMap()) + + /** + * Synchronizes notification action receivers with every manager instance in this process. + * Without this, an old in-memory MessagingStyle history could reappear on the next DM. + */ + fun acknowledgeConversation(context: Context, conversationID: String) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val managers = synchronized(liveManagers) { liveManagers.toList() } + managers.forEach { it.clearNotificationsForSender(canonicalID) } + if (managers.isEmpty()) { + NotificationManagerCompat.from(context).cancel(canonicalID.hashCode()) + } + } } - private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val systemNotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as AndroidNotificationManager + private val conversationPreferences = + ConversationListPreferences.getInstance(context.applicationContext) // Track pending notifications per sender to enable grouping private val pendingNotifications = ConcurrentHashMap>() @@ -87,15 +120,17 @@ class NotificationManager( ) init { + synchronized(liveManagers) { liveManagers.add(this) } createNotificationChannel() } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // DM notifications channel - val dmName = "Direct Messages" - val dmDescriptionText = "Notifications for private messages from other users" - val dmImportance = NotificationManager.IMPORTANCE_HIGH + val dmName = context.getString(R.string.notification_channel_direct_messages) + val dmDescriptionText = + context.getString(R.string.notification_channel_direct_messages_description) + val dmImportance = AndroidNotificationManager.IMPORTANCE_HIGH val dmChannel = NotificationChannel(CHANNEL_ID, dmName, dmImportance).apply { description = dmDescriptionText enableVibration(true) @@ -104,9 +139,10 @@ class NotificationManager( systemNotificationManager.createNotificationChannel(dmChannel) // Geohash notifications channel - val geohashName = "Geohash Chats" - val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels" - val geohashImportance = NotificationManager.IMPORTANCE_HIGH + val geohashName = context.getString(R.string.notification_channel_geohash) + val geohashDescriptionText = + context.getString(R.string.notification_channel_geohash_description) + val geohashImportance = AndroidNotificationManager.IMPORTANCE_HIGH val geohashChannel = NotificationChannel(GEOHASH_CHANNEL_ID, geohashName, geohashImportance).apply { description = geohashDescriptionText enableVibration(true) @@ -128,8 +164,8 @@ class NotificationManager( * Update current private chat peer - affects notification logic */ fun setCurrentPrivateChatPeer(peerID: String?) { - currentPrivateChatPeer = peerID - Log.d(TAG, "Current private chat peer changed: $peerID") + currentPrivateChatPeer = peerID?.let { ContactDirectory.canonicalConversationId(it) } + Log.d(TAG, "Current private chat peer changed: $currentPrivateChatPeer") } /** @@ -137,35 +173,41 @@ class NotificationManager( */ fun setCurrentGeohash(geohash: String?) { currentGeohash = geohash - Log.d(TAG, "Current geohash changed: $geohash") + Log.d(TAG, "Current geohash changed") } /** * Show a notification for a private message with proper grouping and state awareness */ fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) { + val conversationID = ContactDirectory.canonicalConversationId(senderPeerID) + if (conversationPreferences.isMuted(conversationID)) { + Log.d(TAG, "Skipping muted conversation notification") + return + } // Only show notifications if app is in background OR user is not viewing this specific chat - val shouldNotify = isAppInBackground || (!isAppInBackground && currentPrivateChatPeer != senderPeerID) + val shouldNotify = isAppInBackground || + (!isAppInBackground && currentPrivateChatPeer != conversationID) if (!shouldNotify) { Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname") return } - Log.d(TAG, "Showing notification for message from $senderNickname (peerID: $senderPeerID)") + Log.d(TAG, "Showing notification for message from $senderNickname (conversationID: $conversationID)") val notification = PendingNotification( - senderPeerID = senderPeerID, + senderPeerID = conversationID, senderNickname = senderNickname, messageContent = messageContent, timestamp = System.currentTimeMillis() ) // Add to pending notifications for this sender - pendingNotifications.computeIfAbsent(senderPeerID) { mutableListOf() }.add(notification) + pendingNotifications.computeIfAbsent(conversationID) { mutableListOf() }.add(notification) // Create or update notification for this sender - showNotificationForSender(senderPeerID) + showNotificationForSender(conversationID) // Update summary notification if we have multiple senders if (pendingNotifications.size > 1) { @@ -173,22 +215,6 @@ class NotificationManager( } } - fun showActiveUserNotification(peers: List) { - val currentTime = System.currentTimeMillis() - val activePeerNotificationIntervalExceeded = - (currentTime - notificationIntervalManager.lastNetworkNotificationTime) > ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL - val newPeers = peers - notificationIntervalManager.recentlySeenPeers - if (isAppInBackground && activePeerNotificationIntervalExceeded && newPeers.isNotEmpty()) { - Log.d(TAG, "Showing notification for active peers") - showNotificationForActivePeers(peers.size) - notificationIntervalManager.setLastNetworkNotificationTime(currentTime) - notificationIntervalManager.recentlySeenPeers.addAll(newPeers) - } else { - Log.d(TAG, "Skipping notification - app in foreground or it has been less than 5 minutes since last active peer notification") - return - } - } - private fun showNotificationForSender(senderPeerID: String) { val notifications = pendingNotifications[senderPeerID] ?: return if (notifications.isEmpty()) return @@ -216,6 +242,12 @@ class NotificationManager( .setName(latestNotification.senderNickname) .setKey(senderPeerID) .build() + val shortcutID = conversationShortcutID(senderPeerID) + publishConversationShortcut( + shortcutID = shortcutID, + person = person, + contentIntent = intent + ) // Build notification content val contentText = if (messageCount == 1) { @@ -239,79 +271,144 @@ class NotificationManager( .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .addPerson(person) + .setShortcutId(shortcutID) + .setLocusId(LocusIdCompat(shortcutID)) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setShowWhen(true) .setWhen(latestNotification.timestamp) + val markReadIntent = Intent(context, ConversationNotificationReceiver::class.java).apply { + action = ACTION_MARK_CONVERSATION_READ + putExtra(EXTRA_PEER_ID, senderPeerID) + } + val markReadPendingIntent = PendingIntent.getBroadcast( + context, + NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 1, + markReadIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val replyIntent = Intent(context, ConversationNotificationReceiver::class.java).apply { + action = ACTION_REPLY_TO_CONVERSATION + putExtra(EXTRA_PEER_ID, senderPeerID) + putExtra(EXTRA_SENDER_NICKNAME, latestNotification.senderNickname) + } + val replyPendingIntent = PendingIntent.getBroadcast( + context, + NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode() + 2, + replyIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel(context.getString(R.string.notification_reply)) + .build() + builder + .addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_notification, + context.getString(R.string.notification_mark_read), + markReadPendingIntent + ).build() + ) + .addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_notification, + context.getString(R.string.notification_reply), + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .build() + ) + .setPublicVersion( + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_private_message)) + .setContentText(context.getString(R.string.notification_content_hidden)) + .build() + ) + // Add to notification group if we have multiple senders if (pendingNotifications.size > 1) { builder.setGroup(GROUP_KEY_DM) } - // Add style for multiple messages - if (messageCount > 1) { - val style = NotificationCompat.InboxStyle() - .setBigContentTitle(contentTitle) - - // Show last few messages in expanded view - notifications.takeLast(5).forEach { notif -> - style.addLine(notif.messageContent) - } - - if (messageCount > 5) { - style.setSummaryText("and ${messageCount - 5} more") - } - - builder.setStyle(style) - } else { - // Single message - use BigTextStyle for long messages - builder.setStyle( - NotificationCompat.BigTextStyle() - .bigText(latestNotification.messageContent) + val self = Person.Builder() + .setName(context.getString(R.string.you)) + .setKey("bitchat-self") + .build() + val messagingStyle = NotificationCompat.MessagingStyle(self) + .setGroupConversation(false) + notifications.takeLast(MAX_MESSAGES_IN_NOTIFICATION).forEach { notification -> + messagingStyle.addMessage( + notification.messageContent, + notification.timestamp, + person ) } + builder.setStyle(messagingStyle) // Use sender peer ID hash as notification ID to group messages from same sender val notificationId = senderPeerID.hashCode() - notificationManager.notify(notificationId, builder.build()) + notifySafely(notificationId, builder.build()) Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") } - private fun showNotificationForActivePeers(peersSize: Int) { - // Create intent to open the app + private fun conversationShortcutID(conversationID: String): String = + "dm_" + java.util.UUID.nameUUIDFromBytes( + conversationID.lowercase().toByteArray(Charsets.UTF_8) + ).toString() + + private fun publishConversationShortcut( + shortcutID: String, + person: Person, + contentIntent: Intent + ) { + val shortcut = ShortcutInfoCompat.Builder(context, shortcutID) + .setShortLabel(person.name?.toString()?.take(40).orEmpty()) + .setLongLived(true) + .setPerson(person) + .setLocusId(LocusIdCompat(shortcutID)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_notification)) + .setIntent(Intent(contentIntent).apply { action = Intent.ACTION_VIEW }) + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } + + fun showVerificationNotification(title: String, body: String, peerID: String? = null) { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (peerID != null) { + putExtra(EXTRA_OPEN_PRIVATE_CHAT, true) + putExtra(EXTRA_PEER_ID, peerID) + putExtra(EXTRA_SENDER_NICKNAME, body) + } } val pendingIntent = PendingIntent.getActivity( - context, - ACTIVE_PEERS_NOTIFICATION_ID, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + context, + (System.currentTimeMillis() and 0x7FFFFFFF).toInt(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) - // Build notification content - val contentTitle = "👥 bitchatters nearby!" - val contentText = if (peersSize == 1) { - "1 person around" - } else { - "$peersSize people around" - } - val builder = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(contentTitle) - .setContentText(contentText) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setShowWhen(true) - .setWhen(System.currentTimeMillis()) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) - notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build()) - Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID") + notifySafely( + (System.currentTimeMillis() and 0x7FFFFFFF).toInt(), + builder.build() + ) } + private fun showSummaryNotification() { if (pendingNotifications.isEmpty()) return @@ -331,8 +428,8 @@ class NotificationManager( val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("bitchat") - .setContentText("$totalMessages messages from $senderCount people") + .setContentTitle(context.getString(R.string.app_name)) + .setContentText(context.getString(R.string.notification_messages_from_people, totalMessages, senderCount)) .setContentIntent(pendingIntent) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -342,7 +439,7 @@ class NotificationManager( // Add inbox style showing recent senders val style = NotificationCompat.InboxStyle() - .setBigContentTitle("New Messages") + .setBigContentTitle(context.getString(R.string.notification_new_location_messages)) pendingNotifications.entries.take(5).forEach { (peerID, notifications) -> val latestNotif = notifications.last() @@ -356,12 +453,12 @@ class NotificationManager( } if (pendingNotifications.size > 5) { - style.setSummaryText("and ${pendingNotifications.size - 5} more conversations") + style.setSummaryText(context.getString(R.string.notification_more_conversations, pendingNotifications.size - 5)) } builder.setStyle(style) - notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build()) + notifySafely(SUMMARY_NOTIFICATION_ID, builder.build()) Log.d(TAG, "Displayed summary notification for $senderCount senders") } @@ -370,11 +467,15 @@ class NotificationManager( * Clear notifications for a specific sender (e.g., when user opens their chat) */ fun clearNotificationsForSender(senderPeerID: String) { - pendingNotifications.remove(senderPeerID) - - // Cancel the individual notification - val notificationId = senderPeerID.hashCode() - notificationManager.cancel(notificationId) + val conversationID = ContactDirectory.canonicalConversationId(senderPeerID) + val matchingKeys = pendingNotifications.keys.filter { key -> + ContactDirectory.canonicalConversationId(key) == conversationID + } + matchingKeys.forEach { key -> + pendingNotifications.remove(key) + notificationManager.cancel(key.hashCode()) + } + notificationManager.cancel(conversationID.hashCode()) // Update or remove summary notification if (pendingNotifications.isEmpty()) { @@ -387,7 +488,16 @@ class NotificationManager( showSummaryNotification() } - Log.d(TAG, "Cleared notifications for sender: $senderPeerID") + Log.d(TAG, "Cleared notifications for conversation: $conversationID") + } + + fun removeConversationShortcut(conversationID: String) { + val shortcutIDs = listOf(conversationShortcutID(conversationID)) + ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutIDs) + ShortcutManagerCompat.removeLongLivedShortcuts( + context, + shortcutIDs + ) } /** @@ -405,11 +515,11 @@ class NotificationManager( val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash) if (!shouldNotify) { - Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash") + Log.d(TAG, "Skipping geohash notification while viewing the channel") return } - Log.d(TAG, "Showing geohash notification for $geohash from $senderNickname (mention: $isMention, first: $isFirstMessage)") + Log.d(TAG, "Showing geohash notification (mention: $isMention, first: $isFirstMessage)") val notification = GeohashNotification( geohash = geohash, @@ -459,15 +569,15 @@ class NotificationManager( // Build notification content with location name if available val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash" val contentTitle = when { - mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> "Mentioned in $geohashDisplay (+${messageCount - 1} more)" - mentionCount > 0 -> if (mentionCount == 1) "Mentioned in $geohashDisplay" else "$mentionCount mentions in $geohashDisplay" - firstMessageCount > 0 -> "New activity in $geohashDisplay" - else -> "Messages in $geohashDisplay" + mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> context.getString(R.string.notification_mentions_in_more, geohashDisplay, messageCount - 1) + mentionCount > 0 -> if (mentionCount == 1) context.getString(R.string.notification_mentions_in, geohashDisplay) else context.getString(R.string.notification_mentions_in_plural, mentionCount, geohashDisplay) + firstMessageCount > 0 -> context.getString(R.string.notification_new_activity_in, geohashDisplay) + else -> context.getString(R.string.notification_messages_in, geohashDisplay) } val contentText = when { latestNotification.isMention -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}" - latestNotification.isFirstMessage -> "${latestNotification.senderNickname} joined the conversation" + latestNotification.isFirstMessage -> context.getString(R.string.notification_joined_conversation, latestNotification.senderNickname) else -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}" } @@ -503,7 +613,8 @@ class NotificationManager( } if (messageCount > 5) { - style.setSummaryText("and ${messageCount - 5} more") + val extra = messageCount - 5 + style.setSummaryText(context.resources.getQuantityString(R.plurals.notification_and_more, extra, extra)) } builder.setStyle(style) @@ -517,7 +628,7 @@ class NotificationManager( // Use geohash hash as notification ID to group messages from same geohash val notificationId = 3000 + geohash.hashCode() - notificationManager.notify(notificationId, builder.build()) + notifySafely(notificationId, builder.build()) Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId") } @@ -543,12 +654,12 @@ class NotificationManager( ) val contentTitle = if (totalMentions > 0) { - "bitchat - $totalMentions mentions" + context.getString(R.string.notification_geohash_summary_title_mentions, totalMentions) } else { - "bitchat - location chats" + context.getString(R.string.notification_geohash_summary_title) } - val contentText = "$totalMessages messages from $geohashCount locations" + val contentText = context.getString(R.string.notification_geohash_summary_text, totalMessages, geohashCount) val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) @@ -563,7 +674,7 @@ class NotificationManager( // Add inbox style showing recent geohashes val style = NotificationCompat.InboxStyle() - .setBigContentTitle("New Location Messages") + .setBigContentTitle(context.getString(R.string.notification_new_messages)) pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) -> val mentionCount = notifications.count { it.isMention } @@ -579,12 +690,12 @@ class NotificationManager( } if (pendingGeohashNotifications.size > 5) { - style.setSummaryText("and ${pendingGeohashNotifications.size - 5} more locations") + style.setSummaryText(context.getString(R.string.notification_more_locations, pendingGeohashNotifications.size - 5)) } builder.setStyle(style) - notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build()) + notifySafely(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build()) Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations") } @@ -610,7 +721,7 @@ class NotificationManager( showGeohashSummaryNotification() } - Log.d(TAG, "Cleared notifications for geohash: $geohash") + Log.d(TAG, "Cleared notifications for geohash") } /** @@ -676,9 +787,9 @@ class NotificationManager( // Build notification content val contentTitle = if (messageCount == 1) { - "Mentioned in Mesh Chat" + context.getString(R.string.notification_mesh_mention_title_singular) } else { - "$messageCount mentions in Mesh Chat" + context.getString(R.string.notification_mesh_mention_title_plural, messageCount) } val contentText = "${latestNotification.senderNickname}: ${latestNotification.messageContent}" @@ -710,7 +821,8 @@ class NotificationManager( } if (messageCount > 5) { - style.setSummaryText("and ${messageCount - 5} more") + val extra = messageCount - 5 + style.setSummaryText(context.resources.getQuantityString(R.plurals.notification_and_more, extra, extra)) } builder.setStyle(style) @@ -724,7 +836,7 @@ class NotificationManager( // Use a special notification ID for mesh mentions val notificationId = 4000 // Different from DM and geohash IDs - notificationManager.notify(notificationId, builder.build()) + notifySafely(notificationId, builder.build()) Log.d(TAG, "Displayed mesh mention notification: $contentTitle") } @@ -756,13 +868,37 @@ class NotificationManager( /** * Clear all pending notifications */ - fun clearAllNotifications() { + fun clearAllNotifications(removeConversationShortcuts: Boolean = false) { pendingNotifications.clear() notificationManager.cancelAll() pendingGeohashNotifications.clear() + if (removeConversationShortcuts) { + val shortcutIDs = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id } + ShortcutManagerCompat.removeAllDynamicShortcuts(context) + if (shortcutIDs.isNotEmpty()) { + ShortcutManagerCompat.removeLongLivedShortcuts(context, shortcutIDs) + } + } Log.d(TAG, "Cleared all notifications") } + private fun notifySafely(notificationID: Int, notification: android.app.Notification) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + try { + notificationManager.notify(notificationID, notification) + } catch (error: SecurityException) { + Log.w(TAG, "Notification permission was revoked: ${error.message}") + } + } + /** * Get pending notification count for UI badging */ diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationTextUtils.kt b/app/src/main/java/com/bitchat/android/ui/NotificationTextUtils.kt new file mode 100644 index 00000000..a80a2ea6 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/NotificationTextUtils.kt @@ -0,0 +1,48 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType + +/** + * Utilities for building human-friendly notification text/previews. + */ +object NotificationTextUtils { + /** + * Build a user-friendly notification preview for private messages, especially attachments. + * Examples: + * - Image: "📷 sent an image" + * - Audio: "🎤 sent a voice message" + * - File (pdf): "📄 file.pdf" + * - Text: original message content + */ + fun buildPrivateMessagePreview(message: BitchatMessage): String { + return try { + when (message.type) { + BitchatMessageType.Image -> "📷 sent an image" + BitchatMessageType.Audio -> "🎤 sent a voice message" + BitchatMessageType.File -> { + // Show just the filename (not the full path) + val name = try { java.io.File(message.content).name } catch (_: Exception) { null } + if (!name.isNullOrBlank()) { + val lower = name.lowercase() + val icon = when { + lower.endsWith(".pdf") -> "📄" + lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".7z") -> "🗜️" + lower.endsWith(".doc") || lower.endsWith(".docx") -> "📄" + lower.endsWith(".xls") || lower.endsWith(".xlsx") -> "📊" + lower.endsWith(".ppt") || lower.endsWith(".pptx") -> "📈" + else -> "📎" + } + "$icon $name" + } else { + "📎 sent a file" + } + } + else -> message.content + } + } catch (_: Exception) { + // Fallback to original content on any error + message.content + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt new file mode 100644 index 00000000..a3914c90 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt @@ -0,0 +1,28 @@ +package com.bitchat.android.ui + +import android.content.pm.ActivityInfo +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.bitchat.android.utils.DeviceUtils + +/** + * Base activity that automatically sets orientation based on device type. + * Tablets can rotate to landscape, phones are locked to portrait. + */ +abstract class OrientationAwareActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setOrientationBasedOnDeviceType() + } + + private fun setOrientationBasedOnDeviceType() { + requestedOrientation = if (DeviceUtils.isTablet(this)) { + // Allow all orientations on tablets + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } else { + // Lock to portrait on phones + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/PeerAvatar.kt b/app/src/main/java/com/bitchat/android/ui/PeerAvatar.kt new file mode 100644 index 00000000..bd2a5e97 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerAvatar.kt @@ -0,0 +1,131 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.bitchat.android.R +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.LocalBitchatPalette + +internal val PeerAvatarBadgeSize = 18.dp +private val PeerAvatarStarSize = 16.dp +private val PeerAvatarVerifiedSize = 16.dp + +@Composable +internal fun PeerAvatar( + name: String, + color: Color, + modifier: Modifier = Modifier, + isFavorite: Boolean = false, + theyFavoritedUs: Boolean = false, + isVerified: Boolean = false, + badge: (@Composable () -> Unit)? = null +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + Box( + modifier = modifier.size(42.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(38.dp) + .background(color.copy(alpha = 0.16f), CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = name.trim().firstOrNull()?.uppercase() ?: "#", + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold + ), + color = color + ) + } + + if (badge != null) { + Surface( + modifier = Modifier + .size(PeerAvatarBadgeSize) + .align(Alignment.BottomEnd), + shape = CircleShape, + color = colorScheme.surface, + tonalElevation = 1.dp + ) { + Box(contentAlignment = Alignment.Center) { + badge() + } + } + } + + if (isFavorite || theyFavoritedUs) { + Surface( + modifier = Modifier + .size(PeerAvatarStarSize) + .align(Alignment.TopEnd), + shape = CircleShape, + color = colorScheme.surface, + tonalElevation = 1.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = stringResource( + if (isFavorite) { + R.string.cd_favorite + } else { + R.string.cd_favorited_you + } + ), + modifier = Modifier.size(10.dp), + tint = palette.accentOrange + ) + } + } + } + + if (isVerified) { + Surface( + modifier = Modifier + .size(PeerAvatarVerifiedSize) + .align(Alignment.TopStart), + shape = CircleShape, + color = colorScheme.surface, + tonalElevation = 1.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource( + R.string.fingerprint_verified_label + ), + modifier = Modifier.size(12.dp), + tint = colorScheme.primary + ) + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt new file mode 100644 index 00000000..b6eac7cd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Stable, presentation-neutral identity used to derive a peer hue. + * + * ViewModels may expose this value, but only the UI theme resolves it to a rendered color. + */ +@JvmInline +value class PeerColorSeed(val value: String) + +fun meshPeerColorSeed(peerID: String): PeerColorSeed = + PeerColorSeed("noise:${peerID.lowercase(Locale.ROOT)}") + +fun nostrPeerColorSeed(pubkeyHex: String): PeerColorSeed = + PeerColorSeed("nostr:${pubkeyHex.lowercase(Locale.ROOT)}") + +fun peerColorSeedForMessage(message: BitchatMessage): PeerColorSeed { + val value = when { + message.senderPeerID?.startsWith("nostr:") == true || + message.senderPeerID?.startsWith("nostr_") == true -> { + "nostr:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + message.senderPeerID?.length == 16 || message.senderPeerID?.length == 64 -> { + "noise:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + else -> message.sender.lowercase(Locale.ROOT) + } + + return PeerColorSeed(value) +} diff --git a/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt new file mode 100644 index 00000000..47dd5629 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Canonical, presentation-neutral identity used to derive a peer hue. + * + * Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced + * here so the same mesh or Nostr user resolves to the same color on every surface. + */ +@JvmInline +value class PeerIdentity private constructor(internal val stableKey: String) { + companion object { + fun mesh(peerID: String): PeerIdentity = + PeerIdentity("noise:${normalize(peerID)}") + + /** + * Preserve the established geohash-chat color mapping while accepting a full public key. + * + * Older chat rendering hashed `nostr:nostr:`. Keeping that visual + * key here avoids recoloring existing conversations; the important change is that every + * surface now derives it from the same canonical Nostr identity. + */ + fun nostr(pubkeyHex: String): PeerIdentity { + val normalized = normalizeNostrIdentifier(pubkeyHex) + return PeerIdentity("nostr:nostr:${normalized.take(8)}") + } + + /** + * Last-resort identity for legacy messages and mentions that carry no stable peer ID. + */ + fun nickname(nickname: String): PeerIdentity = + PeerIdentity(normalize(nickname)) + + private fun normalize(value: String): String = + value.trim().lowercase(Locale.ROOT) + + private fun normalizeNostrIdentifier(value: String): String { + var normalized = normalize(value) + while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) { + normalized = normalized.removePrefix("nostr:").removePrefix("nostr_") + } + return normalized + } + } +} + +/** + * Resolve the canonical identity attached to a rendered message. + * + * Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a + * compatibility fallback for messages saved by older app versions. + */ +fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity { + message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let { + return PeerIdentity.nostr(it) + } + + val senderPeerID = message.senderPeerID + return when { + senderPeerID?.startsWith("nostr:", ignoreCase = true) == true || + senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> { + PeerIdentity.nostr(senderPeerID) + } + senderPeerID?.length == 16 || senderPeerID?.length == 64 -> { + PeerIdentity.mesh(senderPeerID) + } + else -> PeerIdentity.nickname(message.sender) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt new file mode 100644 index 00000000..01c9c6ca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale + +/** + * Press feedback for the app's icon buttons. + * + * Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost + * nothing to show. A brief scale dip is legible on any background and reads as physical. Springs + * rather than tweens, so releasing overshoots very slightly instead of stopping dead. + */ +@Composable +fun rememberPressScale( + interactionSource: InteractionSource, + pressedScale: Float = 0.86f +): Float { + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) pressedScale else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "pressScale" + ) + return scale +} + +/** + * Convenience wrapper for the common case: a clickable that scales while held. + * + * Returns the modifier chain to apply, having disabled the default indication — the scale *is* the + * indication, and a ripple underneath it just muddies the edges of these small glyphs. + */ +@Composable +fun Modifier.pressScaleClickable( + onClick: () -> Unit, + enabled: Boolean = true, + onClickLabel: String? = null, + pressedScale: Float = 0.86f +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + val scale = rememberPressScale(interactionSource, pressedScale) + return this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + onClick = onClick + ) + .scale(scale) +} diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index cf549d76..12678625 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -1,9 +1,13 @@ package com.bitchat.android.ui +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.PeerFingerprintManager -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver + import java.util.* import android.util.Log @@ -17,22 +21,28 @@ interface NoiseSessionDelegate { fun getMyPeerID(): String } +enum class PrivateMessageOrigin { + MESH, + NOSTR +} + /** - * Handles private chat functionality including peer management and blocking - * Now uses centralized PeerFingerprintManager for all fingerprint operations + * Handles private chat functionality including peer management and blocking. */ class PrivateChatManager( private val state: ChatState, private val messageManager: MessageManager, private val dataManager: DataManager, - private val noiseSessionDelegate: NoiseSessionDelegate + private val noiseSessionDelegate: NoiseSessionDelegate, + private val trackUnreadMessages: Boolean = true, + private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false }, + private val markMessageReadLocally: (messageID: String) -> Unit = {} ) { companion object { private const val TAG = "PrivateChatManager" } - // Use centralized fingerprint management - NO LOCAL STORAGE private val fingerprintManager = PeerFingerprintManager.getInstance() // Track received private messages that need read receipts @@ -40,9 +50,17 @@ class PrivateChatManager( // MARK: - Private Chat Lifecycle - fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean { + fun startPrivateChat( + peerID: String, + meshService: MeshService, + unreadAliases: Set = emptySet() + ): Boolean { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val route = ContactDirectory.resolve(conversationID) + val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } + if (isPeerBlocked(peerID)) { - val peerNickname = getPeerNickname(peerID, meshService) + val peerNickname = route.displayName ?: getPeerNickname(peerID, meshService) val systemMessage = BitchatMessage( sender = "system", content = "cannot start chat with $peerNickname: user is blocked.", @@ -53,24 +71,24 @@ class PrivateChatManager( return false } - // Establish Noise session if needed before starting the chat - establishNoiseSessionIfNeeded(peerID, meshService) + if (meshPeerID != null && meshService.getPeerInfo(meshPeerID)?.isConnected == true) { + establishNoiseSessionIfNeeded(meshPeerID, meshService) + } - // Consolidate any temporary Nostr conversation for this peer into the stable/current peerID try { - consolidateNostrTempConversationIfNeeded(peerID) + consolidateNostrTempConversationIfNeeded(conversationID, meshService) } catch (_: Exception) { } - state.setSelectedPrivateChatPeer(peerID) + state.setSelectedPrivateChatPeer(conversationID) // Clear unread - messageManager.clearPrivateUnreadMessages(peerID) + messageManager.clearPrivateUnreadMessages(conversationID, unreadAliases) // Initialize chat if needed - messageManager.initializePrivateChat(peerID) + messageManager.initializePrivateChat(conversationID) // Send read receipts for all unread messages from this peer - sendReadReceiptsForPeer(peerID, meshService) + sendReadReceiptsForPeer(conversationID, meshPeerID, meshService) return true } @@ -87,6 +105,7 @@ class PrivateChatManager( myPeerID: String, onSendMessage: (String, String, String, String) -> Unit ): Boolean { + val conversationID = ContactDirectory.canonicalConversationId(peerID) if (isPeerBlocked(peerID)) { val systemMessage = BitchatMessage( sender = "system", @@ -109,35 +128,97 @@ class PrivateChatManager( deliveryStatus = DeliveryStatus.Sending ) - messageManager.addPrivateMessage(peerID, message) - onSendMessage(content, peerID, recipientNickname ?: "", message.id) + messageManager.addPrivateMessage(conversationID, message) + onSendMessage(content, conversationID, recipientNickname ?: "", message.id) return true } + /** + * Persists the local echo before handing the payload to a transport. + * + * A failed database write deliberately aborts the send: otherwise the remote peer could + * receive a message that disappears from the sender's conversation after process death. + */ + suspend fun sendPrivateMessageDurably( + content: String, + peerID: String, + recipientNickname: String?, + senderNickname: String?, + myPeerID: String, + onSendMessage: (String, String, String, String) -> Unit + ): Boolean { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + if (isPeerBlocked(peerID)) { + val systemMessage = BitchatMessage( + sender = "system", + content = "cannot send message to $recipientNickname: user is blocked.", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(systemMessage) + return false + } + + val message = BitchatMessage( + sender = senderNickname ?: myPeerID, + content = content, + timestamp = Date(), + isRelay = false, + isPrivate = true, + recipientNickname = recipientNickname, + senderPeerID = myPeerID, + deliveryStatus = DeliveryStatus.Sending + ) + + if (!messageManager.addPrivateMessageDurably(conversationID, message, forceRead = true)) { + return false + } + onSendMessage(content, conversationID, recipientNickname ?: "", message.id) + return true + } + // MARK: - Peer Management fun isPeerBlocked(peerID: String): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) + ?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID)) + ?: ContactDirectory.resolve(peerID).noisePublicKey?.let { ContactIdentityResolver.fingerprintHex(it) } return fingerprint != null && dataManager.isUserBlocked(fingerprint) } fun toggleFavorite(peerID: String) { - val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return + var fingerprint = fingerprintManager.getFingerprintForPeer(peerID) + ?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID)) + + if (fingerprint == null && ContactIdentityResolver.isNoiseKeyHex(peerID)) { + try { + val pubBytes = ContactIdentityResolver.bytesFromHex(peerID) ?: return + fingerprint = ContactIdentityResolver.fingerprintHex(pubBytes) + Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint") + } catch (e: Exception) { + Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}") + } + } + + if (fingerprint == null) { + Log.w(TAG, "toggleFavorite: no fingerprint for peerID=$peerID; ignoring toggle") + return + } Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint") - val wasFavorite = dataManager.isFavorite(fingerprint) + val wasFavorite = dataManager.isFavorite(fingerprint!!) Log.d(TAG, "Current favorite status: $wasFavorite") val currentFavorites = state.getFavoritePeersValue() Log.d(TAG, "Current UI state favorites: $currentFavorites") if (wasFavorite) { - dataManager.removeFavorite(fingerprint) + dataManager.removeFavorite(fingerprint!!) Log.d(TAG, "Removed from favorites: $fingerprint") } else { - dataManager.addFavorite(fingerprint) + dataManager.addFavorite(fingerprint!!) Log.d(TAG, "Added to favorites: $fingerprint") } @@ -149,11 +230,28 @@ class PrivateChatManager( Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}") } + fun isFavorite(peerID: String): Boolean { - val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false - val isFav = dataManager.isFavorite(fingerprint) - Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav") - return isFav + val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) + ?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID)) + ?: if (ContactIdentityResolver.isNoiseKeyHex(peerID)) { + ContactIdentityResolver.bytesFromHex(peerID)?.let { ContactIdentityResolver.fingerprintHex(it) } + } else { + null + } + + if (fingerprint != null && dataManager.isFavorite(fingerprint)) { + Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=true") + return true + } + + val persistedFavorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isFavorite == true + } catch (_: Exception) { + false + } + Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$persistedFavorite") + return persistedFavorite } fun getPeerFingerprint(peerID: String): String? { @@ -166,7 +264,7 @@ class PrivateChatManager( // MARK: - Block/Unblock Operations - fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean { + fun blockPeer(peerID: String, meshService: MeshService): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) if (fingerprint != null) { dataManager.addBlockedUser(fingerprint) @@ -190,7 +288,7 @@ class PrivateChatManager( return false } - fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean { + fun unblockPeer(peerID: String, meshService: MeshService): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) { dataManager.removeBlockedUser(fingerprint) @@ -208,7 +306,7 @@ class PrivateChatManager( return false } - fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean { + fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean { val peerID = getPeerIDForNickname(targetName, meshService) if (peerID != null) { @@ -225,7 +323,7 @@ class PrivateChatManager( } } - fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean { + fun unblockPeerByNickname(targetName: String, meshService: MeshService): Boolean { val peerID = getPeerIDForNickname(targetName, meshService) if (peerID != null) { @@ -266,61 +364,166 @@ class PrivateChatManager( // MARK: - Message Handling fun handleIncomingPrivateMessage(message: BitchatMessage) { - handleIncomingPrivateMessage(message, suppressUnread = false) + handleIncomingPrivateMessage( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.MESH + ) } - fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) { - message.senderPeerID?.let { senderPeerID -> + fun handleIncomingPrivateMessage( + message: BitchatMessage, + suppressUnread: Boolean, + origin: PrivateMessageOrigin = PrivateMessageOrigin.MESH + ) { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val conversationID = ContactDirectory.canonicalConversationId(senderPeerID) + // Mesh-origin private message: AppStateStore updates the list; avoid double-add here. if (!isPeerBlocked(senderPeerID)) { - // Add to private messages - if (suppressUnread) { - messageManager.addPrivateMessageNoUnread(senderPeerID, message) - } else { - messageManager.addPrivateMessage(senderPeerID, message) + // Ensure chat exists + messageManager.initializePrivateChat(conversationID) + + // Mesh messages are already reflected through AppStateStore by the mesh service. + // Nostr messages originate here and must be added explicitly, even after their + // sender alias has canonicalized to a contact_* conversation ID. + if (origin == PrivateMessageOrigin.NOSTR) { + if (suppressUnread || !trackUnreadMessages) { + messageManager.addPrivateMessageNoUnread(conversationID, message) + } else { + messageManager.addPrivateMessage(conversationID, message) + } } - // Track as unread for read receipt purposes - var unreadCount = 0 - if (!suppressUnread) { - val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } + // Track as unread for read receipt purposes if not focused + if (trackUnreadMessages && + !suppressUnread && + state.getSelectedPrivateChatPeerValue() != conversationID + ) { + val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() } unreadList.add(message) - unreadCount = unreadList.size + Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})") + val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() + currentUnread.add(conversationID) + state.setUnreadPrivateMessages(currentUnread) } - - Log.d( - TAG, - "Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)" - ) } + return } + // Non-mesh path (e.g., Nostr): add to UI state using existing logic + val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return + if (suppressUnread) { + messageManager.addPrivateMessageNoUnread(inferredPeer, message) + } else { + messageManager.addPrivateMessage(inferredPeer, message) + } + } + + /** + * Durable admission for transports that do not pass through the mesh admission pipeline. + * + * Nostr acknowledgements are emitted by the caller only after this returns true, which lets a + * failed write be retried rather than silently acknowledging a message that was never saved. + */ + suspend fun handleIncomingPrivateMessageDurably( + message: BitchatMessage, + suppressUnread: Boolean, + origin: PrivateMessageOrigin + ): Boolean { + val senderPeerID = message.senderPeerID + val conversationID = senderPeerID + ?.let(ContactDirectory::canonicalConversationId) + ?: state.getSelectedPrivateChatPeerValue() + ?: return false + + if (senderPeerID != null && isPeerBlocked(senderPeerID)) return false + messageManager.initializePrivateChat(conversationID) + + val shouldPersistHere = origin == PrivateMessageOrigin.NOSTR || senderPeerID == null + if (shouldPersistHere) { + val accepted = messageManager.addPrivateMessageDurably( + peerID = conversationID, + message = message, + forceRead = suppressUnread || !trackUnreadMessages + ) + if (!accepted) return false + } + + if ( + senderPeerID != null && + trackUnreadMessages && + !suppressUnread && + state.getSelectedPrivateChatPeerValue() != conversationID + ) { + val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() } + unreadList.add(message) + val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() + currentUnread.add(conversationID) + state.setUnreadPrivateMessages(currentUnread) + } + return true } /** * Send read receipts for all unread messages from a specific peer * Called when the user focuses on a private chat */ - fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) { - val unreadList = unreadReceivedMessages[peerID] - if (unreadList.isNullOrEmpty()) { - Log.d(TAG, "No unread messages to send read receipts for peer $peerID") - return + fun sendReadReceiptsForPeer( + conversationID: String, + meshPeerID: String?, + meshService: MeshService + ) { + val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID) + + // Collect candidate messages: all incoming messages from this peer in the conversation + val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap>() } + val messages = chats[canonicalConversationID].orEmpty() + + if (messages.isEmpty()) { + Log.d(TAG, "No messages found for conversation $canonicalConversationID to send read receipts") } - Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID") - - // Send read receipt for each unread message - now using direct method call - unreadList.forEach { message -> - try { - val myNickname = state.getNicknameValue() ?: "unknown" - meshService.sendReadReceipt(message.id, peerID, myNickname) - Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") - } catch (e: Exception) { - Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") + val myNickname = state.getNicknameValue() ?: "unknown" + val hasMesh = meshPeerID != null && try { + meshService.getPeerInfo(meshPeerID)?.isConnected == true && + meshService.hasEstablishedSession(meshPeerID) + } catch (_: Exception) { + false + } + var sentCount = 0 + messages.forEach { msg -> + val senderPeerID = msg.senderPeerID + val isFromTarget = senderPeerID != null && ( + senderPeerID == meshPeerID || + ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID + ) + if (isFromTarget) { + try { + markMessageReadLocally(msg.id) + } catch (e: Exception) { + Log.w(TAG, "Failed to persist local read for message ${msg.id}: ${e.message}") + } + } + if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) { + try { + if (hasMesh) { + meshService.sendReadReceipt(msg.id, meshPeerID, myNickname) + sentCount += 1 + } + } catch (e: Exception) { + Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") + } } } - // Clear the unread list since we've sent read receipts - unreadReceivedMessages.remove(peerID) + // Clear any locally tracked unread queue for this peer + unreadReceivedMessages.remove(canonicalConversationID) + // Also clear UI unread marker for this peer now that chat is focused/read + try { messageManager.clearPrivateUnreadMessages(canonicalConversationID) } catch (_: Exception) { } + Log.d( + TAG, + "Sent $sentCount read receipts for conversation $canonicalConversationID via mesh peer $meshPeerID" + ) } fun cleanupDisconnectedPeer(peerID: String) { @@ -340,7 +543,7 @@ class PrivateChatManager( * Establish Noise session if needed before starting private chat * Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement */ - private fun establishNoiseSessionIfNeeded(peerID: String, meshService: BluetoothMeshService) { + private fun establishNoiseSessionIfNeeded(peerID: String, meshService: MeshService) { if (noiseSessionDelegate.hasEstablishedSession(peerID)) { Log.d(TAG, "Noise session already established with $peerID") return @@ -365,152 +568,55 @@ class PrivateChatManager( "Our peer ID lexicographically >= target peer ID, sending identity announcement to prompt handshake from $peerID" ) meshService.sendAnnouncementToPeer(peerID) + Log.d(TAG, "Sent identity announcement to $peerID – starting handshake now from our side") + noiseSessionDelegate.initiateHandshake(peerID) } } -// /** -// * Legacy reflection-based implementation for backward compatibility -// */ -// private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) { -// try { -// // Check if we already have an established Noise session with this peer -// val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java) -// val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean -// -// if (hasSession) { -// Log.d(TAG, "Noise session already established with $peerID") -// return -// } -// -// Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake") -// -// // Get our peer ID from mesh service for lexicographical comparison -// val myPeerIDField = meshService::class.java.getField("myPeerID") -// val myPeerID = myPeerIDField.get(meshService) as String -// -// // Use lexicographical comparison to decide who initiates (same logic as MessageHandler) -// if (myPeerID < peerID) { -// // We should initiate the handshake -// Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID") -// initiateHandshakeWithPeer(peerID, meshService) -// } else { -// // They should initiate, we send a Noise identity announcement -// Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID") -// sendNoiseIdentityAnnouncement(meshService) -// } -// -// } catch (e: Exception) { -// Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}") -// } -// } - - /** - * Initiate handshake with specific peer using the existing delegate pattern - */ - private fun initiateHandshakeWithPeer(peerID: String, meshService: Any) { - try { - // Use the existing MessageHandler delegate approach to initiate handshake - // This calls the same code that's in MessageHandler's delegate.initiateNoiseHandshake() - val messageHandler = meshService::class.java.getDeclaredField("messageHandler") - messageHandler.isAccessible = true - val handler = messageHandler.get(meshService) - - val delegate = handler::class.java.getDeclaredField("delegate") - delegate.isAccessible = true - val handlerDelegate = delegate.get(handler) - - val method = - handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java) - method.invoke(handlerDelegate, peerID) - - Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern") - } catch (e: Exception) { - Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}") - } - } - - /** - * Send Noise identity announcement to prompt other peer to initiate handshake - * This follows the same pattern as broadcastNoiseIdentityAnnouncement() in BluetoothMeshService - */ - private fun sendNoiseIdentityAnnouncement(meshService: Any) { - try { - // Call broadcastNoiseIdentityAnnouncement which sends a NoiseIdentityAnnouncement - val method = - meshService::class.java.getDeclaredMethod("broadcastNoiseIdentityAnnouncement") - method.invoke(meshService) - Log.d(TAG, "Successfully sent Noise identity announcement") - } catch (e: Exception) { - Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}") - } - } - // MARK: - Utility Functions - private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { + private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { + private fun getPeerNickname(peerID: String, meshService: MeshService): String { return meshService.getPeerNicknames()[peerID] ?: peerID } // MARK: - Consolidation - private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String) { - // If target is a mesh/noise-based peerID, merge any messages from its temp Nostr key - if (targetPeerID.startsWith("nostr_")) return + private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String, meshService: MeshService) { + val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID) + if (ContactIdentityResolver.isNostrAlias(targetPeerID)) return - // Find favorites mapping and corresponding temp key val tryMergeKeys = mutableListOf() - - // If we know the sender's Nostr pubkey for this peer via favorites, derive temp key - try { - val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes) - if (npub != null) { - // Normalize to hex to match how we formed temp keys (nostr_) - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) - if (hrp == "npub") { - val pubHex = data.joinToString("") { "%02x".format(it) } - tryMergeKeys.add("nostr_${pubHex.take(16)}") - } - } - } catch (_: Exception) { } - - // Also merge any directly-addressed temp key used by incoming messages (without mapping yet) - // Search existing chats for keys that begin with "nostr_" and have messages from the same nickname - state.getPrivateChatsValue().keys.filter { it.startsWith("nostr_") }.forEach { tempKey -> - if (!tryMergeKeys.contains(tempKey)) tryMergeKeys.add(tempKey) + val noiseKey = when { + ContactIdentityResolver.isNoiseKeyHex(targetPeerID) -> + ContactIdentityResolver.bytesFromHex(targetPeerID) + ContactIdentityResolver.isMeshPeerId(targetPeerID) -> + meshService.getPeerInfo(targetPeerID)?.noisePublicKey + else -> null } - if (tryMergeKeys.isEmpty()) return - - val currentChats = state.getPrivateChatsValue().toMutableMap() - val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf() - - var didMerge = false - tryMergeKeys.forEach { tempKey -> - val tempList = currentChats[tempKey] - if (!tempList.isNullOrEmpty()) { - targetList.addAll(tempList) - currentChats.remove(tempKey) - didMerge = true + if (noiseKey != null) { + val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey) + if (!noiseHex.equals(targetPeerID, ignoreCase = true)) { + tryMergeKeys.add(noiseHex) } + try { + FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) + ?.let { ContactIdentityResolver.nostrAliasForPubkey(it) } + ?.let { tryMergeKeys.add(it) } + } catch (_: Exception) { } } - if (didMerge) { - currentChats[targetPeerID] = targetList - state.setPrivateChats(currentChats) - - // Also remove unread flag from temp keys and apply to target - val unread = state.getUnreadPrivateMessagesValue().toMutableSet() - val hadUnread = tryMergeKeys.any { unread.remove(it) } - if (hadUnread) { - unread.add(targetPeerID) - state.setUnreadPrivateMessages(unread) - } + if (tryMergeKeys.isNotEmpty()) { + com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = targetConversationID, + keysToMerge = tryMergeKeys + ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt b/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt new file mode 100644 index 00000000..6603795a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PrivateMediaRecipientResolver.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver + +internal data class PrivateMediaRecipient( + val conversationID: String, + val meshPeerID: String +) + +/** + * Private-chat state is keyed by a stable contact/conversation ID, while mesh + * encryption and transport APIs require the current 16-hex peer ID. + */ +internal object PrivateMediaRecipientResolver { + fun resolve(requestedRecipientID: String, meshService: MeshService): PrivateMediaRecipient? { + val requested = requestedRecipientID.trim() + val conversationID = ContactDirectory.canonicalConversationId(requested) + + val directoryPeerID = runCatching { + ContactDirectory.resolve(requested).meshPeerID + }.getOrNull() + val directPeerID = requested.takeIf(ContactIdentityResolver::isMeshPeerId) + val expectedFingerprint = + ContactIdentityResolver.fingerprintFromContactConversationId(conversationID) + ?: requested + .takeIf(ContactIdentityResolver::isNoiseKeyHex) + ?.let(ContactIdentityResolver::bytesFromHex) + ?.let(ContactIdentityResolver::fingerprintHex) + val discoveredPeerID = expectedFingerprint?.let { fingerprint -> + runCatching { + meshService.getPeerNicknames().keys.firstOrNull { candidatePeerID -> + val info = meshService.getPeerInfo(candidatePeerID) + val noisePublicKey = info?.noisePublicKey + info?.isConnected == true && + noisePublicKey != null && + ContactIdentityResolver.fingerprintHex(noisePublicKey) + .equals(fingerprint, ignoreCase = true) + } + }.getOrNull() + } + + val meshPeerID = (directoryPeerID ?: directPeerID ?: discoveredPeerID) + ?.takeIf(ContactIdentityResolver::isMeshPeerId) + ?: return null + return PrivateMediaRecipient(conversationID, meshPeerID) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt new file mode 100644 index 00000000..71e59f0d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -0,0 +1,445 @@ +package com.bitchat.android.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.NoEncryption +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning as OutlinedWarning +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +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.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.services.ContactDirectory + +private data class SecurityStatusInfo( + val text: String, + val icon: ImageVector, + val tint: Color +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SecurityVerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + + val colorScheme = MaterialTheme.colorScheme + val accent = colorScheme.primary + val boxColor = colorScheme.surfaceVariant + val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } + + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SecurityVerificationHeader( + accent = accent, + onClose = onDismiss + ) + + if (peerID == null) { + Text( + text = stringResource(R.string.fingerprint_no_peer), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } else { + val selectedPeerID = peerID!! + val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID) + val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID) + val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint) + val activeMeshPeerID = ContactDirectory.resolve(selectedPeerID).meshPeerID + val sessionState = resolveConversationSessionState( + conversationID = selectedPeerID, + activeMeshPeerID = activeMeshPeerID, + peerSessionStates = peerSessionStates + ) + val statusInfo = buildStatusInfo( + isVerified = isVerified, + sessionState = sessionState, + accent = accent + ) + + SecurityStatusCard( + displayName = displayName, + accent = accent, + boxColor = boxColor, + statusInfo = statusInfo + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_their), + fingerprint = fingerprint, + boxColor = boxColor, + accent = accent + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_yours), + fingerprint = viewModel.getMyFingerprint(), + boxColor = boxColor, + accent = accent + ) + + SecurityVerificationActions( + isVerified = isVerified, + fingerprint = fingerprint, + displayName = displayName, + accent = accent, + canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex), + onStartHandshake = { viewModel.initiateMeshHandshake(selectedPeerID) }, + onVerify = { fp -> viewModel.verifyFingerprintValue(fp) }, + onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) } + ) + } + } + } +} + +@Composable +private fun SecurityVerificationHeader( + accent: Color, + onClose: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.security_verification_title), + style = MaterialTheme.typography.titleSmall.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Spacer(modifier = Modifier.weight(1f)) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) + } +} + +@Composable +private fun buildStatusInfo( + isVerified: Boolean, + sessionState: String?, + accent: Color +): SecurityStatusInfo { + val text = when { + isVerified -> stringResource(R.string.fingerprint_status_verified) + sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted) + sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking) + sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed) + else -> stringResource(R.string.fingerprint_status_uninitialized) + } + val icon = when { + isVerified -> Icons.Filled.Verified + sessionState == "handshaking" -> Icons.Outlined.Sync + sessionState == "failed" -> Icons.Outlined.OutlinedWarning + sessionState == "established" -> Icons.Filled.Lock + else -> Icons.Outlined.NoEncryption + } + val tint = when { + isVerified -> Color(0xFF32D74B) + sessionState == "failed" -> Color(0xFFFF3B30) + sessionState == "handshaking" -> Color(0xFFFF9500) + sessionState == "established" -> Color(0xFF32D74B) + else -> accent.copy(alpha = 0.6f) + } + return SecurityStatusInfo(text, icon, tint) +} + +@Composable +private fun SecurityStatusCard( + displayName: String, + accent: Color, + boxColor: Color, + statusInfo: SecurityStatusInfo +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(boxColor, shape = MaterialTheme.shapes.medium) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = statusInfo.icon, + contentDescription = null, + tint = statusInfo.tint + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Text( + text = statusInfo.text, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily + ), + color = accent.copy(alpha = 0.8f) + ) + } + } +} + +@Composable +private fun SecurityVerificationActions( + isVerified: Boolean, + fingerprint: String?, + displayName: String, + accent: Color, + canStartHandshake: Boolean, + onStartHandshake: () -> Unit, + onVerify: (String) -> Unit, + onUnverify: (String) -> Unit +) { + if (canStartHandshake) { + Button( + onClick = onStartHandshake, + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_start_handshake), + fontFamily = BitchatFontFamily, + fontSize = 12.sp + ) + } + } + + if (isVerified) { + VerificationStatusRow( + icon = Icons.Filled.Verified, + iconTint = Color(0xFF32D74B), + text = stringResource(R.string.fingerprint_verified_label), + textTint = Color(0xFF32D74B) + ) + Text( + text = stringResource(R.string.fingerprint_verified_message), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Button( + onClick = { fingerprint?.let(onUnverify) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF3B30), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = BitchatFontFamily, + fontSize = 12.sp + ) + } + } else { + VerificationStatusRow( + icon = Icons.Filled.Warning, + iconTint = Color(0xFFFF9500), + text = stringResource(R.string.fingerprint_not_verified_label), + textTint = Color(0xFFFF9500) + ) + Text( + text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + if (fingerprint != null) { + Button( + onClick = { onVerify(fingerprint) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF34C759), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_mark_verified), + fontFamily = BitchatFontFamily, + fontSize = 12.sp + ) + } + } + } +} + +@Composable +private fun VerificationStatusRow( + icon: ImageVector, + iconTint: Color, + text: String, + textTint: Color +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold + ), + color = textTint + ) + } +} + +@Composable +private fun FingerprintBlock( + title: String, + fingerprint: String?, + boxColor: Color, + accent: Color +) { + val clipboardManager = LocalClipboardManager.current + var showMenu by remember(fingerprint) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold + ), + color = accent.copy(alpha = 0.8f) + ) + if (fingerprint != null) { + Column { + Text( + text = formatFingerprint(fingerprint), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + fontSize = 14.sp + ), + color = accent, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = {}, + onLongClick = { showMenu = true } + ) + .background(boxColor, shape = MaterialTheme.shapes.small) + .padding(16.dp), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.fingerprint_copy)) }, + onClick = { + clipboardManager.setText(AnnotatedString(fingerprint)) + showMenu = false + } + ) + } + } + } else { + Text( + text = stringResource(R.string.fingerprint_pending), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), + color = Color(0xFFFF9500), + modifier = Modifier.padding(16.dp) + ) + } + } +} + +private fun formatFingerprint(fingerprint: String): String { + val upper = fingerprint.uppercase() + val sb = StringBuilder() + upper.forEachIndexed { index, c -> + if (index > 0 && index % 4 == 0) { + if (index % 16 == 0) sb.append('\n') else sb.append(' ') + } + sb.append(c) + } + return sb.toString() +} diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt deleted file mode 100644 index 2823896c..00000000 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ /dev/null @@ -1,673 +0,0 @@ -package com.bitchat.android.ui - -import com.bitchat.android.R -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.style.TextOverflow -import com.bitchat.android.ui.theme.BASE_FONT_SIZE - - -/** - * Sidebar components for ChatScreen - * Extracted from ChatScreen.kt for better organization - */ - -@Composable -fun SidebarOverlay( - viewModel: ChatViewModel, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) - val currentChannel by viewModel.currentChannel.observeAsState() - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() - val nickname by viewModel.nickname.observeAsState("") - val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) - val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap()) - - Box( - modifier = modifier - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(indication = null, interactionSource = interactionSource) { onDismiss() } - ) { - Row( - modifier = Modifier - .fillMaxHeight() - .width(280.dp) - .align(Alignment.CenterEnd) - .clickable { /* Prevent dismissing when clicking sidebar */ } - ) { - // Grey vertical bar for visual continuity (matches iOS) - Box( - modifier = Modifier - .fillMaxHeight() - .width(1.dp) - .background(Color.Gray.copy(alpha = 0.3f)) - ) - - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f) - .background(colorScheme.background.copy(alpha = 0.95f)) - .windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding - ) { - SidebarHeader() - - HorizontalDivider() - - // Scrollable content - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Channels section - if (joinedChannels.isNotEmpty()) { - item { - ChannelsSection( - channels = joinedChannels.toList(), // Convert Set to List - currentChannel = currentChannel, - colorScheme = colorScheme, - onChannelClick = { channel -> - viewModel.switchToChannel(channel) - onDismiss() - }, - onLeaveChannel = { channel -> - viewModel.leaveChannel(channel) - }, - unreadChannelMessages = unreadChannelMessages - ) - } - - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - } - } - - // People section - switch between mesh and geohash lists (iOS-compatible) - item { - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - - when (selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> { - // Show geohash people list when in location channel - GeohashPeopleList( - viewModel = viewModel, - onTapPerson = onDismiss - ) - } - else -> { - // Show mesh peer list when in mesh channel (default) - PeopleSection( - connectedPeers = connectedPeers, - peerNicknames = peerNicknames, - peerRSSI = peerRSSI, - nickname = nickname, - colorScheme = colorScheme, - selectedPrivatePeer = selectedPrivatePeer, - viewModel = viewModel, - onPrivateChatStart = { peerID -> - viewModel.startPrivateChat(peerID) - onDismiss() - } - ) - } - } - } - } - } - } - } -} - -@Composable -private fun SidebarHeader() { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .height(42.dp) // Match reduced main header height - .fillMaxWidth() - .background(colorScheme.background.copy(alpha = 0.95f)) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.your_network).uppercase(), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = colorScheme.onSurface - ) - Spacer(modifier = Modifier.weight(1f)) - } -} - -@Composable -fun ChannelsSection( - channels: List, - currentChannel: String?, - colorScheme: ColorScheme, - onChannelClick: (String) -> Unit, - onLeaveChannel: (String) -> Unit, - unreadChannelMessages: Map = emptyMap() -) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Person, // Using Person icon as placeholder - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = R.string.channels).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold - ) - } - - channels.forEach { channel -> - val isSelected = channel == currentChannel - val unreadCount = unreadChannelMessages[channel] ?: 0 - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onChannelClick(channel) } - .background( - if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f) - else Color.Transparent - ) - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Unread badge for channels - UnreadBadge( - count = unreadCount, - colorScheme = colorScheme, - modifier = Modifier.padding(end = 8.dp) - ) - - Text( - text = channel, // Channel already contains the # prefix - style = MaterialTheme.typography.bodyMedium, - color = if (isSelected) colorScheme.primary else colorScheme.onSurface, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, - modifier = Modifier.weight(1f) - ) - - // Leave channel button - IconButton( - onClick = { onLeaveChannel(channel) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Leave channel", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - } - } - } -} - -@Composable -fun PeopleSection( - connectedPeers: List, - peerNicknames: Map, - peerRSSI: Map, - nickname: String, - colorScheme: ColorScheme, - selectedPrivatePeer: String?, - viewModel: ChatViewModel, - onPrivateChatStart: (String) -> Unit -) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Group, // Using Person icon for people - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = R.string.people).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold - ) - } - - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) - ) - } - - // Observe reactive state for favorites and fingerprints - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) - - // Reactive favorite computation for all peers - val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { - connectedPeers.associateWith { peerID -> - // Reactive favorite computation - same as ChatHeader - val fingerprint = peerFingerprints[peerID] - fingerprint != null && favoritePeers.contains(fingerprint) - } - } - - // Build mapping of connected peerID -> noise key hex to unify with offline favorites - val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> - try { - viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } - } catch (_: Exception) { null } - }.filterValues { it != null }.mapValues { it.value!! } - - Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") - - // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical - val sortedPeers = connectedPeers.sortedWith( - compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first - .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) - .thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first - .thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical - ) - - // Build a map of base name counts across all people shown in the list (connected + offline + nostr) - val hex64Regex = Regex("^[0-9a-fA-F]{64}$") - - // Helper to compute display name used for a given key - fun computeDisplayNameForPeerId(key: String): String { - return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12))) - } - - - - val baseNameCounts = mutableMapOf() - - // Connected peers - sortedPeers.forEach { pid -> - val dn = computeDisplayNameForPeerId(pid) - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - - // Offline favorites (exclude ones mapped to connected) - val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() - offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } - if (!isMappedToConnected) { - val dn = peerNicknames[favPeerID] ?: fav.peerNickname - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - } - - // Nostr-only conversations - val connectedIds = sortedPeers.toSet() - val appendedOfflineIds = mutableSetOf() - privateChats.keys - .filter { key -> - (key.startsWith("nostr_") || hex64Regex.matches(key)) && - !connectedIds.contains(key) && - !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } - } - .forEach { convKey -> - val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12)) - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - - sortedPeers.forEach { peerID -> - val isFavorite = peerFavoriteStates[peerID] ?: false - // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below - - val noiseHex = noiseHexByPeerID[peerID] - val meshUnread = hasUnreadPrivateMessages.contains(peerID) - val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false - val combinedHasUnread = meshUnread || nostrUnread - val combinedUnreadCount = ( - privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0 - ) + ( - if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0 - ) - - val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))) - val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - PeerItem( - peerID = peerID, - displayName = displayName, - signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]), - isSelected = peerID == selectedPrivatePeer, - isFavorite = isFavorite, - hasUnreadDM = combinedHasUnread, - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(peerID) }, - onToggleFavorite = { - Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") - viewModel.toggleFavorite(peerID) - }, - unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0, - showNostrGlobe = false, - showHashSuffix = showHash - ) - } - - // Append offline favorites we actively favorite (and not currently connected) - offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - // If any connected peer maps to this noise key, skip showing the offline entry - val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } - if (isMappedToConnected) return@forEach - - // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, - // open chat with the connected peerID instead of the noise hex for a seamless window - val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key - val dn = peerNicknames[favPeerID] ?: fav.peerNickname - val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - PeerItem( - peerID = favPeerID, - displayName = dn, - signalStrength = 0, - isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, - isFavorite = true, - hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID), - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, - onToggleFavorite = { - Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") - viewModel.toggleFavorite(favPeerID) - }, - unreadCount = privateChats[favPeerID]?.count { msg -> - msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) - } ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0, - showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), - showHashSuffix = showHash - ) - appendedOfflineIds.add(favPeerID) - } - - // Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet - // This ensures a user can open and read Nostr messages while the sender remains offline - val alreadyShownIds = connectedIds + appendedOfflineIds - privateChats.keys - .filter { key -> - (key.startsWith("nostr_") || hex64Regex.matches(key)) && - !alreadyShownIds.contains(key) && - // Skip if this key maps to a connected peer via noiseHex mapping - !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } - } - .sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp } - .forEach { convKey -> - val lastSender = privateChats[convKey]?.lastOrNull()?.sender - val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)) - val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - PeerItem( - peerID = convKey, - displayName = dn, - signalStrength = 0, - isSelected = convKey == selectedPrivatePeer, - isFavorite = false, - hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(convKey) }, - onToggleFavorite = { viewModel.toggleFavorite(convKey) }, - unreadCount = privateChats[convKey]?.count { msg -> - msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) - } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, - showNostrGlobe = true, - showHashSuffix = showHash - ) - } - } -} - -@Composable -private fun PeerItem( - peerID: String, - displayName: String, - signalStrength: Int, - isSelected: Boolean, - isFavorite: Boolean, - hasUnreadDM: Boolean, - colorScheme: ColorScheme, - viewModel: ChatViewModel, - onItemClick: () -> Unit, - onToggleFavorite: () -> Unit, - unreadCount: Int = 0, - showNostrGlobe: Boolean = false, - showHashSuffix: Boolean = true -) { - // Split display name for hashtag suffix support (iOS-compatible) - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - val isMe = displayName == "You" || peerID == viewModel.nickname.value - - // Get consistent peer color (iOS-compatible) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onItemClick() } - .background( - if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f) - else Color.Transparent - ) - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Show unread badge or signal strength - if (hasUnreadDM) { - // Show mail icon for unread DMs (iOS orange) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = "Unread message", - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else { - // Signal strength indicators - if (showNostrGlobe) { - // Purple globe to indicate Nostr availability - Icon( - imageVector = Icons.Filled.Public, - contentDescription = "Reachable via Nostr", - modifier = Modifier.size(16.dp), - tint = Color(0xFF9C27B0) // Purple - ) - } else { - SignalStrengthIndicator( - signalStrength = signalStrength, - colorScheme = colorScheme - ) - } - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with iOS-style color and hashtag suffix support - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - // Base name with peer-specific color - Text( - text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) - ) - } - } - - // Favorite star with proper filled/outlined states - IconButton( - onClick = onToggleFavorite, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) - ) - } - } -} - - - -@Composable -private fun SignalStrengthIndicator( - signalStrength: Int, - colorScheme: ColorScheme -) { - Row(modifier = Modifier.width(24.dp)) { - repeat(3) { index -> - val opacity = when { - signalStrength >= (index + 1) * 33 -> 1f - else -> 0.2f - } - Box( - modifier = Modifier - .size(width = 3.dp, height = (4 + index * 2).dp) - .background( - colorScheme.onSurface.copy(alpha = opacity), - RoundedCornerShape(1.dp) - ) - ) - if (index < 2) Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -/** - * Reusable unread badge component for both channels and private messages - */ -@Composable -private fun UnreadBadge( - count: Int, - colorScheme: ColorScheme, - modifier: Modifier = Modifier -) { - if (count > 0) { - Box( - modifier = modifier - .background( - color = Color(0xFFFFD700), // Yellow color - shape = RoundedCornerShape(10.dp) - ) - .padding(horizontal = 2.dp, vertical = 0.dp) - .defaultMinSize(minWidth = 14.dp, minHeight = 14.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = if (count > 99) "99+" else count.toString(), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ), - color = Color.Black // Black text on yellow background - ) - } - } -} - -/** - * Convert RSSI value (dBm) to signal strength percentage (0-100) - * RSSI typically ranges from -30 (excellent) to -100 (very poor) - * Maps to 0-100 scale where: - * - 0-32: No signal (0 bars) - * - 33-65: Weak (1 bar) - * - 66-98: Good (2 bars) - * - 99-100: Excellent (3 bars) - */ -private fun convertRSSIToSignalStrength(rssi: Int?): Int { - if (rssi == null) return 0 - - return when { - rssi >= -40 -> 100 // Excellent signal - rssi >= -55 -> 85 // Very good signal - rssi >= -70 -> 70 // Good signal - rssi >= -85 -> 50 // Fair signal - rssi >= -100 -> 25 // Poor signal - else -> 0 // Very poor or no signal - } -} diff --git a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt new file mode 100644 index 00000000..5d0ad81a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt @@ -0,0 +1,105 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage + +internal enum class DirectMessageTransport { + MESH, + NOSTR +} + +/** + * Presence-independent presentation state for an unread private conversation. + * + * A conversation remains in this model until it is read, even when none of its identities are in + * the current mesh or geohash participant lists. + */ +internal data class UnreadConversationSummary( + val conversationID: String, + val displayName: String, + val unreadCount: Int, + val latestMessageAt: Long, + val transport: DirectMessageTransport, + val nostrPubkey: String?, + val identityAliases: Set, + val isConnected: Boolean = false, + val sourceGeohash: String? = null +) + +internal fun buildUnreadConversationSummaries( + unreadConversationIDs: Set, + privateChats: Map>, + currentUserIdentifiers: Set, + canonicalize: (String) -> String, + isMessageRead: (BitchatMessage) -> Boolean +): List { + if (unreadConversationIDs.isEmpty()) return emptyList() + + val normalizedCurrentUserIdentifiers = currentUserIdentifiers.filterTo(mutableSetOf()) { + it.isNotBlank() + } + val canonicalUnreadIDs = unreadConversationIDs + .mapTo(linkedSetOf()) { canonicalize(it) } + val unreadAliasesByCanonicalID = unreadConversationIDs.groupBy(canonicalize) + val messagesByCanonicalID = linkedMapOf>() + + privateChats.forEach { (conversationID, messages) -> + val canonicalID = canonicalize(conversationID) + messagesByCanonicalID.getOrPut(canonicalID) { mutableListOf() }.addAll(messages) + } + + return canonicalUnreadIDs.map { conversationID -> + val messages = messagesByCanonicalID[conversationID] + .orEmpty() + .distinctBy { it.id } + val incomingMessages = messages.filterNot { + it.sender in normalizedCurrentUserIdentifiers + } + val unreadIncomingMessages = incomingMessages.filterNot(isMessageRead) + val latestMessage = (unreadIncomingMessages.ifEmpty { incomingMessages }) + .maxWithOrNull(compareBy { it.timestamp.time }.thenBy { it.id }) + val aliases = unreadAliasesByCanonicalID[conversationID].orEmpty() + val nostrPubkey = latestMessage?.senderNostrPubkey + val isNostrConversation = nostrPubkey != null || + aliases.any(::isNostrConversationID) || + isNostrConversationID(conversationID) + + UnreadConversationSummary( + conversationID = conversationID, + displayName = latestMessage + ?.sender + ?.takeIf { it.isNotBlank() } + ?: conversationID.take(12), + unreadCount = unreadIncomingMessages.size.coerceAtLeast(1), + latestMessageAt = latestMessage?.timestamp?.time ?: Long.MIN_VALUE, + transport = if (isNostrConversation) { + DirectMessageTransport.NOSTR + } else { + DirectMessageTransport.MESH + }, + nostrPubkey = nostrPubkey, + identityAliases = (aliases + conversationID) + .mapTo(mutableSetOf()) { it.lowercase() } + ) + }.sortedWith( + compareByDescending { it.latestMessageAt } + .thenBy { it.displayName.lowercase() } + .thenBy { it.conversationID } + ) +} + +private fun isNostrConversationID(value: String): Boolean = + value.startsWith("nostr_") || value.startsWith("nostr:") + +internal fun matchingUnreadAliases( + unreadConversationIDs: Set, + canonicalConversationID: String, + canonicalize: (String) -> String +): Set { + val normalizedCanonicalID = canonicalConversationID.lowercase() + return unreadConversationIDs + .filterTo(mutableSetOf()) { unreadID -> + canonicalize(unreadID).equals(normalizedCanonicalID, ignoreCase = true) + } + .plus(canonicalConversationID) + .mapTo(mutableSetOf()) { it.lowercase() } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt new file mode 100644 index 00000000..8003508e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -0,0 +1,375 @@ +package com.bitchat.android.ui + +import android.content.Context +import com.bitchat.android.R +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.services.VerificationService +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.Date +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles QR verification logic and state, extracted from ChatViewModel. + */ +class VerificationHandler( + private val context: Context, + private val scope: CoroutineScope, + private val getMeshService: () -> MeshService, + private val identityManager: SecureIdentityStateManager, + private val state: ChatState, + private val notificationManager: NotificationManager, + private val messageManager: MessageManager +) { + // Helper to get current mesh service (may change after panic clear) + private val meshService: MeshService + get() = getMeshService() + + private val _verifiedFingerprints = MutableStateFlow>(emptySet()) + val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() + + private val pendingQRVerifications = ConcurrentHashMap() + private val lastVerifyNonceByPeer = ConcurrentHashMap() + private val lastInboundVerifyChallengeAt = ConcurrentHashMap() + private val lastMutualToastAt = ConcurrentHashMap() + + fun loadVerifiedFingerprints() { + _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() + } + + fun isPeerVerified(peerID: String): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = getPeerFingerprintForDisplay(peerID) + return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean { + val fingerprint = fingerprintFromNoiseBytes(noisePublicKey) + return _verifiedFingerprints.value.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + val fingerprint = meshService.getPeerFingerprint(peerID) ?: return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + val targetNoise = qr.noiseKeyHex.lowercase() + val peerID = state.getConnectedPeersValue().firstOrNull { pid -> + val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase() + noiseKeyHex == targetNoise + } ?: return false + + if (pendingQRVerifications.containsKey(peerID)) return true + val nonce = ByteArray(16) + java.security.SecureRandom().nextBytes(nonce) + val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false) + pendingQRVerifications[peerID] = pending + + if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } else { + meshService.initiateNoiseHandshake(peerID) + } + fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp -> + identityManager.cacheFingerprintNickname(fp, qr.nickname) + identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp) + identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex) + } + return true + } + + fun sendPendingVerificationIfNeeded(peerID: String) { + val pending = pendingQRVerifications[peerID] ?: return + if (pending.sent) return + meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } + + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) { + scope.launch { + val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch + val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch + if (parsed.first.lowercase() != myNoiseHex) return@launch + + val lastNonce = lastVerifyNonceByPeer[peerID] + if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch + lastVerifyNonceByPeer[peerID] = parsed.second + + val fp = meshService.getPeerFingerprint(peerID) + if (fp != null) { + lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis() + if (_verifiedFingerprints.value.contains(fp)) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val name = resolvePeerDisplayName(peerID) + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + + meshService.sendVerifyResponse(peerID, parsed.first, parsed.second) + } + } + + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) { + scope.launch { + val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch + val pending = pendingQRVerifications[peerID] ?: return@launch + if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch + if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch + + val ok = VerificationService.verifyResponseSignature( + noiseKeyHex = resp.noiseKeyHex, + nonceA = resp.nonceA, + signature = resp.signature, + signerPublicKeyHex = pending.signKeyHex + ) + if (!ok) return@launch + + pendingQRVerifications.remove(peerID) + val fp = meshService.getPeerFingerprint(peerID) ?: return@launch + identityManager.setVerifiedFingerprint(fp, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fp) + _verifiedFingerprints.value = current + + val name = resolvePeerDisplayName(peerID) + identityManager.cacheFingerprintNickname(fp, name) + val noiseKeyHex = try { + meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString() + } catch (_: Exception) { + null + } + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fp) + } + addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID) + + val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L + if (System.currentTimeMillis() - lastChallenge < 600_000L) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + val fromMap = state.getPeerFingerprintsValue()[peerID] + if (fromMap != null) return fromMap + val hexRegex = Regex("^[0-9a-fA-F]+$") + return try { + when { + ContactIdentityResolver.isContactConversationId(peerID) -> { + ContactIdentityResolver.fingerprintFromContactConversationId(peerID) + } + peerID.length == 64 && peerID.matches(hexRegex) -> { + identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } + fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } + } + peerID.length == 16 && peerID.matches(hexRegex) -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + peerID.startsWith("nostr_") -> { + val pubHex = GeohashAliasRegistry.get(peerID) + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + peerID.startsWith("nostr:") -> { + val prefix = peerID.removePrefix("nostr:").lowercase() + val pubHex = GeohashAliasRegistry + .snapshot() + .values + .firstOrNull { it.lowercase().startsWith(prefix) } + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + else -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + } + } catch (_: Exception) { + null + } + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + val nicknameMap = state.peerNicknames.value + nicknameMap[peerID]?.let { return it } + try { + meshService.getPeerInfo(peerID)?.nickname?.let { return it } + } catch (_: Exception) { } + + val fingerprint = getPeerFingerprintForDisplay(peerID) + fingerprint?.let { fp -> + identityManager.getCachedFingerprintNickname(fp)?.let { cached -> + if (cached.isNotBlank()) return cached + } + } + + val hexRegex = Regex("^[0-9a-fA-F]+$") + if (peerID.length == 64 && peerID.matches(hexRegex)) { + val noiseKeyBytes = try { + peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } catch (_: Exception) { null } + val favorite = noiseKeyBytes?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + if (peerID.length == 16 && peerID.matches(hexRegex)) { + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + return peerID.take(8) + } + + fun getMyFingerprint(): String { + return meshService.getIdentityFingerprint() + } + + fun verifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fingerprint) + _verifiedFingerprints.value = current + } + + fun unverifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + private fun addVerificationSystemMessage(peerID: String, text: String) { + val msg = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + isPrivate = true, + senderPeerID = peerID + ) + messageManager.addPrivateMessageNoUnread(peerID, msg) + } + + private fun resolvePeerDisplayName(peerID: String): String { + val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null } + return nick ?: peerID.take(8) + } + + private fun sendVerificationNotification(title: String, body: String, peerID: String) { + notificationManager.showVerificationNotification(title, body, peerID) + } + + private fun fingerprintFromNoiseHex(noiseHex: String): String? { + val bytes = noiseHex.dataFromHexString() ?: return null + return fingerprintFromNoiseBytes(bytes) + } + + fun fingerprintFromNoiseBytes(bytes: ByteArray): String { + val hash = MessageDigest.getInstance("SHA-256").digest(bytes) + return hash.hexEncodedString() + } + + private data class PendingVerification( + val noiseKeyHex: String, + val signKeyHex: String, + val nonceA: ByteArray, + val startedAtMs: Long, + val sent: Boolean + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PendingVerification + + if (startedAtMs != other.startedAtMs) return false + if (sent != other.sent) return false + if (noiseKeyHex != other.noiseKeyHex) return false + if (signKeyHex != other.signKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + + return true + } + + override fun hashCode(): Int { + var result = startedAtMs.hashCode() + result = 31 * result + sent.hashCode() + result = 31 * result + noiseKeyHex.hashCode() + result = 31 * result + signKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + return result + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt new file mode 100644 index 00000000..a84c3c94 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -0,0 +1,538 @@ +package com.bitchat.android.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCodeScanner +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.viewfinder.core.ImplementationMode +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.services.VerificationService +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val accent = MaterialTheme.colorScheme.primary + + var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val npub = remember { viewModel.getCurrentNpub() } + + val qrString = remember(nickname, npub) { + viewModel.buildMyQRString(nickname, npub) + } + + BitchatBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.Top + ) { + // Header + VerificationHeader( + accent = accent, + onClose = onDismiss, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + + // Tabs + TabRow( + selectedTabIndex = selectedTab, + containerColor = Color.Transparent, + contentColor = accent, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTab]), + color = accent + ) + } + ) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { + Text( + text = "My QR", + fontFamily = BitchatFontFamily, + fontSize = 14.sp + ) + } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { + Text( + text = "Scan", + fontFamily = BitchatFontFamily, + fontSize = 14.sp + ) + } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Content + Crossfade( + targetState = selectedTab, + label = "VerificationTabCrossfade", + modifier = Modifier.weight(1f) + ) { tab -> + when (tab) { + 0 -> MyQrTabContent( + qrString = qrString, + nickname = nickname, + accent = accent + ) + 1 -> ScanTabContent( + accent = accent, + onScan = { code -> + val qr = VerificationService.verifyScannedQR(code) + if (qr != null && viewModel.beginQRVerification(qr)) { + selectedTab = 0 + } + } + ) + } + } + + // Unverify Action + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + + if (peerID != null) { + val fingerprint = viewModel.getMeshPeerFingerprint(peerID!!) + if (fingerprint != null && fingerprints.contains(fingerprint)) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.unverifyFingerprint(peerID!!) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = BitchatFontFamily, + fontSize = 12.sp + ) + } + } + } + } + } +} + +@Composable +private fun VerificationHeader( + accent: Color, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.verify_title).uppercase(), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = accent + ) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) + } +} + +@Composable +private fun MyQrTabContent( + qrString: String, + nickname: String, + accent: Color +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.verify_my_qr_title), + style = MaterialTheme.typography.titleMedium, + fontFamily = BitchatFontFamily, + color = accent + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (qrString.isNotBlank()) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .padding(20.dp) // Quiet zone + ) { + QRCodeImage(data = qrString, size = 260.dp) + } + } else { + Box( + modifier = Modifier + .size(260.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.verify_qr_unavailable), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = Color.Black.copy(alpha = 0.6f) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // User Nickname + Text( + text = nickname, + style = MaterialTheme.typography.headlineSmall, + fontFamily = BitchatFontFamily, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Helper text + Text( + text = stringResource(R.string.app_name).lowercase(), + style = MaterialTheme.typography.bodyMedium, + fontFamily = BitchatFontFamily, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + textAlign = TextAlign.Center + ) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun ScanTabContent( + accent: Color, + onScan: (String) -> Unit +) { + val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (permissionState.status.isGranted) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + ScannerView(onScan = onScan) + + // Overlay border + Box( + modifier = Modifier + .size(280.dp) + .border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp)) + ) + + // Corner accents for the overlay + Box(modifier = Modifier.size(260.dp)) { + // This could be drawn with Canvas for cooler effect, but simple border is cleaner for now + } + + Text( + text = stringResource(R.string.verify_scan_prompt_friend), + color = Color.White, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(24.dp) + ) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = accent + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.verify_camera_permission), + fontFamily = BitchatFontFamily, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(32.dp)) + Button( + onClick = { permissionState.launchPermissionRequest() }, + colors = ButtonDefaults.buttonColors(containerColor = accent) + ) { + Text( + text = stringResource(R.string.verify_request_camera), + fontFamily = BitchatFontFamily + ) + } + } + } + } +} + +@Composable +private fun ScannerView( + onScan: (String) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var lastValid by remember { mutableStateOf(null) } + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + val surfaceRequests = remember { MutableStateFlow(null) } + val surfaceRequest by surfaceRequests.collectAsState(initial = null) + val mainHandler = remember { Handler(Looper.getMainLooper()) } + + val onCodeState = rememberUpdatedState(onScan) + val analyzer = remember { + QRCodeAnalyzer { text -> + mainHandler.post { + if (text == lastValid) return@post + lastValid = text + onCodeState.value(text) + } + } + } + + DisposableEffect(Unit) { + val executor = ContextCompat.getMainExecutor(context) + var cameraProvider: ProcessCameraProvider? = null + + cameraProviderFuture.addListener( + { + val provider = cameraProviderFuture.get() + cameraProvider = provider + val preview = Preview.Builder().build().also { + it.setSurfaceProvider { request -> surfaceRequests.value = request } + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(cameraExecutor, analyzer) } + + runCatching { + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis + ) + }.onFailure { + Log.w("VerificationSheet", "Failed to bind camera: ${it.message}") + } + }, + executor + ) + + onDispose { + surfaceRequests.value = null + runCatching { cameraProvider?.unbindAll() } + cameraExecutor.shutdown() + } + } + + surfaceRequest?.let { request -> + CameraXViewfinder( + surfaceRequest = request, + implementationMode = ImplementationMode.EMBEDDED, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +private fun QRCodeImage(data: String, size: Dp) { + val sizePx = with(LocalDensity.current) { size.toPx().toInt() } + val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(size) + ) + } +} + +private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + if (data.isBlank() || sizePx <= 0) return null + return try { + val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx) + bitmapFromMatrix(matrix) + } catch (_: Exception) { + null + } +} + +private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap { + val width = matrix.width + val height = matrix.height + val bitmap = createBitmap(width, height) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap[x, y] = + if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE + } + } + return bitmap +} + +private class QRCodeAnalyzer( + private val onCode: (String) -> Unit +) : ImageAnalysis.Analyzer { + private val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + + @ExperimentalGetImage + override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image ?: run { + imageProxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + val text = barcodes.firstOrNull()?.rawValue + if (!text.isNullOrBlank()) onCode(text) + } + .addOnCompleteListener { imageProxy.close() } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt new file mode 100644 index 00000000..1545cd2c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -0,0 +1,279 @@ +package com.bitchat.android.ui + +import android.Manifest +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.res.stringResource +import com.bitchat.android.features.voice.VoiceRecorder +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File + +/** + * How long the button must be held before a recording starts. + * + * Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a + * recording, and it also made the control impossible to defend against pointer events it should + * never have seen (see [ArmDelayMs]). + */ +private const val HoldToRecordMs = 220L + +/** + * How long the button ignores presses after entering composition. + * + * The action cluster swaps send out for camera+microphone the instant the field is cleared, which + * puts the microphone exactly where the send button was a frame earlier. Tapping send quickly + * could hand the microphone a pointer-down whose matching pointer-up had already been delivered + * to the send button that no longer exists — leaving the gesture waiting for a release that will + * never come, stuck in "recording" until the composable is disposed. Refusing presses until the + * swap animation has settled removes that whole class of failure. + */ +private const val ArmDelayMs = 350L + +/** Hard cap on a single recording. */ +private const val MaxRecordingMs = 10_000L + +/** Tail kept after release so the last syllable is not clipped. */ +private const val ReleaseTailMs = 500L + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun VoiceRecordButton( + modifier: Modifier = Modifier, + recorderFactory: (() -> VoiceRecorder)? = null, + courtesyActive: Boolean = false, + /** + * Recording state as the composer sees it. Drives the active tint so the button and the + * pill's border change together instead of one lagging the other. + */ + isRecording: Boolean = false, + /** + * Consulted the instant the finger lifts, with the final pointer position in root + * coordinates: when it lands inside the slide-to-cancel target, the recording is + * discarded instead of sent. Receiving the position here (instead of reading composed + * state) keeps the verdict exact even for a slide-and-lift within a single frame. + */ + shouldCancel: (Offset) -> Boolean = { false }, + /** + * Finger position in root coordinates while a capture is live (drives the magnetic + * cancel target); null once the gesture ends. + */ + onTrackFinger: (Offset?) -> Unit = {}, + onStart: (isLive: Boolean) -> Unit, + onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, + onFinish: (filePath: String) -> Unit, + /** + * Invoked whenever a recording ends without producing a file — permission denied, recorder + * failure, the button being torn down mid-capture, or a deliberate slide-to-cancel. + */ + onCancel: () -> Unit = {} +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val view = LocalView.current + val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + + var isCapturing by remember { mutableStateOf(false) } + var recorder by remember { mutableStateOf(null) } + var recordedFilePath by remember { mutableStateOf(null) } + var recordingStart by remember { mutableStateOf(0L) } + var buttonCoords by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + var ampJob by remember { mutableStateOf(null) } + + // Ensure latest callbacks are used inside gesture coroutine + val latestOnStart = rememberUpdatedState(onStart) + val latestOnAmplitude = rememberUpdatedState(onAmplitude) + val latestOnFinish = rememberUpdatedState(onFinish) + val latestOnCancel = rememberUpdatedState(onCancel) + val latestShouldCancel = rememberUpdatedState(shouldCancel) + val latestOnTrackFinger = rememberUpdatedState(onTrackFinger) + val latestRecorderFactory = rememberUpdatedState(recorderFactory) + + // Set when this instance was composed, so presses inherited from whatever occupied this spot + // beforehand can be rejected. + val composedAt = remember { System.currentTimeMillis() } + + fun buzz() { + try { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } catch (_: Exception) { + } + } + + // Last line of defence: if the button is removed while capturing — the cluster swapping to + // send, the sheet closing, the screen going away — release the recorder and tell the caller, + // so nothing is left holding the microphone or showing a recording UI. + DisposableEffect(Unit) { + onDispose { + ampJob?.cancel() + ampJob = null + if (isCapturing) { + isCapturing = false + runCatching { recorder?.stop(canceled = true) } + recorder = null + recordedFilePath = null + latestOnTrackFinger.value(null) + latestOnCancel.value() + } + } + } + + // Same disc, same sizing and the same press feedback as the camera and send buttons. + ComposerActionSurface( + isActive = isRecording || isCapturing || courtesyActive, + isPressed = isCapturing, + activeColor = if (courtesyActive && !isRecording && !isCapturing) { + androidx.compose.ui.graphics.Color(0xFFFFB300) + } else androidx.compose.ui.graphics.Color.Unspecified, + modifier = modifier + .onGloballyPositioned { buttonCoords = it } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + // Guard 1: ignore anything arriving before the swap animation settled. + if (System.currentTimeMillis() - composedAt < ArmDelayMs) { + return@awaitEachGesture + } + // Guard 2: never start a second capture on top of a live one. + if (isCapturing) return@awaitEachGesture + + if (micPermission.status !is PermissionStatus.Granted) { + micPermission.launchPermissionRequest() + return@awaitEachGesture + } + + // Guard 3: require a deliberate hold. An up (or a stolen pointer) inside the + // arm window means the press was never a hold; only the timeout means the + // finger is still down. + var stolenDuringArm = false + val releasedEarly = withTimeoutOrNull(HoldToRecordMs) { + waitForUpOrCancellation().also { if (it == null) stolenDuringArm = true } + } + if (releasedEarly != null || stolenDuringArm) return@awaitEachGesture + + val rec = latestRecorderFactory.value?.invoke() ?: VoiceRecorder(context) + val startedFile = rec.start() + if (startedFile == null) { + // Recorder refused to start; make sure the caller does not sit in a + // recording state that never began. + runCatching { rec.stop(canceled = true) } + latestOnCancel.value() + return@awaitEachGesture + } + + recorder = rec + recordedFilePath = startedFile.absolutePath + recordingStart = System.currentTimeMillis() + isCapturing = true + latestOnStart.value(rec.isLive) + buzz() + + ampJob?.cancel() + ampJob = scope.launch { + while (isActive && isCapturing) { + val amp = recorder?.pollAmplitude() ?: 0 + val elapsed = + (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) + latestOnAmplitude.value(amp, elapsed) + + if (elapsed >= MaxRecordingMs && isCapturing) { + val file = recorder?.stop() + isCapturing = false + recorder = null + val path = file?.absolutePath ?: recordedFilePath?.takeIf { File(it).isFile } + recordedFilePath = null + latestOnTrackFinger.value(null) + buzz() + // Always report the outcome, even when the file is unusable, + // or the caller stays stuck showing the waveform. + if (!path.isNullOrBlank()) { + latestOnFinish.value(path) + } else { + latestOnCancel.value() + } + break + } + delay(80) + } + } + + // Track the finger in root coordinates until it lifts, so the composer can + // run the magnetic slide-to-cancel target. A cancelled pointer (stolen by a + // scroller) ends the capture the same way a lift does. + var finalPos: Offset? = null + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: continue + finalPos = buttonCoords?.localToRoot(change.position) + finalPos?.let { latestOnTrackFinger.value(it) } + if (!change.pressed) break + } + + // Cancelling discards immediately; sending keeps a short tail so the last + // syllable is not clipped (an early pointer event simply ends the tail). + // The verdict is computed from the final pointer coordinate directly — + // reading recomposed state here could be one frame stale. + val cancel = finalPos?.let { latestShouldCancel.value(it) } == true + latestOnTrackFinger.value(null) + if (isCapturing && !cancel) { + withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() } + } + if (isCapturing) { + val file = recorder?.stop(canceled = cancel) + isCapturing = false + recorder = null + val path = file?.absolutePath ?: recordedFilePath?.takeIf { File(it).isFile } + recordedFilePath = null + if (cancel) { + path?.let { runCatching { File(it).delete() } } + try { + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + } catch (_: Exception) { + } + latestOnCancel.value() + } else { + buzz() + if (!path.isNullOrBlank()) { + latestOnFinish.value(path) + } else { + latestOnCancel.value() + } + } + } + ampJob?.cancel() + ampJob = null + } + } + ) { tint -> + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), + tint = tint, + modifier = Modifier.size(ComposerIconSize) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt new file mode 100644 index 00000000..2d734c14 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -0,0 +1,129 @@ +package com.bitchat.android.ui.debug + +import android.content.Context +import android.content.SharedPreferences + +/** + * SharedPreferences-backed persistence for debug settings. + * Keeps the DebugSettingsManager stateless with regard to Android Context. + */ +object DebugPreferenceManager { + private const val PREFS_NAME = "bitchat_debug_settings" + private const val KEY_VERBOSE = "verbose_logging" + private const val KEY_GATT_SERVER = "gatt_server_enabled" + private const val KEY_GATT_CLIENT = "gatt_client_enabled" + private const val KEY_PACKET_RELAY = "packet_relay_enabled" + private const val KEY_MAX_CONN_OVERALL = "max_connections_overall" + private const val KEY_MAX_CONN_SERVER = "max_connections_server" + private const val KEY_MAX_CONN_CLIENT = "max_connections_client" + private const val KEY_SEEN_PACKET_CAP = "seen_packet_capacity" + // GCS keys (no migration/back-compat) + private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" + private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" + // Transport master toggles + private const val KEY_BLE_ENABLED = "ble_enabled" + private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled" + private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose" + + private lateinit var prefs: SharedPreferences + + fun init(context: Context) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + private fun ready(): Boolean = ::prefs.isInitialized + + fun getVerboseLogging(default: Boolean = false): Boolean = + if (ready()) prefs.getBoolean(KEY_VERBOSE, default) else default + + fun setVerboseLogging(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_VERBOSE, value).apply() + } + + fun getGattServerEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_GATT_SERVER, default) else default + + fun setGattServerEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_GATT_SERVER, value).apply() + } + + fun getGattClientEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_GATT_CLIENT, default) else default + + fun setGattClientEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_GATT_CLIENT, value).apply() + } + + fun getPacketRelayEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_PACKET_RELAY, default) else default + + fun setPacketRelayEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_PACKET_RELAY, value).apply() + } + + // Optional connection limits (0 or missing => use defaults) + fun getMaxConnectionsOverall(default: Int = 8): Int = + if (ready()) prefs.getInt(KEY_MAX_CONN_OVERALL, default) else default + + fun setMaxConnectionsOverall(value: Int) { + if (ready()) prefs.edit().putInt(KEY_MAX_CONN_OVERALL, value).apply() + } + + fun getMaxConnectionsServer(default: Int = 8): Int = + if (ready()) prefs.getInt(KEY_MAX_CONN_SERVER, default) else default + + fun setMaxConnectionsServer(value: Int) { + if (ready()) prefs.edit().putInt(KEY_MAX_CONN_SERVER, value).apply() + } + + fun getMaxConnectionsClient(default: Int = 8): Int = + if (ready()) prefs.getInt(KEY_MAX_CONN_CLIENT, default) else default + + fun setMaxConnectionsClient(value: Int) { + if (ready()) prefs.edit().putInt(KEY_MAX_CONN_CLIENT, value).apply() + } + + // Sync/GCS settings + fun getSeenPacketCapacity(default: Int = 500): Int = + if (ready()) prefs.getInt(KEY_SEEN_PACKET_CAP, default) else default + + fun setSeenPacketCapacity(value: Int) { + if (ready()) prefs.edit().putInt(KEY_SEEN_PACKET_CAP, value).apply() + } + + fun getGcsMaxFilterBytes(default: Int = 400): Int = + if (ready()) prefs.getInt(KEY_GCS_MAX_BYTES, default) else default + + fun setGcsMaxFilterBytes(value: Int) { + if (ready()) prefs.edit().putInt(KEY_GCS_MAX_BYTES, value).apply() + } + + fun getGcsFprPercent(default: Double = 1.0): Double = + if (ready()) java.lang.Double.longBitsToDouble(prefs.getLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(default))) else default + + fun setGcsFprPercent(value: Double) { + if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() + } + + // Transport toggles + fun getBleEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default + + fun setBleEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply() + } + + fun getWifiAwareEnabled(default: Boolean = false): Boolean = + if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default + + fun setWifiAwareEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_ENABLED, value).apply() + } + + fun getWifiAwareVerbose(default: Boolean = false): Boolean = + if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_VERBOSE, default) else default + + fun setWifiAwareVerbose(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_VERBOSE, value).apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt new file mode 100644 index 00000000..80910b15 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -0,0 +1,691 @@ +package com.bitchat.android.ui.debug + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import java.util.Date +import java.util.concurrent.ConcurrentLinkedQueue +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.toHexString + +/** + * Debug settings manager for controlling debug features and collecting debug data + */ +class DebugSettingsManager private constructor() { + // NOTE: This singleton is referenced from mesh layer. Keep in ui.debug but avoid Compose deps. + + companion object { + @Volatile + private var INSTANCE: DebugSettingsManager? = null + + fun getInstance(): DebugSettingsManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: DebugSettingsManager().also { INSTANCE = it } + } + } + } + + // Debug settings state + private val _verboseLoggingEnabled = MutableStateFlow(false) + val verboseLoggingEnabled: StateFlow = _verboseLoggingEnabled.asStateFlow() + + private val _gattServerEnabled = MutableStateFlow(true) + val gattServerEnabled: StateFlow = _gattServerEnabled.asStateFlow() + + private val _gattClientEnabled = MutableStateFlow(true) + val gattClientEnabled: StateFlow = _gattClientEnabled.asStateFlow() + + private val _packetRelayEnabled = MutableStateFlow(true) + val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + + // Master transport toggles + private val _bleEnabled = MutableStateFlow(true) + val bleEnabled: StateFlow = _bleEnabled.asStateFlow() + + private val _wifiAwareEnabled = MutableStateFlow(false) + val wifiAwareEnabled: StateFlow = _wifiAwareEnabled.asStateFlow() + + // Master transport toggles + private val _wifiAwareVerbose = MutableStateFlow(false) + val wifiAwareVerbose: StateFlow = _wifiAwareVerbose.asStateFlow() + + // Visibility of the debug sheet; gates heavy work + private val _debugSheetVisible = MutableStateFlow(false) + val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow() + fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible } + + // Connection limit overrides (debug) + private val _maxConnectionsOverall = MutableStateFlow(8) + val maxConnectionsOverall: StateFlow = _maxConnectionsOverall.asStateFlow() + private val _maxServerConnections = MutableStateFlow(8) + val maxServerConnections: StateFlow = _maxServerConnections.asStateFlow() + private val _maxClientConnections = MutableStateFlow(8) + val maxClientConnections: StateFlow = _maxClientConnections.asStateFlow() + + init { + // Load persisted defaults (if preference manager already initialized) + try { + _verboseLoggingEnabled.value = DebugPreferenceManager.getVerboseLogging(false) + _gattServerEnabled.value = DebugPreferenceManager.getGattServerEnabled(true) + _gattClientEnabled.value = DebugPreferenceManager.getGattClientEnabled(true) + _packetRelayEnabled.value = DebugPreferenceManager.getPacketRelayEnabled(true) + _maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8) + _maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8) + _maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8) + // Transport toggles + _bleEnabled.value = DebugPreferenceManager.getBleEnabled(true) + _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(false) + _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false) + } catch (_: Exception) { + // Preferences not ready yet; keep defaults. They will be applied on first change. + } + } + + // Debug data collections + private val _debugMessages = MutableStateFlow>(emptyList()) + val debugMessages: StateFlow> = _debugMessages.asStateFlow() + + private val _scanResults = MutableStateFlow>(emptyList()) + val scanResults: StateFlow> = _scanResults.asStateFlow() + + private val _connectedDevices = MutableStateFlow>(emptyList()) + val connectedDevices: StateFlow> = _connectedDevices.asStateFlow() + + // Packet relay statistics + private val _relayStats = MutableStateFlow(PacketRelayStats()) + val relayStats: StateFlow = _relayStats.asStateFlow() + + // Timestamps to compute rolling window stats + private val relayTimestamps = ConcurrentLinkedQueue() + // Per-device and per-peer rolling timestamps for stacked graphs + private val perDeviceRelayTimestamps = mutableMapOf>() + private val perPeerRelayTimestamps = mutableMapOf>() + + // Additional buckets to split incoming vs outgoing + private val incomingTimestamps = ConcurrentLinkedQueue() + private val outgoingTimestamps = ConcurrentLinkedQueue() + private val perDeviceIncoming = mutableMapOf>() + private val perDeviceOutgoing = mutableMapOf>() + private val perPeerIncoming = mutableMapOf>() + private val perPeerOutgoing = mutableMapOf>() + + // Expose current per-second rates (updated when logging/pruning occurs) + private val _perDeviceLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceLastSecond: StateFlow> = _perDeviceLastSecond.asStateFlow() + private val _perPeerLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerLastSecond: StateFlow> = _perPeerLastSecond.asStateFlow() + // New flows used by UI for incoming/outgoing stacked plots + private val _perDeviceIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastSecond: StateFlow> = _perDeviceIncomingLastSecond.asStateFlow() + private val _perDeviceOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastSecond: StateFlow> = _perDeviceOutgoingLastSecond.asStateFlow() + private val _perPeerIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastSecond: StateFlow> = _perPeerIncomingLastSecond.asStateFlow() + private val _perPeerOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastSecond: StateFlow> = _perPeerOutgoingLastSecond.asStateFlow() + + // Per-minute counts per key + private val _perDeviceIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastMinute: StateFlow> = _perDeviceIncomingLastMinute.asStateFlow() + private val _perDeviceOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastMinute: StateFlow> = _perDeviceOutgoingLastMinute.asStateFlow() + private val _perPeerIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastMinute: StateFlow> = _perPeerIncomingLastMinute.asStateFlow() + private val _perPeerOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastMinute: StateFlow> = _perPeerOutgoingLastMinute.asStateFlow() + + // Totals per key (since app start) + private val deviceIncomingTotalsMap = mutableMapOf() + private val deviceOutgoingTotalsMap = mutableMapOf() + private val peerIncomingTotalsMap = mutableMapOf() + private val peerOutgoingTotalsMap = mutableMapOf() + private val _perDeviceIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingTotal: StateFlow> = _perDeviceIncomingTotalsFlow.asStateFlow() + private val _perDeviceOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingTotal: StateFlow> = _perDeviceOutgoingTotalsFlow.asStateFlow() + private val _perPeerIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingTotal: StateFlow> = _perPeerIncomingTotalsFlow.asStateFlow() + private val _perPeerOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingTotal: StateFlow> = _perPeerOutgoingTotalsFlow.asStateFlow() + + // Internal data storage for managing debug data + private val debugMessageQueue = ConcurrentLinkedQueue() + private val scanResultsQueue = ConcurrentLinkedQueue() + + private fun updateRelayStatsFromTimestamps() { + if (!_debugSheetVisible.value) return + val now = System.currentTimeMillis() + // prune older than 15m + while (true) { + val head = relayTimestamps.peek() ?: break + if (now - head > 15 * 60 * 1000L) { + relayTimestamps.poll() + } else break + } + // prune per-device and per-peer and compute 1s/60s rates + fun pruneAndCount1s(map: MutableMap>): Map { + val result = mutableMapOf() + val iterator = map.entries.iterator() + while (iterator.hasNext()) { + val (key, q) = iterator.next() + // prune this queue + while (true) { + val ts = q.peek() ?: break + if (now - ts > 15 * 60 * 1000L) { + q.poll() + } else break + } + // count last 1s only + val count1s = q.count { now - it <= 1_000L } + if (q.isEmpty()) { + // cleanup empty queues to prevent unbounded growth + iterator.remove() + } + if (count1s > 0) result[key] = count1s + } + return result + } + fun pruneAndCount60s(map: MutableMap>): Map { + val result = mutableMapOf() + map.forEach { (key, q) -> + val count60 = q.count { now - it <= 60_000L } + if (count60 > 0) result[key] = count60 + } + return result + } + + val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps) + val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps) + + _perDeviceLastSecond.value = perDevice1s + _perPeerLastSecond.value = perPeer1s + // Also compute incoming/outgoing per-key rates + _perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming) + _perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing) + _perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming) + _perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing) + _perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming) + _perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing) + _perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming) + _perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing) + val last1s = relayTimestamps.count { now - it <= 1_000L } + val last10s = relayTimestamps.count { now - it <= 10_000L } + val last1m = relayTimestamps.count { now - it <= 60_000L } + val last15m = relayTimestamps.size + // And incoming/outgoing per-second counters + val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L } + val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L } + val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L } + val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L } + val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L } + val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L } + val last15mIncoming = incomingTimestamps.size + val last15mOutgoing = outgoingTimestamps.size + val totalIncoming = _relayStats.value.totalIncomingCount + val totalOutgoing = _relayStats.value.totalOutgoingCount + _relayStats.value = PacketRelayStats( + totalRelaysCount = totalIncoming + totalOutgoing, + lastSecondRelays = last1s, + last10SecondRelays = last10s, + lastMinuteRelays = last1m, + last15MinuteRelays = last15m, + lastResetTime = _relayStats.value.lastResetTime, + lastSecondIncoming = last1sIncoming, + lastSecondOutgoing = last1sOutgoing, + last10SecondIncoming = last10sIncoming, + last10SecondOutgoing = last10sOutgoing, + lastMinuteIncoming = last1mIncoming, + lastMinuteOutgoing = last1mOutgoing, + last15MinuteIncoming = last15mIncoming, + last15MinuteOutgoing = last15mOutgoing, + totalIncomingCount = totalIncoming, + totalOutgoingCount = totalOutgoing + ) + } + + // MARK: - Setting Controls + + fun setVerboseLoggingEnabled(enabled: Boolean) { + DebugPreferenceManager.setVerboseLogging(enabled) + _verboseLoggingEnabled.value = enabled + if (enabled) { + addDebugMessage(DebugMessage.SystemMessage("🔊 Verbose logging enabled")) + } else { + addDebugMessage(DebugMessage.SystemMessage("🔇 Verbose logging disabled")) + } + } + + fun setGattServerEnabled(enabled: Boolean) { + DebugPreferenceManager.setGattServerEnabled(enabled) + _gattServerEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage( + if (enabled) "🟢 GATT Server enabled" else "🔴 GATT Server disabled" + )) + } + + fun setGattClientEnabled(enabled: Boolean) { + DebugPreferenceManager.setGattClientEnabled(enabled) + _gattClientEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage( + if (enabled) "🟢 GATT Client enabled" else "🔴 GATT Client disabled" + )) + } + + fun setPacketRelayEnabled(enabled: Boolean) { + DebugPreferenceManager.setPacketRelayEnabled(enabled) + _packetRelayEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage( + if (enabled) "📡 Packet relay enabled" else "🚫 Packet relay disabled" + )) + } + + fun setBleEnabled(enabled: Boolean) { + DebugPreferenceManager.setBleEnabled(enabled) + _bleEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) + try { + com.bitchat.android.service.MeshServiceHolder.meshService?.setBleTransportEnabled(enabled) + } catch (_: Exception) { } + } + + fun setWifiAwareEnabled(enabled: Boolean) { + DebugPreferenceManager.setWifiAwareEnabled(enabled) + _wifiAwareEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 Wi‑Fi Aware enabled" else "🔴 Wi‑Fi Aware disabled")) + try { + com.bitchat.android.wifiaware.WifiAwareController.setEnabled(enabled) + } catch (_: Exception) { } + } + + fun setWifiAwareVerbose(enabled: Boolean) { + DebugPreferenceManager.setWifiAwareVerbose(enabled) + _wifiAwareVerbose.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🔊 Wi‑Fi Aware verbose logging enabled" else "🔇 Wi‑Fi Aware verbose logging disabled")) + } + + fun setMaxConnectionsOverall(value: Int) { + val clamped = value.coerceIn(1, 32) + DebugPreferenceManager.setMaxConnectionsOverall(clamped) + _maxConnectionsOverall.value = clamped + addDebugMessage(DebugMessage.SystemMessage("🔢 Max overall connections set to $clamped")) + } + + fun setMaxServerConnections(value: Int) { + val clamped = value.coerceIn(1, 32) + DebugPreferenceManager.setMaxConnectionsServer(clamped) + _maxServerConnections.value = clamped + addDebugMessage(DebugMessage.SystemMessage("🖥️ Max server connections set to $clamped")) + } + + fun setMaxClientConnections(value: Int) { + val clamped = value.coerceIn(1, 32) + DebugPreferenceManager.setMaxConnectionsClient(clamped) + _maxClientConnections.value = clamped + addDebugMessage(DebugMessage.SystemMessage("📱 Max client connections set to $clamped")) + } + + // MARK: - Debug Data Collection + + fun addDebugMessage(message: DebugMessage) { + if (!verboseLoggingEnabled.value && message !is DebugMessage.SystemMessage) { + return // Only show system messages when verbose logging is disabled + } + + debugMessageQueue.offer(message) + + // Keep only last 200 messages to prevent memory issues + while (debugMessageQueue.size > 200) { + debugMessageQueue.poll() + } + + _debugMessages.value = debugMessageQueue.toList() + } + + fun addScanResult(scanResult: DebugScanResult) { + // De-duplicate by device address; keep most recent + if (scanResultsQueue.isNotEmpty()) { + val toRemove = scanResultsQueue.filter { it.deviceAddress == scanResult.deviceAddress } + toRemove.forEach { scanResultsQueue.remove(it) } + } + scanResultsQueue.offer(scanResult) + + // Keep only last 100 unique scan results + while (scanResultsQueue.size > 100) { + scanResultsQueue.poll() + } + + _scanResults.value = scanResultsQueue.toList() + } + + fun updateConnectedDevices(devices: List) { + _connectedDevices.value = devices + } + + // Wi‑Fi Aware debug collections + private val _wifiAwareDiscovered = MutableStateFlow>(emptyMap()) // peerID->nickname + val wifiAwareDiscovered: StateFlow> = _wifiAwareDiscovered.asStateFlow() + + private val _wifiAwareConnected = MutableStateFlow>(emptyMap()) // peerID->ip + val wifiAwareConnected: StateFlow> = _wifiAwareConnected.asStateFlow() + + fun updateWifiAwareDiscovered(map: Map) { _wifiAwareDiscovered.value = map } + fun updateWifiAwareConnected(map: Map) { _wifiAwareConnected.value = map } + + fun updateRelayStats(stats: PacketRelayStats) { + _relayStats.value = stats + } + + // Sync/GCS settings (UI-configurable) + private val _seenPacketCapacity = MutableStateFlow(DebugPreferenceManager.getSeenPacketCapacity(500)) + val seenPacketCapacity: StateFlow = _seenPacketCapacity.asStateFlow() + + private val _gcsMaxBytes = MutableStateFlow(DebugPreferenceManager.getGcsMaxFilterBytes(400)) + val gcsMaxBytes: StateFlow = _gcsMaxBytes.asStateFlow() + + private val _gcsFprPercent = MutableStateFlow(DebugPreferenceManager.getGcsFprPercent(1.0)) + val gcsFprPercent: StateFlow = _gcsFprPercent.asStateFlow() + + fun setSeenPacketCapacity(value: Int) { + val clamped = value.coerceIn(10, 1000) + DebugPreferenceManager.setSeenPacketCapacity(clamped) + _seenPacketCapacity.value = clamped + addDebugMessage(DebugMessage.SystemMessage("🧩 max packets per sync set to $clamped")) + } + + fun setGcsMaxBytes(value: Int) { + val clamped = value.coerceIn(128, 1024) + DebugPreferenceManager.setGcsMaxFilterBytes(clamped) + _gcsMaxBytes.value = clamped + addDebugMessage(DebugMessage.SystemMessage("🌸 max GCS filter size set to $clamped bytes")) + } + + fun setGcsFprPercent(value: Double) { + val clamped = value.coerceIn(0.1, 5.0) + DebugPreferenceManager.setGcsFprPercent(clamped) + _gcsFprPercent.value = clamped + addDebugMessage(DebugMessage.SystemMessage("🎯 GCS FPR set to ${String.format("%.2f", clamped)}%")) + } + + // MARK: - Debug Message Creation Helpers + + fun logPeerConnection(peerID: String, nickname: String, deviceID: String, isInbound: Boolean) { + if (verboseLoggingEnabled.value) { + val direction = if (isInbound) "connected to our server" else "we connected as client" + addDebugMessage(DebugMessage.PeerEvent( + "🔗 $nickname ($peerID) $direction via device $deviceID" + )) + } + } + + fun logPeerDisconnection(peerID: String, nickname: String, deviceID: String) { + if (verboseLoggingEnabled.value) { + addDebugMessage(DebugMessage.PeerEvent( + "❌ $nickname ($peerID) disconnected from device $deviceID" + )) + } + } + + fun logIncomingPacket(senderPeerID: String, senderNickname: String?, messageType: String, viaDeviceId: String?) { + if (verboseLoggingEnabled.value) { + val who = if (!senderNickname.isNullOrBlank()) "$senderNickname ($senderPeerID)" else senderPeerID + val routeInfo = if (!viaDeviceId.isNullOrBlank()) " via $viaDeviceId" else " (direct)" + addDebugMessage(DebugMessage.PacketEvent( + "📦 Received $messageType from $who$routeInfo" + )) + } + } + fun logPacketRelay( + packetType: String, + originalPeerID: String, + originalNickname: String?, + viaDeviceId: String? + ) { + // Backward-compatible simple API; delegate to detailed formatter with best effort + logPacketRelayDetailed( + packetType = packetType, + senderPeerID = originalPeerID, + senderNickname = originalNickname, + fromPeerID = null, + fromNickname = null, + fromDeviceAddress = viaDeviceId, + toPeerID = null, + toNickname = null, + toDeviceAddress = null, + ttl = null, + isRelay = true + ) + } + + + // New, more detailed relay logger used by the mesh/broadcaster + fun logPacketRelayDetailed( + packetType: String, + senderPeerID: String?, + senderNickname: String?, + fromPeerID: String?, + fromNickname: String?, + fromDeviceAddress: String?, + toPeerID: String?, + toNickname: String?, + toDeviceAddress: String?, + ttl: UByte?, + isRelay: Boolean = true, + packetVersion: UByte = 1u, + routeInfo: String? = null + ) { + // Build message only if verbose logging is enabled, but always update stats + val senderLabel = when { + !senderNickname.isNullOrBlank() && !senderPeerID.isNullOrBlank() -> "$senderNickname ($senderPeerID)" + !senderNickname.isNullOrBlank() -> senderNickname + !senderPeerID.isNullOrBlank() -> senderPeerID + else -> "unknown" + } + val fromName = when { + !fromNickname.isNullOrBlank() -> fromNickname + !fromPeerID.isNullOrBlank() -> fromPeerID + else -> "unknown" + } + val toName = when { + !toNickname.isNullOrBlank() -> toNickname + !toPeerID.isNullOrBlank() -> toPeerID + else -> "unknown" + } + + val fromAddr = fromDeviceAddress ?: "?" + val toAddr = toDeviceAddress ?: "?" + val ttlStr = ttl?.toString() ?: "?" + val routeStr = if (routeInfo != null) " $routeInfo" else "" + + if (verboseLoggingEnabled.value) { + if (isRelay) { + // Relay: show [previousPeer] -> [nextPeer] + addDebugMessage( + DebugMessage.RelayEvent( + "♻️ Relayed v$packetVersion $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr" + ) + ) + } else { + addDebugMessage( + DebugMessage.PacketEvent( + "📤 Sent v$packetVersion $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr" + ) + ) + } + } + + // Do not update counters here; this path is for readable logs only. + } + + // MARK: - Debug Events for Animation + sealed class MeshVisualEvent { + data class PacketActivity(val peerID: String) : MeshVisualEvent() + data class RouteActivity(val route: List) : MeshVisualEvent() + } + + private val _meshVisualEvents = kotlinx.coroutines.flow.MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) + val meshVisualEvents: kotlinx.coroutines.flow.SharedFlow = _meshVisualEvents.asSharedFlow() + + fun emitVisualEvent(event: MeshVisualEvent) { + if (_debugSheetVisible.value) { + _meshVisualEvents.tryEmit(event) + } + } + + // Peer nickname resolver + private var nicknameResolver: ((String) -> String?)? = null + fun setNicknameResolver(resolver: (String) -> String?) { nicknameResolver = resolver } + + // Explicit incoming/outgoing logging to avoid double counting + fun logIncoming(packet: BitchatPacket, fromPeerID: String, fromNickname: String?, fromDeviceAddress: String?, myPeerID: String) { + val packetType = packet.type.toString() + val packetVersion = packet.version + val route = packet.route + val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null + + if (verboseLoggingEnabled.value) { + val resolvedNick = fromNickname ?: nicknameResolver?.invoke(fromPeerID) ?: "unknown" + val who = if (resolvedNick != "unknown") "$resolvedNick ($fromPeerID)" else fromPeerID + val routeStr = if (routeInfo != null) " $routeInfo" else "" + addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromDeviceAddress ?: "?"})$routeStr")) + } + + emitVisualEvent(MeshVisualEvent.PacketActivity(fromPeerID)) + + if (!route.isNullOrEmpty()) { + val fullRoute = mutableListOf() + fullRoute.add(packet.senderID.toHexString()) + route.forEach { fullRoute.add(it.toHexString()) } + packet.recipientID?.let { fullRoute.add(it.toHexString()) } + emitVisualEvent(MeshVisualEvent.RouteActivity(fullRoute)) + } + + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) incomingTimestamps.offer(now) + fromDeviceAddress?.let { + perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L + _perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap() + } + + perPeerIncoming.getOrPut(fromPeerID) { ConcurrentLinkedQueue() }.offer(now) + peerIncomingTotalsMap[fromPeerID] = (peerIncomingTotalsMap[fromPeerID] ?: 0L) + 1L + _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() + + // bump totals + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalIncomingCount = cur.totalIncomingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() + } + + fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null, packetVersion: UByte = 1u, routeInfo: String? = null) { + if (verboseLoggingEnabled.value) { + val who = toNickname ?: toPeerID ?: "unknown" + val routeStr = if (routeInfo != null) " $routeInfo" else "" + addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing v$packetVersion $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})$routeStr")) + } + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) outgoingTimestamps.offer(now) + toDeviceAddress?.let { + perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L + _perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap() + } + (toPeerID ?: previousHopPeerID)?.let { + perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L + _perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap() + } + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalOutgoingCount = cur.totalOutgoingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() + } + + // MARK: - Clear Data + + fun clearDebugMessages() { + debugMessageQueue.clear() + _debugMessages.value = emptyList() + addDebugMessage(DebugMessage.SystemMessage("🗑️ Debug messages cleared")) + } + + fun clearScanResults() { + scanResultsQueue.clear() + _scanResults.value = emptyList() + addDebugMessage(DebugMessage.SystemMessage("🗑️ Scan results cleared")) + } +} + +// MARK: - Data Models + +/** + * Different types of debug messages for categorization and formatting + */ +sealed class DebugMessage(val content: String, val timestamp: Date = Date()) { + class SystemMessage(content: String) : DebugMessage("⚙️ $content") + class PeerEvent(content: String) : DebugMessage(content) + class PacketEvent(content: String) : DebugMessage(content) + class RelayEvent(content: String) : DebugMessage(content) +} + +/** + * Scan result data for debugging + */ +data class DebugScanResult( + val deviceName: String?, + val deviceAddress: String, + val rssi: Int, + val peerID: String?, + val timestamp: Date = Date() +) + +/** + * Connected device information for debugging + */ +data class ConnectedDevice( + val deviceAddress: String, + val peerID: String?, + val nickname: String?, + val rssi: Int?, + val connectionType: ConnectionType, + val isDirectConnection: Boolean +) + +enum class ConnectionType { + GATT_SERVER, + GATT_CLIENT +} + +/** + * Packet relay statistics for monitoring network activity + */ +data class PacketRelayStats( + val totalRelaysCount: Long = 0, + val lastSecondRelays: Int = 0, + val last10SecondRelays: Int = 0, + val lastMinuteRelays: Int = 0, + val last15MinuteRelays: Int = 0, + val lastResetTime: Date = Date(), + val lastSecondIncoming: Int = 0, + val lastSecondOutgoing: Int = 0, + val last10SecondIncoming: Int = 0, + val last10SecondOutgoing: Int = 0, + val lastMinuteIncoming: Int = 0, + val lastMinuteOutgoing: Int = 0, + val last15MinuteIncoming: Int = 0, + val last15MinuteOutgoing: Int = 0, + val totalIncomingCount: Long = 0, + val totalOutgoingCount: Long = 0 +) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt new file mode 100644 index 00000000..9c8c48b5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -0,0 +1,1096 @@ +package com.bitchat.android.ui.debug + +import android.content.ClipData +import android.content.ClipboardManager +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiTethering +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.SettingsEthernet +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.rotate +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.services.meshgraph.MeshGraphService +import kotlinx.coroutines.launch +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R +import androidx.compose.ui.platform.LocalContext +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import com.bitchat.android.onboarding.PermissionManager +import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.util.DistributionInfoProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun MeshTopologySection( + localPeerID: String? = null, + blePeerIDs: Set = emptySet(), +) { + val colorScheme = MaterialTheme.colorScheme + val graphService = remember { MeshGraphService.getInstance() } + val snapshot by graphService.graphState.collectAsState() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } + + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) + Text("Mesh topology", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + val nodes = snapshot.nodes + val edges = snapshot.edges + val empty = nodes.isEmpty() + if (empty) { + Text("No gossip yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + ForceDirectedMeshGraph( + nodes = nodes, + edges = edges, + wifiAwarePeerIDs = wifiAwarePeerIDs, + blePeerIDs = blePeerIDs, + localPeerID = localPeerID, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background(colorScheme.surface.copy(alpha = 0.4f)) + ) + + // Flexible peer list + FlowRow( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + nodes.forEach { node -> + val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}" + Text( + text = label, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.85f) + ) + } + } + } + } + } +} + +@Composable +private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionInfo?) { + val context = LocalContext.current + val colorScheme = MaterialTheme.colorScheme + + Surface( + shape = RoundedCornerShape(12.dp), + color = colorScheme.surfaceVariant.copy(alpha = 0.2f) + ) { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6)) + Text( + "Distribution info", + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + + if (info == null) { + Text( + "Inspecting installed package…", + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } else { + DistributionInfoRow("Install source", info.installSource) + info.installerPackage?.let { + DistributionInfoRow("Installer package", it) + } + DistributionInfoRow("Package format", info.packageFormat) + DistributionInfoRow("APK architecture", info.architecture) + DistributionInfoRow("Sharing source", info.sharingSource) + DistributionInfoRow("Version", "${info.versionName} (${info.versionCode})") + DistributionInfoRow("Signing channel", info.signingChannel) + DistributionInfoRow( + label = "Certificate SHA-256", + value = info.certificateSha256 ?: "Unavailable" + ) + + if (info.certificateSha256 != null) { + TextButton( + onClick = { + val clipboard = context.getSystemService(ClipboardManager::class.java) + clipboard?.setPrimaryClip( + ClipData.newPlainText( + "BitChat signing certificate SHA-256", + info.certificateSha256 + ) + ) + Toast.makeText(context, "Certificate fingerprint copied", Toast.LENGTH_SHORT).show() + }, + contentPadding = PaddingValues(horizontal = 0.dp) + ) { + Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily) + } + } + } + } + } +} + +@Composable +private fun DistributionInfoRow(label: String, value: String) { + val colorScheme = MaterialTheme.colorScheme + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + label, + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.55f) + ) + Text( + value, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.9f) + ) + } +} + +private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun DebugSettingsSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + meshService: BluetoothMeshService +) { + val colorScheme = MaterialTheme.colorScheme + val manager = remember { DebugSettingsManager.getInstance() } + + val verboseLogging by manager.verboseLoggingEnabled.collectAsState() + val gattServerEnabled by manager.gattServerEnabled.collectAsState() + val gattClientEnabled by manager.gattClientEnabled.collectAsState() + val packetRelayed by manager.packetRelayEnabled.collectAsState() + val maxOverall by manager.maxConnectionsOverall.collectAsState() + val maxServer by manager.maxServerConnections.collectAsState() + val maxClient by manager.maxClientConnections.collectAsState() + val debugMessages by manager.debugMessages.collectAsState() + val scanResults by manager.scanResults.collectAsState() + val connectedDevices by manager.connectedDevices.collectAsState() + val relayStats by manager.relayStats.collectAsState() + val seenCapacity by manager.seenPacketCapacity.collectAsState() + val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() + val gcsFpr by manager.gcsFprPercent.collectAsState() + val context = LocalContext.current + var distributionInfo by remember { + mutableStateOf(null) + } + + val bleEnabled by manager.bleEnabled.collectAsState() + val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState() + val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState() + + // Onboarding only asks for these when the toggle is already on, and it defaults to off, + // so enabling from here has to request them or the controller never starts. + val wifiAwarePermissions = remember { PermissionManager(context).wifiAwarePermissions() } + val wifiAwarePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { _ -> + // Check live state, not the result map — already-held permissions are filtered out + // before launching. Only NEARBY_WIFI_DEVICES blocks startup; the other is defensive. + val nearbyPermission = android.Manifest.permission.NEARBY_WIFI_DEVICES + val nearbyGranted = nearbyPermission !in wifiAwarePermissions || + ContextCompat.checkSelfPermission(context, nearbyPermission) == + PackageManager.PERMISSION_GRANTED + if (nearbyGranted) { + manager.setWifiAwareEnabled(true) + } else { + manager.addDebugMessage( + DebugMessage.SystemMessage("Wi‑Fi Aware needs the Nearby devices permission") + ) + } + } + val enableWifiAware: () -> Unit = { + val missing = wifiAwarePermissions.filter { + ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + manager.setWifiAwareEnabled(true) + } else { + wifiAwarePermissionLauncher.launch(missing.toTypedArray()) + } + } + val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState() + val wifiAwareConnected by manager.wifiAwareConnected.collectAsState() + val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState() + val wifiAwareAvailable by com.bitchat.android.wifiaware.WifiAwareController.available.collectAsState() + val wifiAwareSupportStatus by com.bitchat.android.wifiaware.WifiAwareController.supportStatus.collectAsState() + // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + // Push live connected devices from mesh service whenever sheet is visible + LaunchedEffect(isPresented) { + if (isPresented) { + // Poll device list periodically for now (TODO: add callbacks) + while (true) { + val entries = meshService.connectionManager.getConnectedDeviceEntries() + val mapping = meshService.getDeviceAddressToPeerMapping() + val peers = mapping.values.toSet() + val nicknames = meshService.getPeerNicknames() + val directMap = peers.associateWith { pid -> meshService.getPeerInfo(pid)?.isDirectConnection == true } + val devices = entries.map { (address, isClient, rssi) -> + val pid = mapping[address] + com.bitchat.android.ui.debug.ConnectedDevice( + deviceAddress = address, + peerID = pid, + nickname = pid?.let { nicknames[it] }, + rssi = rssi, + connectionType = if (isClient) ConnectionType.GATT_CLIENT else ConnectionType.GATT_SERVER, + isDirectConnection = pid?.let { directMap[it] } ?: false + ) + } + manager.updateConnectedDevices(devices) + // Also surface Wi‑Fi Aware status + try { + val ctrl = com.bitchat.android.wifiaware.WifiAwareController + val known = ctrl.knownPeers.value + val discovered = ctrl.discoveredPeers.value + val discoveredMap = discovered.associateWith { pid -> known[pid] ?: "" } + manager.updateWifiAwareDiscovered(discoveredMap) + manager.updateWifiAwareConnected(ctrl.connectedPeers.value) + } catch (_: Exception) { } + kotlinx.coroutines.delay(1000) + } + } + } + + LaunchedEffect(isPresented) { + if (isPresented) { + distributionInfo = withContext(Dispatchers.IO) { + runCatching { DistributionInfoProvider.inspect(context) }.getOrNull() + } + } + } + + val scope = rememberCoroutineScope() + + if (!isPresented) return + + BitchatBottomSheet( + onDismissRequest = onDismiss, + ) { + // Mark debug sheet visible/invisible to gate heavy work + LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) } + DisposableEffect(Unit) { + onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) } + } + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 80.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text( + text = stringResource(R.string.debug_tools_desc), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + item { + DistributionInfoSection(distributionInfo) + } + // Verbose logging toggle + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851)) + Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) }) + } + Text( + stringResource(R.string.debug_verbose_hint), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } + + // Mesh topology visualization (moved below verbose logging) + item { + val blePeerIDs = remember(connectedDevices) { + connectedDevices.mapNotNull { it.peerID }.toSet() + } + MeshTopologySection( + localPeerID = meshService.myPeerID, + blePeerIDs = blePeerIDs, + ) + } + + // GATT controls + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) + Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) + Switch(checked = gattServerEnabled, onCheckedChange = { + manager.setGattServerEnabled(it) + scope.launch { + if (it) meshService.connectionManager.startServer() else meshService.connectionManager.stopServer() + } + }) + } + val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER } + Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) + Slider( + value = maxServer.toFloat(), + onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) }, + valueRange = 1f..32f, + steps = 30 + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) + Switch(checked = gattClientEnabled, onCheckedChange = { + manager.setGattClientEnabled(it) + scope.launch { + if (it) meshService.connectionManager.startClient() else meshService.connectionManager.stopClient() + } + }) + } + val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT } + Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) + Slider( + value = maxClient.toFloat(), + onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) }, + valueRange = 1f..32f, + steps = 30 + ) + } + val overallCount = connectedDevices.size + Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) + Slider( + value = maxOverall.toFloat(), + onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) }, + valueRange = 1f..32f, + steps = 30 + ) + } + Text( + stringResource(R.string.debug_roles_hint), + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } + + // Transport toggles (BLE + Wi‑Fi Aware) + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) + Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) + Spacer(Modifier.width(8.dp)) + Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) + Switch(checked = bleEnabled, onCheckedChange = { + manager.setBleEnabled(it) + }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) + Spacer(Modifier.width(8.dp)) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) + val wifiSwitchEnabled = wifiAwareSupported + Text( + when { + !wifiAwareSupported -> "unsupported" + wifiAwareAvailable -> "available" + else -> "unavailable" + }, + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(Modifier.width(8.dp)) + Switch( + checked = wifiAwareEnabled && wifiAwareSupported, + enabled = wifiSwitchEnabled, + onCheckedChange = { on -> + if (on) enableWifiAware() else manager.setWifiAwareEnabled(false) + } + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(24.dp)) + Text("Wi‑Fi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) + Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) + } + } + } + } + + // Packet relay controls and stats + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) + Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) + } + // Removed aggregate labels; we will show per-direction compact labels below titles + // Toggle: overall vs per-connection vs per-peer + var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) } + val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState() + val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState() + val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState() + val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState() + val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState() + val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState() + val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState() + val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState() + val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState() + val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState() + val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState() + val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState() + val nicknameMap = remember { mutableStateOf>(emptyMap()) } + val devicePeerMap = remember { mutableStateOf>(emptyMap()) } + LaunchedEffect(Unit) { + try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { } + // Try to fetch device->peer map periodically for legend resolution + while (isPresented) { + try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { } + kotlinx.coroutines.delay(1000) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Mode selector + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = graphMode == GraphMode.OVERALL, + onClick = { graphMode = GraphMode.OVERALL }, + label = { Text("Overall") } + ) + FilterChip( + selected = graphMode == GraphMode.PER_DEVICE, + onClick = { graphMode = GraphMode.PER_DEVICE }, + label = { Text("Per Device") }, + leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) } + ) + FilterChip( + selected = graphMode == GraphMode.PER_PEER, + onClick = { graphMode = GraphMode.PER_PEER }, + label = { Text("Per Peer") }, + leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) } + ) + } + + // Time series state + var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf()) } + var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf()) } + var stackedSeriesIncoming by rememberSaveable { mutableStateOf>>(emptyMap()) } + var stackedSeriesOutgoing by rememberSaveable { mutableStateOf>>(emptyMap()) } + var highlightedKey by rememberSaveable { mutableStateOf(null) } + + // Color palette for stacked legend + val palette = remember { + listOf( + Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30), + Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759), + Color(0xFFFFCC00), Color(0xFF5856D6) + ) + } + val colorForKey = remember { mutableStateMapOf() } + fun stableColorFor(key: String): Color { + // Deterministic fallback color based on key hash using HSV palette + val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360 + return Color.hsv(h.toFloat(), 0.65f, 0.95f) + } + // Ensure colors are assigned for current keys before drawing + fun ensureColors(keys: List) { + keys.forEachIndexed { idx, k -> + colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k)) + } + } + + LaunchedEffect(isPresented, graphMode) { + while (isPresented) { + when (graphMode) { + GraphMode.OVERALL -> { + val sIn = relayStats.lastSecondIncoming.toFloat() + val sOut = relayStats.lastSecondOutgoing.toFloat() + overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60) + overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60) + } + GraphMode.PER_DEVICE -> { + val snapshotIn = perDeviceIncoming + val snapshotOut = perDeviceOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + // Advance and prune fully-stale series (all-zero in visible window) + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() + } + GraphMode.PER_PEER -> { + val snapshotIn = perPeerIncoming + val snapshotOut = perPeerOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() + } + } + kotlinx.coroutines.delay(1000) + } + } + + // Helper functions moved to top-level composable below to avoid scope issues + + // Render two blocks: Incoming and Outgoing + Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Incoming", + stackedKeys = stackedKeysIncoming, + stackedSeries = stackedSeriesIncoming, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerIncoming[key] ?: 0 + val m = perPeerIncoming1m[key] ?: 0 + val t = (perPeerIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceIncoming[key] ?: 0 + val m = perDeviceIncoming1m[key] ?: 0 + val t = (perDeviceIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } + } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } + + Spacer(Modifier.height(8.dp)) + Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Outgoing", + stackedKeys = stackedKeysOutgoing, + stackedSeries = stackedSeriesOutgoing, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerOutgoing[key] ?: 0 + val m = perPeerOutgoing1m[key] ?: 0 + val t = (perPeerOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceOutgoing[key] ?: 0 + val m = perDeviceOutgoing1m[key] ?: 0 + val t = (perDeviceOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } + } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } + } + } + } + } + + // Wi‑Fi Aware controls and status + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + val wifiStatusText = when { + !wifiAwareSupported -> "unsupported" + running -> "running" + !wifiAwareAvailable -> "unavailable" + else -> "stopped" + } + Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + if (!wifiAwareSupported) { + Text( + wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device", + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AssistChip( + onClick = enableWifiAware, + enabled = wifiAwareSupported, + label = { Text("Start") } + ) + AssistChip(onClick = { manager.setWifiAwareEnabled(false) }, label = { Text("Stop") }) + AssistChip( + onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, + enabled = running, + label = { Text("Announce") } + ) + } + Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + if (wifiAwareDiscovered.isEmpty()) { + Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) -> + Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp) + } + } + Divider() + Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + if (wifiAwareConnected.isEmpty()) { + Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + wifiAwareConnected.entries.take(50).forEach { (peer, ip) -> + Text("• ${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp) + } + } + } + } + } + + // Connected devices + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0)) + Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99) + Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0) + Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49) + val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) } + val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) } + Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + } + } + + // Connected devices + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) + Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() } + Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + if (connectedDevices.isEmpty()) { + Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + connectedDevices.forEach { dev -> + Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { + Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client) + Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { + meshService.connectionManager.disconnectAddress(dev.deviceAddress) + }) + } + } + } + } + } + } + } + + // Recent scan results + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) + Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + if (scanResults.isEmpty()) { + Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + scanResults.forEach { res -> + Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { + Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { + meshService.connectionManager.connectToAddress(res.deviceAddress) + }) + } + } + } + } + } + } + } + + // Debug console + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) + Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { + manager.clearDebugMessages() + }) + } + Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) { + debugMessages.takeLast(100).reversed().forEach { msg -> + Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp) + } + } + } + } + } + + item { Spacer(Modifier.height(16.dp)) } + } + + BitchatSheetTopBar( + onClose = onDismiss, + modifier = Modifier.align(Alignment.TopCenter), + backgroundAlpha = topBarAlpha, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.BugReport, + contentDescription = null, + tint = Color(0xFFFF9500) + ) + BitchatSheetTitle( + text = stringResource(R.string.debug_tools) + ) + } + } + ) + } + } +} + +@Composable +private fun DrawGraphBlock( + title: String, + stackedKeys: List, + stackedSeries: Map>, + overallSeries: List?, + graphMode: GraphMode, + highlightedKey: String?, + onToggleHighlight: (String) -> Unit, + ensureColors: (List) -> Unit, + colorForKey: (String) -> Color, + legendTitleFor: (String) -> String, + legendMetricsFor: (String) -> String +) { + val colorScheme = MaterialTheme.colorScheme + val leftGutter = 40.dp + Box(Modifier.fillMaxWidth().height(56.dp)) { + androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { + val axisPx = leftGutter.toPx() + val barCount = 60 + val availW = (size.width - axisPx).coerceAtLeast(1f) + val w = availW / barCount + val h = size.height + drawLine( + color = Color(0x33888888), + start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), + end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), + strokeWidth = 1f + ) + + when (graphMode) { + GraphMode.OVERALL -> { + val maxValRaw = (overallSeries?.maxOrNull() ?: 0f) + val maxVal = if (maxValRaw > 0f) maxValRaw else 0f + (overallSeries ?: emptyList()).forEachIndexed { i, value -> + if (value > 0f && maxVal > 0f) { + val ratio = (value / maxVal).coerceIn(0f, 1f) + val barHeight = (h * ratio).coerceAtLeast(0f) + if (barHeight > 0.5f) { + drawRect( + color = Color(0xFF00C851), + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), + size = androidx.compose.ui.geometry.Size(w, barHeight) + ) + } + } + } + } + else -> { + val indices = 0 until 60 + val totals = indices.map { idx -> + stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() + } + val maxTotal = (totals.maxOrNull() ?: 0f) + val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + indices.forEach { i -> + var yTop = h + if (maxTotal > 0f) { + ensureColors(drawKeysBars) + drawKeysBars.forEach { k -> + val v = stackedSeries[k]?.getOrNull(i) ?: 0f + if (v > 0f) { + val ratio = (v / maxTotal).coerceIn(0f, 1f) + val segH = (h * ratio) + if (segH > 0.5f) { + val top = (yTop - segH) + val baseColor = colorForKey(k) + val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f) + drawRect( + color = c, + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top), + size = androidx.compose.ui.geometry.Size(w, segH) + ) + yTop = top + } + } + } + } + } + } + } + } + + Row(Modifier.fillMaxSize()) { + Box(Modifier.width(leftGutter).fillMaxHeight()) { + Text( + "p/s", + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) + ) + val topLabel = when (graphMode) { + GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString() + else -> { + val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() } + (totals.maxOrNull() ?: 0f).toInt().toString() + } + } + Text( + topLabel, + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) + ) + Text( + "0", + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) + ) + } + Spacer(Modifier.weight(1f)) + } + } + + val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) { + Column(Modifier.fillMaxWidth()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + drawKeys.forEach { key -> + val baseColor = colorForKey(key) + val dimmed = highlightedKey != null && highlightedKey != key + val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clickable { onToggleHighlight(key) } + ) { + Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) + Column { + Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt new file mode 100644 index 00000000..3254c565 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -0,0 +1,506 @@ +package com.bitchat.android.ui.debug + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.services.meshgraph.MeshGraphService +import kotlin.math.* +import kotlin.random.Random +import androidx.compose.material3.MaterialTheme +import com.bitchat.android.ui.debug.DebugSettingsManager.MeshVisualEvent + +// Physics constants +private const val REPULSION_FORCE = 100000f +private const val SPRING_LENGTH = 150f +private const val SPRING_STRENGTH = 0.02f +private const val CENTER_GRAVITY = 0.02f +private const val DAMPING = 0.85f +private const val MAX_VELOCITY = 30f +private const val PULSE_DECAY = 0.05f +private const val ROUTE_DECAY = 0.02f +private val EDGE_ICON_OFFSET = 14.dp + +private enum class EdgeTransport { WIFI, BLE } + +private data class EdgeMarker(val x: Float, val y: Float, val transport: EdgeTransport) + +private fun shouldMarkDirectEdge( + edge: MeshGraphService.GraphEdge, + transportPeerIDs: Set, + localID: String? +): Boolean { + if (transportPeerIDs.isEmpty()) return false + return if (localID != null) { + (edge.a == localID && edge.b in transportPeerIDs) || + (edge.b == localID && edge.a in transportPeerIDs) + } else { + edge.a in transportPeerIDs || edge.b in transportPeerIDs + } +} + +private fun edgeMarkerPosition( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + offsetPx: Float, + sideSign: Float +): Pair { + val midX = (x1 + x2) / 2f + val midY = (y1 + y2) / 2f + val dx = x2 - x1 + val dy = y2 - y1 + val len = sqrt(dx * dx + dy * dy) + if (len < 0.1f) return midX to midY + val px = -dy / len * offsetPx * sideSign + val py = dx / len * offsetPx * sideSign + return (midX + px) to (midY + py) +} + +private class GraphNodeState( + val id: String, + var label: String, + var x: Float, + var y: Float +) { + var vx: Float = 0f + var vy: Float = 0f + var isDragged: Boolean = false + var pulseLevel: Float = 0f // 0f to 1f, used for size/glow animation +} + +private class Simulation { + val nodes = mutableMapOf() + // Storing edges as pairs of IDs + val edges = mutableListOf() + // Active routes being animated: List of peerIDs in order -> intensity (1.0..0.0) + val activeRoutes = mutableListOf, Float>>() + + // Bounds for initial placement and centering + var width: Float = 1000f + var height: Float = 1000f + + fun updateTopology( + newNodes: List, + newEdges: List + ) { + // Remove stale nodes + val newIds = newNodes.map { it.peerID }.toSet() + nodes.keys.toList().forEach { id -> + if (id !in newIds) nodes.remove(id) + } + + // Add/Update nodes + newNodes.forEach { n -> + val existing = nodes[n.peerID] + val displayLabel = n.nickname ?: n.peerID.take(8) + if (existing != null) { + existing.label = displayLabel + } else { + // Spawn near center with random jitter + val angle = Random.nextFloat() * 2 * PI + val radius = 50f + Random.nextFloat() * 50f + nodes[n.peerID] = GraphNodeState( + id = n.peerID, + label = displayLabel, + x = (width / 2f) + (cos(angle) * radius).toFloat(), + y = (height / 2f) + (sin(angle) * radius).toFloat() + ) + } + } + + // Update edges + edges.clear() + edges.addAll(newEdges) + } + + fun triggerNodePulse(peerID: String) { + nodes[peerID]?.pulseLevel = 1f + } + + fun triggerRouteAnimation(route: List) { + if (route.size > 1) { + activeRoutes.add(route to 1f) + } + } + + fun step() { + val nodeList = nodes.values.toList() + val cx = width / 2f + val cy = height / 2f + + // 1. Repulsion (Node-Node) + for (i in nodeList.indices) { + val n1 = nodeList[i] + for (j in i + 1 until nodeList.size) { + val n2 = nodeList[j] + val dx = n1.x - n2.x + val dy = n1.y - n2.y + val distSq = dx * dx + dy * dy + if (distSq > 0.1f) { + val dist = sqrt(distSq) + val force = REPULSION_FORCE / distSq + val fx = (dx / dist) * force + val fy = (dy / dist) * force + + if (!n1.isDragged) { + n1.vx += fx + n1.vy += fy + } + if (!n2.isDragged) { + n2.vx -= fx + n2.vy -= fy + } + } + } + } + + // 2. Attraction (Edges) + edges.forEach { edge -> + val n1 = nodes[edge.a] + val n2 = nodes[edge.b] + if (n1 != null && n2 != null) { + val dx = n1.x - n2.x + val dy = n1.y - n2.y + val dist = sqrt(dx * dx + dy * dy) + if (dist > 0.1f) { + val force = (dist - SPRING_LENGTH) * SPRING_STRENGTH + val fx = (dx / dist) * force + val fy = (dy / dist) * force + + if (!n1.isDragged) { + n1.vx -= fx + n1.vy -= fy + } + if (!n2.isDragged) { + n2.vx += fx + n2.vy += fy + } + } + } + } + + // 3. Center Gravity & Integration & Animation Decay + nodeList.forEach { n -> + if (!n.isDragged) { + // Pull to center + val dx = n.x - cx + val dy = n.y - cy + n.vx -= dx * CENTER_GRAVITY + n.vy -= dy * CENTER_GRAVITY + + // Apply velocity + val vMag = sqrt(n.vx * n.vx + n.vy * n.vy) + if (vMag > MAX_VELOCITY) { + n.vx = (n.vx / vMag) * MAX_VELOCITY + n.vy = (n.vy / vMag) * MAX_VELOCITY + } + + n.x += n.vx + n.y += n.vy + + // Damping + n.vx *= DAMPING + n.vy *= DAMPING + } else { + n.vx = 0f + n.vy = 0f + } + + // Decay pulse + if (n.pulseLevel > 0f) { + n.pulseLevel = (n.pulseLevel - PULSE_DECAY).coerceAtLeast(0f) + } + } + + // Decay active routes + val iter = activeRoutes.iterator() + while (iter.hasNext()) { + val (route, intensity) = iter.next() + val newIntensity = intensity - ROUTE_DECAY + if (newIntensity <= 0f) { + iter.remove() + } else { + // Ugly mutation but efficient for simulation loop + // We need to replace the pair since pairs are immutable + // Finding index is O(N) but N is small + val idx = activeRoutes.indexOfFirst { it.first === route && it.second == intensity } + if (idx >= 0) { + activeRoutes[idx] = route to newIntensity + } + } + } + } +} + +@Composable +fun ForceDirectedMeshGraph( + nodes: List, + edges: List, + wifiAwarePeerIDs: Set = emptySet(), + blePeerIDs: Set = emptySet(), + localPeerID: String? = null, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val simulation = remember { Simulation() } + val colorScheme = MaterialTheme.colorScheme + + // Listen for visual events + val debugManager = remember { DebugSettingsManager.getInstance() } + LaunchedEffect(Unit) { + debugManager.meshVisualEvents.collect { event -> + when (event) { + is MeshVisualEvent.PacketActivity -> simulation.triggerNodePulse(event.peerID) + is MeshVisualEvent.RouteActivity -> simulation.triggerRouteAnimation(event.route) + } + } + } + + // We need a state that changes on every tick to trigger redraw + var tick by remember { mutableLongStateOf(0L) } + + // Update topology when input data changes + LaunchedEffect(nodes, edges) { + simulation.updateTopology(nodes, edges) + } + + // Animation Loop + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { + simulation.step() + tick++ + } + } + } + + BoxWithConstraints(modifier = modifier) { + val w = maxWidth.value * density.density + val h = maxHeight.value * density.density + + // Update simulation bounds if size changes + SideEffect { + simulation.width = w + simulation.height = h + } + + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + // Find closest node + val closest = simulation.nodes.values.minByOrNull { + val dx = it.x - offset.x + val dy = it.y - offset.y + dx*dx + dy*dy + } + if (closest != null) { + val dist = sqrt((closest.x - offset.x).pow(2) + (closest.y - offset.y).pow(2)) + if (dist < 80f) { // Touch radius + closest.isDragged = true + } + } + }, + onDragEnd = { + simulation.nodes.values.forEach { it.isDragged = false } + }, + onDragCancel = { + simulation.nodes.values.forEach { it.isDragged = false } + }, + onDrag = { change, dragAmount -> + change.consume() + val dragged = simulation.nodes.values.find { it.isDragged } + if (dragged != null) { + dragged.x += dragAmount.x + dragged.y += dragAmount.y + } + } + ) + } + ) { + // Read tick to ensure recomposition + val t = tick + + val nodeMap = simulation.nodes + + // Draw Edges + simulation.edges.forEach { edge -> + val n1 = nodeMap[edge.a] + val n2 = nodeMap[edge.b] + + if (n1 != null && n2 != null) { + val start = Offset(n1.x, n1.y) + val end = Offset(n2.x, n2.y) + val baseColor = Color(0xFF4A90E2) + + if (edge.isConfirmed) { + drawLine( + color = baseColor, + start = start, + end = end, + strokeWidth = 5f + ) + } else { + // Unconfirmed: draw "solid" from declarer, "dashed" from other + val isA = (edge.confirmedBy == edge.a) + val solidStart = if (isA) start else end + val solidEnd = if (isA) end else start + + val midX = (start.x + end.x) / 2 + val midY = (start.y + end.y) / 2 + val mid = Offset(midX, midY) + + drawLine( + color = baseColor, + start = solidStart, + end = mid, + strokeWidth = 4f + ) + + drawLine( + color = baseColor.copy(alpha = 0.6f), + start = mid, + end = solidEnd, + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + } + } + } + + // Draw Active Routes (overlay) + simulation.activeRoutes.forEach { (route, intensity) -> + val routeColor = Color(0xFFFFD700).copy(alpha = intensity) // Gold + val strokeW = 4f * intensity + 2f + + for (i in 0 until route.size - 1) { + val p1 = nodeMap[route[i]] + val p2 = nodeMap[route[i+1]] + if (p1 != null && p2 != null) { + drawLine( + color = routeColor, + start = Offset(p1.x, p1.y), + end = Offset(p2.x, p2.y), + strokeWidth = strokeW, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + } + } + + // Draw Nodes + val labelColor = colorScheme.onSurface.toArgb() + val textPaint = android.graphics.Paint().apply { + isAntiAlias = true + textSize = 12.sp.toPx() + this.color = labelColor + } + + nodeMap.values.forEach { node -> + val center = Offset(node.x, node.y) + val pulse = node.pulseLevel + + // Pulse glow + if (pulse > 0.05f) { + drawCircle( + color = Color(0xFF00FF00).copy(alpha = pulse * 0.6f), + radius = 16f + (pulse * 20f), + center = center + ) + } + + drawCircle( + color = Color(0xFF00C851), + radius = 16f + (pulse * 4f), // Slight scale up + center = center + ) + drawCircle( + color = Color.White, + radius = 12f + (pulse * 3f), + center = center, + style = Stroke(width = 2f) + ) + + // Label + drawContext.canvas.nativeCanvas.drawText( + node.label, + node.x + 22f + (pulse * 5f), + node.y + 4f, + textPaint + ) + } + } + + val iconSize = 16.dp + val iconSizePx = with(density) { iconSize.toPx() } + val halfIconSizePx = iconSizePx / 2f + val iconOffsetPx = with(density) { EDGE_ICON_OFFSET.toPx() } + val localID = localPeerID + + val edgeMarkers = tick.let { + simulation.edges.flatMap { edge -> + val n1 = simulation.nodes[edge.a] + val n2 = simulation.nodes[edge.b] + if (n1 == null || n2 == null) return@flatMap emptyList() + + val isWifi = shouldMarkDirectEdge(edge, wifiAwarePeerIDs, localID) + val isBle = shouldMarkDirectEdge(edge, blePeerIDs, localID) + if (!isWifi && !isBle) return@flatMap emptyList() + + val markers = mutableListOf() + if (isWifi) { + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = 1f) + markers.add(EdgeMarker(x, y, EdgeTransport.WIFI)) + } + if (isBle) { + val side = if (isWifi) -1f else 1f + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = side) + markers.add(EdgeMarker(x, y, EdgeTransport.BLE)) + } + markers + } + } + + edgeMarkers.forEach { marker -> + Icon( + imageVector = when (marker.transport) { + EdgeTransport.WIFI -> Icons.Filled.Wifi + EdgeTransport.BLE -> Icons.Outlined.Bluetooth + }, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.82f), + modifier = Modifier + .offset { + IntOffset( + x = (marker.x - halfIconSizePx).roundToInt(), + y = (marker.y - halfIconSizePx).roundToInt() + ) + } + .size(iconSize) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/events/FileShareDispatcher.kt b/app/src/main/java/com/bitchat/android/ui/events/FileShareDispatcher.kt new file mode 100644 index 00000000..13bda8d7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/events/FileShareDispatcher.kt @@ -0,0 +1,17 @@ +package com.bitchat.android.ui.events + +/** + * Lightweight dispatcher so lower-level UI (MessageInput) can trigger + * file sending without holding a direct reference to ChatViewModel. + */ +object FileShareDispatcher { + @Volatile private var handler: ((String?, String?, String) -> Unit)? = null + + fun setHandler(h: ((String?, String?, String) -> Unit)?) { + handler = h + } + + fun dispatch(peerIdOrNull: String?, channelOrNull: String?, path: String) { + handler?.invoke(peerIdOrNull, channelOrNull, path) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt new file mode 100644 index 00000000..42f41f8e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt @@ -0,0 +1,127 @@ +package com.bitchat.android.ui.globe + +import kotlin.math.* + +/** + * Orthographic globe projection and geohash zoom math for the 3D globe picker. + * + * The globe is rendered as a disc of radius R centered on screen. Points are + * projected with an orthographic projection around a view center lat/lon. + */ +object GlobeMath { + + data class Projection(val x: Float, val y: Float, val cosC: Float) + + /** + * Frame-local projector for prepared static geometry. Center terms are calculated once + * and projection results are written to a caller-owned array without allocating objects. + */ + class PreparedProjector(centerLatDeg: Double, centerLonDeg: Double) { + private val sinCenterLat = sin(Math.toRadians(centerLatDeg)).toFloat() + private val cosCenterLat = cos(Math.toRadians(centerLatDeg)).toFloat() + private val sinCenterLon = sin(Math.toRadians(centerLonDeg)).toFloat() + private val cosCenterLon = cos(Math.toRadians(centerLonDeg)).toFloat() + + fun project(terms: FloatArray, termOffset: Int, out: FloatArray, outOffset: Int) { + val sinLat = terms[termOffset] + val cosLat = terms[termOffset + 1] + val sinLon = terms[termOffset + 2] + val cosLon = terms[termOffset + 3] + val sinDeltaLon = sinLon * cosCenterLon - cosLon * sinCenterLon + val cosDeltaLon = cosLon * cosCenterLon + sinLon * sinCenterLon + out[outOffset] = cosLat * sinDeltaLon + out[outOffset + 1] = -( + cosCenterLat * sinLat - + sinCenterLat * cosLat * cosDeltaLon + ) + out[outOffset + 2] = + sinCenterLat * sinLat + cosCenterLat * cosLat * cosDeltaLon + } + } + + /** + * Projects (lat, lon) onto the view disc of a globe centered at (centerLat, centerLon). + * Returns x/y in units of globe radius (screen y down). [Projection.cosC] is negative + * when the point is on the far side of the sphere. + */ + fun projectRaw(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection { + val phi = Math.toRadians(latDeg) + val phi0 = Math.toRadians(centerLatDeg) + val dLambda = Math.toRadians(normalizeLon(lonDeg - centerLonDeg)) + val cosC = sin(phi0) * sin(phi) + cos(phi0) * cos(phi) * cos(dLambda) + val x = cos(phi) * sin(dLambda) + val y = -(cos(phi0) * sin(phi) - sin(phi0) * cos(phi) * cos(dLambda)) + return Projection(x.toFloat(), y.toFloat(), cosC.toFloat()) + } + + /** Like [projectRaw] but null when the point is behind the limb. */ + fun project(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection? { + val p = projectRaw(latDeg, lonDeg, centerLatDeg, centerLonDeg) + return if (p.cosC >= 0f) p else null + } + + /** + * Inverse projection: disc coordinates (units of radius, screen y down) back to lat/lon. + * Returns null if the point lies outside the disc. + */ + fun unproject(x: Double, y: Double, centerLatDeg: Double, centerLonDeg: Double): Pair? { + val rho = sqrt(x * x + y * y) + if (rho > 1.0) return null + val c = asin(rho.coerceIn(-1.0, 1.0)) + val phi0 = Math.toRadians(centerLatDeg) + val sinC = sin(c) + val cosC = cos(c) + val lat: Double + val lonOffset: Double + if (rho < 1e-9) { + lat = centerLatDeg + lonOffset = 0.0 + } else { + lat = Math.toDegrees(asin(cosC * sin(phi0) + (-y) * sinC * cos(phi0) / rho)) + lonOffset = Math.toDegrees(atan2(x * sinC, rho * cos(phi0) * cosC - (-y) * sin(phi0) * sinC)) + } + return lat to normalizeLon(centerLonDeg + lonOffset) + } + + fun normalizeLon(lon: Double): Double { + var x = lon % 360.0 + if (x > 180.0) x -= 360.0 + if (x < -180.0) x += 360.0 + return x + } + + /** Longitude span of a geohash cell in degrees. */ + fun cellSpanLon(precision: Int): Double = 360.0 / 2.0.pow(ceil(5.0 * precision / 2.0)) + + /** Latitude span of a geohash cell in degrees. */ + fun cellSpanLat(precision: Int): Double = 180.0 / 2.0.pow(floor(5.0 * precision / 2.0)) + + /** + * Picks the geohash precision whose cells render at a comfortable on-screen size + * for the current zoom: the largest precision whose cell is at least ~22% of the + * screen's smallest dimension. + */ + fun autoPrecision(globeRadiusPx: Float, screenMinPx: Float): Int { + val targetPx = screenMinPx * 0.22f + var best = 1 + for (p in 1..MAX_PRECISION) { + val spanPx = (cellSpanLat(p) * (Math.PI / 180.0) * globeRadiusPx).toFloat() + if (spanPx >= targetPx) best = p else break + } + return best.coerceIn(1, MAX_PRECISION) + } + + /** + * Zoom factor (globe radius multiplier over the fit-to-screen base radius) that frames + * a cell of the given precision nicely: the cell spans ~1/3 of the screen. + */ + fun zoomForPrecision(precision: Int, baseRadiusPx: Float, screenMinPx: Float): Float { + val spanRad = cellSpanLat(precision) * (Math.PI / 180.0) + val targetRadius = (screenMinPx / 3.0) / spanRad + return (targetRadius / baseRadiusPx).toFloat().coerceIn(MIN_ZOOM, MAX_ZOOM) + } + + const val MIN_ZOOM = 1f + const val MAX_ZOOM = 120000f + const val MAX_PRECISION = 12 +} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt new file mode 100644 index 00000000..6c62d0ef --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt @@ -0,0 +1,228 @@ +package com.bitchat.android.ui.globe + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.getValue +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.bitchat.android.geohash.Geohash +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import androidx.compose.runtime.withFrameNanos +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.pow + +/** + * Hoisted state for the interactive 3D globe: view center, zoom, geohash precision + * and the current selection. All mutation funnels through this class so rendering, + * gestures and buttons stay in sync. + */ +@Stable +class GlobeState( + targetLat: Double, + targetLon: Double, + initialPrecision: Int, + startZoomedOut: Boolean +) { + var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat()) + private set + var centerLon by mutableFloatStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0).toFloat() else targetLon.toFloat()) + private set + var zoom by mutableFloatStateOf(if (startZoomedOut) GlobeMath.MIN_ZOOM else 1f) + private set + var precision by mutableIntStateOf(initialPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)) + private set + var selectedGeohash by mutableStateOf("") + private set + var isInteracting by mutableStateOf(false) + internal set + var isAnimating by mutableStateOf(false) + private set + + internal var baseRadiusPx by mutableFloatStateOf(0f) + internal var screenMinPx by mutableFloatStateOf(0f) + + private var scope: CoroutineScope? = null + private var animJob: Job? = null + private var animationGeneration = 0 + + /** Pending cinematic intro target (lat, lon, precision); consumed when played. */ + var introTarget: Triple? = null + private var introPlayed = false + + fun playPendingIntroIfAny() { + if (introPlayed) return + val target = introTarget ?: return + introPlayed = true + introTarget = null + playIntro(target.first, target.second, target.third) + } + + fun attach(scope: CoroutineScope) { + this.scope = scope + } + + fun setViewport(baseRadiusPx: Float, screenMinPx: Float) { + if (baseRadiusPx <= 0f || screenMinPx <= 0f) return + this.baseRadiusPx = baseRadiusPx + this.screenMinPx = screenMinPx + syncSelection() + } + + val globeRadiusPx: Float get() = baseRadiusPx * zoom + + /** Direct rotation from drag gestures. Deltas are in screen px. */ + fun rotateBy(dxPx: Float, dyPx: Float) { + val r = globeRadiusPx + if (r <= 0f) return + val degPerPx = 180.0 / (Math.PI * r) + centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx).toFloat() + centerLat = (centerLat + dyPx * degPerPx).toFloat().coerceIn(MIN_LAT, MAX_LAT) + syncSelection() + } + + /** Continuous zoom from pinch gestures; precision follows automatically. */ + fun zoomBy(factor: Float) { + if (factor == 1f) return + zoom = (zoom * factor).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM) + syncPrecisionFromZoom() + syncSelection() + } + + private fun syncPrecisionFromZoom() { + if (baseRadiusPx <= 0f) return + precision = GlobeMath.autoPrecision(globeRadiusPx, screenMinPx) + } + + /** Animate the view to a lat/lon, optionally to a zoom and precision. */ + fun animateTo( + lat: Double, + lon: Double, + targetZoom: Float? = null, + targetPrecision: Int? = null, + durationMs: Int = 550 + ) { + val s = scope ?: return + animJob?.cancel() + val startLat = centerLat + val startLon = centerLon + val dLon = GlobeMath.normalizeLon(lon - startLon) + val startZoom = zoom + val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM) + val generation = ++animationGeneration + isAnimating = true + animJob = s.launch { + try { + val anim = Animatable(0f) + anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) { + val t = value + centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT) + centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat() + // exponential interpolation feels natural for zoom + zoom = startZoom * (endZoom / startZoom).pow(t) + if (targetPrecision != null) { + precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION) + } else { + syncPrecisionFromZoom() + } + syncSelection() + } + } finally { + if (animationGeneration == generation) { + isAnimating = false + animJob = null + } + } + } + } + + /** Button-driven precision change: adjusts precision and animates zoom to frame it. */ + fun animatePrecision(newPrecision: Int) { + val p = newPrecision.coerceIn(1, GlobeMath.MAX_PRECISION) + if (p == precision || baseRadiusPx <= 0f) return + val targetZoom = GlobeMath.zoomForPrecision(p, baseRadiusPx, screenMinPx) + animateTo(centerLat.toDouble(), centerLon.toDouble(), targetZoom, p, durationMs = 450) + } + + /** Cinematic intro: spin and zoom from a far view into the target location. */ + private fun playIntro(targetLat: Double, targetLon: Double, targetPrecision: Int) { + if (baseRadiusPx <= 0f) { + // viewport not ready yet; retry once attached to layout via caller + return + } + val targetZoom = GlobeMath.zoomForPrecision(targetPrecision, baseRadiusPx, screenMinPx) + animateTo(targetLat, targetLon, targetZoom, targetPrecision, durationMs = 1400) + } + + /** Inertial spin after a fling. Velocities are in px/second. */ + fun fling(velocityX: Float, velocityY: Float) { + val s = scope ?: return + var initialVx = velocityX.coerceIn(-MAX_FLING_PX_PER_SECOND, MAX_FLING_PX_PER_SECOND) + var initialVy = velocityY.coerceIn(-MAX_FLING_PX_PER_SECOND, MAX_FLING_PX_PER_SECOND) + if (abs(initialVx) < MIN_FLING_PX_PER_SECOND) initialVx = 0f + if (abs(initialVy) < MIN_FLING_PX_PER_SECOND) initialVy = 0f + if (initialVx == 0f && initialVy == 0f) return + animJob?.cancel() + val generation = ++animationGeneration + isAnimating = true + animJob = s.launch { + try { + var vx = initialVx + var vy = initialVy + var lastFrameNanos = withFrameNanos { it } + while (abs(vx) >= MIN_FLING_PX_PER_SECOND || abs(vy) >= MIN_FLING_PX_PER_SECOND) { + val frameNanos = withFrameNanos { it } + val dtSeconds = ((frameNanos - lastFrameNanos) / 1_000_000_000f) + .coerceIn(0f, MAX_FRAME_DELTA_SECONDS) + lastFrameNanos = frameNanos + val maxStep = globeRadiusPx * MAX_FLING_RADIUS_FRACTION_PER_FRAME + rotateBy( + (vx * dtSeconds).coerceIn(-maxStep, maxStep), + (vy * dtSeconds).coerceIn(-maxStep, maxStep) + ) + val decay = exp(-FLING_FRICTION_PER_SECOND * dtSeconds) + vx *= decay + vy *= decay + } + } finally { + if (animationGeneration == generation) { + isAnimating = false + animJob = null + } + } + } + } + + fun cancelAnimations() { + animationGeneration++ + animJob?.cancel() + animJob = null + isAnimating = false + } + + val isInMotion: Boolean get() = isInteracting || isAnimating + + private companion object { + // Close to the projection limit so polar geohash cells remain selectable; + // clamping tighter would make syncSelection() encode the wrong cell. + const val MIN_LAT = -89f + const val MAX_LAT = 89f + const val MIN_FLING_PX_PER_SECOND = 90f + const val MAX_FLING_PX_PER_SECOND = 3_200f + const val MAX_FRAME_DELTA_SECONDS = 1f / 30f + const val MAX_FLING_RADIUS_FRACTION_PER_FRAME = 0.12f + const val FLING_FRICTION_PER_SECOND = 4.2f + } + + private fun syncSelection() { + if (baseRadiusPx <= 0f) return + val gh = Geohash.encode(centerLat.toDouble(), centerLon.toDouble(), precision) + if (gh != selectedGeohash) selectedGeohash = gh + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt new file mode 100644 index 00000000..7066cf92 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt @@ -0,0 +1,1157 @@ +package com.bitchat.android.ui.globe + +import android.graphics.Paint +import android.graphics.Typeface +import android.os.SystemClock +import android.view.HapticFeedbackConstants +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.sp +import com.bitchat.android.geohash.Geohash +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlin.math.ceil +import kotlin.math.min +import kotlin.math.sqrt + +/** Color palette for the globe, derived from the app theme by the caller. */ +data class GlobeColors( + val accent: Color, + val land: Color, + val coastline: Color, + val border: Color, + val oceanCenter: Color, + val oceanEdge: Color, + val atmosphere: Color, + val graticule: Color, + val grid: Color, + val label: Color, + val labelHalo: Color, + val star: Color +) + +private class Star(val x: Float, val y: Float, val radius: Float, val alpha: Float) + +internal data class GlobeFrameDetail( + val graticuleStepDegrees: Double, + val landPointStride: Int, + val showBorders: Boolean, + val cityMaxRank: Int?, + val showCityLabels: Boolean, + val showGeohashGrid: Boolean, + val showNeighborCells: Boolean +) + +internal enum class GlobeMotionDetail { + FULL, + BALANCED, + FAST +} + +internal fun globeFrameDetail(motionDetail: GlobeMotionDetail): GlobeFrameDetail = + when (motionDetail) { + GlobeMotionDetail.FULL -> GlobeFrameDetail( + graticuleStepDegrees = 4.0, + landPointStride = 1, + showBorders = true, + cityMaxRank = null, + showCityLabels = true, + showGeohashGrid = true, + showNeighborCells = true + ) + GlobeMotionDetail.BALANCED -> GlobeFrameDetail( + graticuleStepDegrees = 8.0, + landPointStride = 2, + showBorders = true, + cityMaxRank = 1, + showCityLabels = false, + showGeohashGrid = true, + showNeighborCells = false + ) + GlobeMotionDetail.FAST -> GlobeFrameDetail( + graticuleStepDegrees = 10.0, + landPointStride = 2, + showBorders = false, + cityMaxRank = -1, + showCityLabels = false, + showGeohashGrid = false, + showNeighborCells = false + ) + } + +/** + * Adjusts moving-frame detail from measured frame cadence. Hysteresis keeps the renderer + * from oscillating between levels when timings sit near a boundary. + */ +internal fun nextGlobeMotionDetail( + current: GlobeMotionDetail, + averageFrameMillis: Float +): GlobeMotionDetail { + if (!averageFrameMillis.isFinite()) return GlobeMotionDetail.FAST + return when (current) { + GlobeMotionDetail.FULL -> when { + averageFrameMillis > SEVERELY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST + averageFrameMillis > SLOW_FRAME_MILLIS -> GlobeMotionDetail.BALANCED + else -> GlobeMotionDetail.FULL + } + GlobeMotionDetail.BALANCED -> when { + averageFrameMillis > VERY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST + averageFrameMillis < SMOOTH_FRAME_MILLIS -> GlobeMotionDetail.FULL + else -> GlobeMotionDetail.BALANCED + } + GlobeMotionDetail.FAST -> { + if (averageFrameMillis < RECOVERING_FRAME_MILLIS) { + GlobeMotionDetail.BALANCED + } else { + GlobeMotionDetail.FAST + } + } + } +} + +@Composable +fun GlobeView( + state: GlobeState, + colors: GlobeColors, + land: List, + borders: List, + cities: List, + labelTypeface: Typeface?, + labelTypefaceBold: Typeface?, + modifier: Modifier = Modifier +) { + val view = LocalView.current + val density = LocalDensity.current + + val stars = remember { + val rnd = kotlin.random.Random(42) + List(160) { + Star( + x = rnd.nextFloat(), + y = rnd.nextFloat(), + radius = 0.6f + rnd.nextFloat() * 1.5f, + alpha = 0.15f + rnd.nextFloat() * 0.55f + ) + } + } + + val maxRingPoints = remember(land) { land.maxOfOrNull { it.size } ?: 0 } + val scratch = remember(maxRingPoints) { FloatArray(maxOf(1, maxRingPoints) * 3) } + val maxBorderPoints = remember(borders) { borders.maxOfOrNull { it.size } ?: 0 } + val borderScratch = remember(maxBorderPoints) { FloatArray(maxOf(1, maxBorderPoints) * 3) } + + val labelPaint = remember { + Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER } + } + val haloPaint = remember { + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + style = Paint.Style.STROKE + } + } + + val pulse by rememberInfiniteTransition(label = "crosshair").animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1100), RepeatMode.Reverse), + label = "crosshairAlpha" + ) + + LaunchedEffect(state) { + // Fire intro once the viewport size is known. + snapshotFlow { state.baseRadiusPx }.first { it > 0f } + state.playPendingIntroIfAny() + } + + LaunchedEffect(state) { + snapshotFlow { state.selectedGeohash } + .drop(1) + .collect { + @Suppress("DEPRECATION") + view.performHapticFeedback( + HapticFeedbackConstants.KEYBOARD_TAP, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + } + } + + var motionDetail by remember(state) { mutableStateOf(GlobeMotionDetail.BALANCED) } + LaunchedEffect(state, state.isInMotion) { + if (!state.isInMotion) { + motionDetail = GlobeMotionDetail.FULL + return@LaunchedEffect + } + + // Balanced is a safe first frame; measured cadence then moves detail up or down. + motionDetail = GlobeMotionDetail.BALANCED + var previousFrameNanos = withFrameNanos { it } + var sampledFrameMillis = 0f + var sampledFrameCount = 0 + while (state.isInMotion) { + val frameNanos = withFrameNanos { it } + val frameMillis = ((frameNanos - previousFrameNanos) / 1_000_000f) + .coerceIn(1f, MAX_SAMPLED_FRAME_MILLIS) + previousFrameNanos = frameNanos + sampledFrameMillis += frameMillis + sampledFrameCount++ + + if (sampledFrameCount >= FRAME_SAMPLE_COUNT) { + motionDetail = nextGlobeMotionDetail( + current = motionDetail, + averageFrameMillis = sampledFrameMillis / sampledFrameCount + ) + sampledFrameMillis = 0f + sampledFrameCount = 0 + } + } + } + + val labelTextSize = with(density) { 12.5.sp.toPx() } + val labelTextSizeSmall = with(density) { 10.sp.toPx() } + + Box(modifier = modifier) { + // Static background lives in its own layer and is not invalidated by globe movement. + Canvas(modifier = Modifier.matchParentSize()) { + stars.forEach { star -> + drawCircle( + color = colors.star.copy(alpha = star.alpha), + radius = star.radius * density.density, + center = Offset(star.x * size.width, star.y * size.height) + ) + } + } + + Canvas( + modifier = Modifier + .matchParentSize() + .onSizeChanged { size: IntSize -> + val minDim = min(size.width, size.height).toFloat() + state.setViewport(minDim * 0.44f, minDim) + } + .pointerInput(state) { + var lastTapTime = 0L + var lastTapPos = Offset.Zero + awaitEachGesture { + val down = awaitFirstDown() + state.cancelAnimations() + state.isInteracting = true + val downTime = SystemClock.uptimeMillis() + val downPos = down.position + var moved = Offset.Zero + var dragStarted = false + var hadMultiplePointers = false + val velocityTracker = VelocityTracker() + velocityTracker.addPosition(down.uptimeMillis, down.position) + + while (true) { + val event = awaitPointerEvent() + val pressed = event.changes.filter { it.pressed } + if (pressed.isEmpty()) break + if (pressed.size > 1) { + hadMultiplePointers = true + velocityTracker.resetTracking() + } + + val pan = event.calculatePan() + val zoomChange = event.calculateZoom() + + if (pan != Offset.Zero) { + moved += pan + if (!dragStarted && moved.getDistance() >= viewConfiguration.touchSlop) { + dragStarted = true + } + if (dragStarted) state.rotateBy(pan.x, pan.y) + } + if (zoomChange != 1f) { + state.zoomBy(zoomChange) + } + if (!hadMultiplePointers && pressed.size == 1) { + val change = pressed[0] + velocityTracker.addPosition(change.uptimeMillis, change.position) + } + event.changes.forEach { if (it.positionChanged()) it.consume() } + } + + state.isInteracting = false + val upTime = SystemClock.uptimeMillis() + val isTap = !hadMultiplePointers && + !dragStarted && + upTime - downTime < 400 && + moved.getDistance() < viewConfiguration.touchSlop + + if (isTap) { + val cx = size.width / 2f + val cy = size.height / 2f + val r = state.globeRadiusPx + if (r > 0f) { + val latLon = GlobeMath.unproject( + ((downPos.x - cx) / r).toDouble(), + ((downPos.y - cy) / r).toDouble(), + state.centerLat.toDouble(), + state.centerLon.toDouble() + ) + if (latLon != null) { + val now = upTime + val lastTap = lastTapTime + val isDouble = now - lastTap < 350 && + (downPos - lastTapPos).getDistance() < viewConfiguration.touchSlop * 4 + lastTapTime = now + lastTapPos = downPos + if (isDouble) { + val targetZoom = (state.zoom * 1.9f).coerceAtMost(GlobeMath.MAX_ZOOM) + state.animateTo(latLon.first, latLon.second, targetZoom, null, 500) + } else { + state.animateTo(latLon.first, latLon.second, null, null, 450) + } + } + } + } else if (dragStarted && !hadMultiplePointers) { + val velocity = velocityTracker.calculateVelocity() + state.fling(velocity.x, velocity.y) + } + } + } + ) { + val cx = size.width / 2f + val cy = size.height / 2f + val baseR = state.baseRadiusPx + if (baseR <= 0f) return@Canvas + val r = state.globeRadiusPx + val cLat = state.centerLat.toDouble() + val cLon = state.centerLon.toDouble() + val preparedProjector = GlobeMath.PreparedProjector(cLat, cLon) + + // Atmosphere glow + drawCircle( + brush = Brush.radialGradient( + 0f to colors.atmosphere.copy(alpha = 0.30f), + 0.75f to colors.atmosphere.copy(alpha = 0.12f), + 1f to Color.Transparent, + center = Offset(cx, cy), + radius = r * 1.22f + ), + radius = r * 1.22f, + center = Offset(cx, cy) + ) + + // Ocean sphere + drawCircle( + brush = Brush.radialGradient( + 0f to colors.oceanCenter, + 0.7f to colors.oceanCenter, + 1f to colors.oceanEdge, + center = Offset(cx - r * 0.32f, cy - r * 0.38f), + radius = r * 1.5f + ), + radius = r, + center = Offset(cx, cy) + ) + + val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) + + val frameDetail = globeFrameDetail( + if (state.isInMotion) motionDetail else GlobeMotionDetail.FULL + ) + + // Graticule + drawGraticule( + cx, cy, r, cLat, cLon, colors.graticule, clip, + step = frameDetail.graticuleStepDegrees + ) + + // Fill every landmass first. Coastlines are drawn in a separate final pass so a + // large continent fill can never cover an island outline drawn earlier. + val coastlineRuns = ArrayList>>>(land.size) + for (ring in land) { + val runs = drawLandFill( + ring = ring, + scratch = scratch, + projector = preparedProjector, + cx = cx, + cy = cy, + r = r, + colors = colors, + clip = clip, + pointStride = if (ring.size >= 64) frameDetail.landPointStride else 1, + centerLat = cLat, + centerLon = cLon + ) + if (runs != null) coastlineRuns.add(runs) + } + for (runs in coastlineRuns) { + strokeRuns(runs, cx, cy, r, colors.coastline, 1.4f, clip) + } + + // Country borders are restored when interaction settles. + if (frameDetail.showBorders) { + for (line in borders) { + drawBorderLine(line, borderScratch, preparedProjector, cx, cy, r, colors, clip) + } + } + + // Sphere shading: dark limb + night side for 3D depth + drawCircle( + brush = Brush.radialGradient( + 0f to Color.Transparent, + 0.62f to Color.Transparent, + 0.88f to Color.Black.copy(alpha = 0.34f), + 1f to Color.Black.copy(alpha = 0.72f), + center = Offset(cx - r * 0.25f, cy - r * 0.3f), + radius = r * 1.35f + ), + radius = r + 1, + center = Offset(cx, cy) + ) + drawCircle( + brush = Brush.linearGradient( + 0f to Color.Transparent, + 0.55f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.42f), + start = Offset(cx - r * 0.7f, cy - r * 0.7f), + end = Offset(cx + r * 0.75f, cy + r * 0.8f) + ), + radius = r + 1, + center = Offset(cx, cy) + ) + + // Cities are detail-only; omitting them while moving keeps touch latency predictable. + val cityMaxRank = frameDetail.cityMaxRank + if (cityMaxRank == null || cityMaxRank >= 0) { + drawCities( + cities, state, preparedProjector, cx, cy, r, colors, + labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density, + maxRankOverride = cityMaxRank, + showLabels = frameDetail.showCityLabels + ) + } + + // Detailed cells and labels settle into place after the gesture ends. + if (frameDetail.showGeohashGrid && state.selectedGeohash.isNotEmpty()) { + drawGeohashGrid( + state, cx, cy, r, cLat, cLon, colors, clip, + includeNeighbors = frameDetail.showNeighborCells + ) + drawGeohashLabels( + state, cx, cy, r, cLat, cLon, colors, + labelPaint, haloPaint, labelTypeface, labelTypefaceBold, + labelTextSize, labelTextSizeSmall, + includeNeighbors = frameDetail.showNeighborCells + ) + } + + } + + // Keep this animation isolated so its pulse does not redraw the globe geometry. + Canvas(modifier = Modifier.matchParentSize()) { + val cx = size.width / 2f + val cy = size.height / 2f + val crossAlpha = if (state.isInMotion) 0.9f else pulse + val crossColor = colors.accent.copy(alpha = crossAlpha) + val gap = 5 * density.density + val len = 9 * density.density + val strokeW = 1.6f * density.density + drawLine(crossColor, Offset(cx - gap - len, cy), Offset(cx - gap, cy), strokeW) + drawLine(crossColor, Offset(cx + gap, cy), Offset(cx + gap + len, cy), strokeW) + drawLine(crossColor, Offset(cx, cy - gap - len), Offset(cx, cy - gap), strokeW) + drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW) + drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy)) + } + } +} + +private const val FRAME_SAMPLE_COUNT = 8 +private const val MAX_SAMPLED_FRAME_MILLIS = 50f +private const val SMOOTH_FRAME_MILLIS = 18.5f +private const val SLOW_FRAME_MILLIS = 22f +private const val RECOVERING_FRAME_MILLIS = 22f +private const val VERY_SLOW_FRAME_MILLIS = 29f +private const val SEVERELY_SLOW_FRAME_MILLIS = 34f + +private fun DrawScope.drawGraticule( + cx: Float, + cy: Float, + r: Float, + cLat: Double, + cLon: Double, + color: Color, + clip: ClipRect, + step: Double +) { + val path = Path() + fun strokeSegment(x0: Float, y0: Float, x1: Float, y1: Float) { + val seg = clipSegment(x0, y0, x1, y1, clip) ?: return + path.moveTo(seg.first.first, seg.first.second) + path.lineTo(seg.second.first, seg.second.second) + } + // latitude lines + var lat = -75.0 + while (lat <= 75.0) { + var prev: GlobeMath.Projection? = null + var lon = -180.0 + while (lon <= 180.0) { + val p = GlobeMath.project(lat, lon, cLat, cLon) + if (p != null && p.cosC > 0.02f) { + val pp = prev + if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r) + prev = p + } else prev = null + lon += step + } + lat += 15.0 + } + // longitude lines + var lon = -180.0 + while (lon < 180.0) { + var prev: GlobeMath.Projection? = null + var la = -90.0 + while (la <= 90.0) { + val p = GlobeMath.project(la, lon, cLat, cLon) + if (p != null && p.cosC > 0.02f) { + val pp = prev + if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r) + prev = p + } else prev = null + la += step + } + lon += 15.0 + } + drawPath(path, color, style = Stroke(width = 1f)) +} + +internal data class DiscPt(val x: Float, val y: Float, val front: Boolean) + +private fun limbPoint(behind: DiscPt, front: DiscPt): Pair { + var lo = 0f; var hi = 1f + repeat(14) { + val t = (lo + hi) / 2f + val x = behind.x + (front.x - behind.x) * t + val y = behind.y + (front.y - behind.y) * t + if (x * x + y * y < 1f) lo = t else hi = t + } + val t = (lo + hi) / 2f + return (behind.x + (front.x - behind.x) * t) to (behind.y + (front.y - behind.y) * t) +} + +/** + * Splits a projected polygon into front-facing runs. Runs that touch the limb are + * padded with the horizon intersection point so they can be closed along the horizon. + * Pass [closed] = false for open polylines (border lines). + */ +internal fun buildFrontRuns( + pts: List, + closed: Boolean = true +): List>> { + if (pts.isEmpty()) return emptyList() + val n = pts.size + val runs = mutableListOf>>() + var run = mutableListOf>() + var prev: DiscPt? = if (closed) pts[n - 1] else null + for (i in 0 until n) { + val cur = pts[i] + val p = prev + if (cur.front) { + if (run.isEmpty() && p != null && !p.front) run.add(limbPoint(p, cur)) + // Antimeridian wrap: consecutive front points can jump across the whole + // disc when a polygon crosses ±180° — break the run instead of drawing + // a chord through the view. + if (run.isNotEmpty()) { + val last = run.last() + val dx = cur.x - last.first + val dy = cur.y - last.second + if (dx * dx + dy * dy > 1.2f) { + runs.add(run) + run = mutableListOf() + } + } + run.add(cur.x to cur.y) + } else { + if (run.isNotEmpty() && p != null) { + run.add(limbPoint(p, cur)) + runs.add(run) + run = mutableListOf() + } + } + prev = cur + } + if (run.isNotEmpty()) { + if (closed && runs.isNotEmpty() && pts[0].front && pts[n - 1].front) { + runs[0] = (run + runs[0]).toMutableList() + } else { + // A fully front-facing ring has no limb transition to terminate its run. + // Close it explicitly; cell boundary sampling intentionally omits the + // duplicated final corner. + if (closed && runs.isEmpty() && pts[0].front && pts[n - 1].front) { + run.add(run.first()) + } + runs.add(run) + } + } + return runs +} + +// Skia's edge rasterizer loses precision with path coordinates beyond ~32767px, +// which happens at high globe zoom. All geometry is clipped to an expanded +// viewport rect in screen space before being handed to a Path. +private class ClipRect(val left: Float, val top: Float, val right: Float, val bottom: Float) + +private fun clipPolygon(pts: List>, rect: ClipRect): List> { + fun clipEdge( + input: List>, + inside: (Pair) -> Boolean, + intersect: (Pair, Pair) -> Pair + ): List> { + if (input.isEmpty()) return input + val result = mutableListOf>() + var s = input.last() + for (e in input) { + val eIn = inside(e) + val sIn = inside(s) + if (eIn) { + if (!sIn) result.add(intersect(s, e)) + result.add(e) + } else if (sIn) { + result.add(intersect(s, e)) + } + s = e + } + return result + } + var out = pts + out = clipEdge(out, { it.first >= rect.left }) { a, b -> + val t = (rect.left - a.first) / (b.first - a.first) + rect.left to (a.second + t * (b.second - a.second)) + } + out = clipEdge(out, { it.first <= rect.right }) { a, b -> + val t = (rect.right - a.first) / (b.first - a.first) + rect.right to (a.second + t * (b.second - a.second)) + } + out = clipEdge(out, { it.second >= rect.top }) { a, b -> + val t = (rect.top - a.second) / (b.second - a.second) + (a.first + t * (b.first - a.first)) to rect.top + } + out = clipEdge(out, { it.second <= rect.bottom }) { a, b -> + val t = (rect.bottom - a.second) / (b.second - a.second) + (a.first + t * (b.first - a.first)) to rect.bottom + } + return out +} + +/** Liang–Barsky clip of a segment to the rect; returns clipped endpoints or null. */ +private fun clipSegment( + x0: Float, y0: Float, x1: Float, y1: Float, rect: ClipRect +): Pair, Pair>? { + val dx = x1 - x0 + val dy = y1 - y0 + var u1 = 0f + var u2 = 1f + fun test(p: Float, q: Float): Boolean { + if (p == 0f) return q >= 0f + val r = q / p + if (p < 0f) { + if (r > u2) return false + if (r > u1) u1 = r + } else { + if (r < u1) return false + if (r < u2) u2 = r + } + return true + } + if (!test(-dx, x0 - rect.left)) return null + if (!test(dx, rect.right - x0)) return null + if (!test(-dy, y0 - rect.top)) return null + if (!test(dy, rect.bottom - y0)) return null + return ((x0 + u1 * dx) to (y0 + u1 * dy)) to ((x0 + u2 * dx) to (y0 + u2 * dy)) +} + +/** + * Builds a fill polygon for a projected ring by clamping back-facing points onto the + * limb and inserting horizon arc steps between consecutive limb points, so the result + * approximates (polygon ∩ visible disc) without self-intersecting chords. + */ +private fun buildFillPolygon( + pts: List, + cx: Float, cy: Float, r: Float +): List> { + val out = ArrayList>(pts.size + 128) + var prevLimbAngle: Float? = null + var firstLimbAngle: Float? = null + + fun addArc(from: Float, to: Float) { + var d = to - from + while (d > Math.PI) d -= (2 * Math.PI).toFloat() + while (d < -Math.PI) d += (2 * Math.PI).toFloat() + val steps = (kotlin.math.abs(d) / 0.04f).toInt().coerceIn(1, 64) + for (s in 1 until steps) { + val a = from + d * s / steps + out.add((cx + kotlin.math.cos(a) * r) to (cy + kotlin.math.sin(a) * r)) + } + } + + for (p in pts) { + if (p.front) { + out.add((cx + p.x * r) to (cy + p.y * r)) + prevLimbAngle = null + } else { + val len = kotlin.math.sqrt(p.x * p.x + p.y * p.y) + val lx: Float; val ly: Float + if (len > 1e-6f) { lx = p.x / len; ly = p.y / len } else { lx = 0f; ly = -1f } + val ang = kotlin.math.atan2(ly, lx) + prevLimbAngle?.let { addArc(it, ang) } + if (firstLimbAngle == null) firstLimbAngle = ang + out.add((cx + lx * r) to (cy + ly * r)) + prevLimbAngle = ang + } + } + // wrap-around arc if the ring ends and starts on the limb + val lastAng = prevLimbAngle + val firstAng = firstLimbAngle + if (lastAng != null && firstAng != null && pts.isNotEmpty() && !pts[0].front) { + addArc(lastAng, firstAng) + } + return out +} + +private fun DrawScope.fillPolygonClipped( + pts: List, + cx: Float, cy: Float, r: Float, + color: Color, + clip: ClipRect, + invertFill: Boolean = false, + preparedPolygon: List>? = null +) { + if (pts.none { it.front }) return + val poly = preparedPolygon ?: buildFillPolygon(pts, cx, cy, r) + if (poly.size < 3) return + val clipped = clipPolygon(poly, clip) + if (clipped.size < 3) return + val path = Path() + if (invertFill) { + // Orthographic projection can choose the wrong side of the horizon closure for + // very large rings. Even-odd filling with the globe disc flips that one ring. + path.fillType = PathFillType.EvenOdd + path.addOval(Rect(cx - r, cy - r, cx + r, cy + r)) + } + path.moveTo(clipped[0].first, clipped[0].second) + for (k in 1 until clipped.size) { + path.lineTo(clipped[k].first, clipped[k].second) + } + path.close() + drawPath(path, color) +} + +internal fun projectedFillNeedsInversion( + polygon: List>, + ring: LandData.Ring, + cx: Float, + cy: Float, + r: Float, + centerLat: Double, + centerLon: Double +): Boolean { + // A single center-point check is ambiguous for a large polygon and caused an + // occasional whole-disc fill. Compare several visible points with the original + // geographic ring, and invert only when the opposite fill wins clearly. + val samples = arrayOf( + 0f to 0f, + -0.5f to 0f, + 0.5f to 0f, + 0f to -0.5f, + 0f to 0.5f, + -0.35f to -0.35f, + 0.35f to -0.35f, + -0.35f to 0.35f, + 0.35f to 0.35f + ) + var normalErrors = 0 + var invertedErrors = 0 + for ((nx, ny) in samples) { + val location = GlobeMath.unproject( + x = nx.toDouble(), + y = ny.toDouble(), + centerLatDeg = centerLat, + centerLonDeg = centerLon + ) ?: continue + val geographicInside = ringContainsLocation(ring, location.first, location.second) + val projectedInside = polygonContains(polygon, cx + nx * r, cy + ny * r) + if (projectedInside != geographicInside) normalErrors++ + if (!projectedInside != geographicInside) invertedErrors++ + } + return invertedErrors < normalErrors +} + +internal fun polygonContains( + polygon: List>, + x: Float, + y: Float +): Boolean { + if (polygon.size < 3) return false + var inside = false + var previous = polygon.last() + for (current in polygon) { + val crossesRay = (current.second > y) != (previous.second > y) + if (crossesRay) { + val intersectionX = (previous.first - current.first) * + (y - current.second) / (previous.second - current.second) + + current.first + if (x < intersectionX) inside = !inside + } + previous = current + } + return inside +} + +internal fun ringContainsLocation( + ring: LandData.Ring, + latitude: Double, + longitude: Double +): Boolean { + if (ring.size < 3) return false + + fun relativeLongitude(value: Float): Double = + GlobeMath.normalizeLon(value.toDouble() - longitude) + + var inside = false + var previousIndex = ring.size - 1 + for (index in 0 until ring.size) { + val currentLat = ring.coords[index * 2].toDouble() + val currentLon = relativeLongitude(ring.coords[index * 2 + 1]) + val previousLat = ring.coords[previousIndex * 2].toDouble() + val previousLon = relativeLongitude(ring.coords[previousIndex * 2 + 1]) + val crossesRay = (currentLat > latitude) != (previousLat > latitude) + if (crossesRay) { + val intersectionLon = (previousLon - currentLon) * + (latitude - currentLat) / (previousLat - currentLat) + + currentLon + if (0.0 < intersectionLon) inside = !inside + } + previousIndex = index + } + return inside +} + +private fun DrawScope.strokeRuns( + runs: List>>, + cx: Float, cy: Float, r: Float, + color: Color, + width: Float, + clip: ClipRect +) { + for (run in runs) { + if (run.size < 2) continue + val path = Path() + for (k in 1 until run.size) { + val seg = clipSegment( + cx + run[k - 1].first * r, cy + run[k - 1].second * r, + cx + run[k].first * r, cy + run[k].second * r, + clip + ) ?: continue + path.moveTo(seg.first.first, seg.first.second) + path.lineTo(seg.second.first, seg.second.second) + } + drawPath(path, color, style = Stroke(width = width)) + } +} + +private fun DrawScope.drawBorderLine( + line: LandData.Ring, + scratch: FloatArray, + projector: GlobeMath.PreparedProjector, + cx: Float, cy: Float, r: Float, + colors: GlobeColors, + clip: ClipRect +) { + val n = line.size + if (n < 2 || n * 3 > scratch.size) return + + var anyFront = false + val pts = ArrayList(n) + var i = 0 + while (i < n) { + projector.project(line.projectionTerms, i * 4, scratch, i * 3) + val x = scratch[i * 3] + val y = scratch[i * 3 + 1] + val cosC = scratch[i * 3 + 2] + val front = cosC > 0.005f + pts.add(DiscPt(x, y, front)) + if (cosC >= 0f) anyFront = true + i++ + } + if (!anyFront) return + + val runs = buildFrontRuns(pts, closed = false) + strokeRuns(runs, cx, cy, r, colors.border, 1.2f, clip) +} + +private fun DrawScope.drawCities( + cities: List, + state: GlobeState, + projector: GlobeMath.PreparedProjector, + cx: Float, cy: Float, r: Float, + colors: GlobeColors, + labelPaint: Paint, + haloPaint: Paint, + typeface: Typeface?, + textSize: Float, + density: Float, + maxRankOverride: Int?, + showLabels: Boolean +) { + if (cities.isEmpty()) return + val projection = FloatArray(3) + val zoom = state.zoom + val maxRank = maxRankOverride ?: when { + zoom < 2f -> 1 + zoom < 8f -> 3 + zoom < 40f -> 4 + else -> 10 + } + val canvas = drawContext.canvas.nativeCanvas + for (city in cities) { + if (city.rank > maxRank) continue + projector.project(city.projectionTerms, 0, projection, 0) + val cosC = projection[2] + if (cosC < 0.03f) continue + val sx = cx + projection[0] * r + val sy = cy + projection[1] * r + if (sx < -50 || sx > size.width + 50 || sy < -50 || sy > size.height + 50) continue + + val alpha = cosC.coerceIn(0.25f, 1f) + val important = city.capital || city.megacity + val dotRadius = (if (important) 2.6f else 1.8f) * density + val dotColor = if (city.capital) colors.accent.copy(alpha = alpha) + else colors.label.copy(alpha = alpha * 0.85f) + drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy)) + + if (showLabels && (zoom >= 6f || (important && zoom >= 2.5f))) { + labelPaint.textSize = textSize + labelPaint.typeface = typeface + labelPaint.textAlign = Paint.Align.LEFT + labelPaint.color = android.graphics.Color.argb( + (200 * alpha).toInt(), + (colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt() + ) + haloPaint.textSize = textSize + haloPaint.typeface = typeface + haloPaint.textAlign = Paint.Align.LEFT + haloPaint.strokeWidth = textSize * 0.16f + haloPaint.color = android.graphics.Color.argb( + (140 * alpha).toInt(), + (colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt() + ) + val tx = sx + dotRadius + 3 * density + val ty = sy - ((labelPaint.descent() + labelPaint.ascent()) / 2f) + canvas.drawText(city.name, tx, ty, haloPaint) + canvas.drawText(city.name, tx, ty, labelPaint) + } + } + labelPaint.textAlign = Paint.Align.CENTER + haloPaint.textAlign = Paint.Align.CENTER +} + +private fun DrawScope.drawLandFill( + ring: LandData.Ring, + scratch: FloatArray, + projector: GlobeMath.PreparedProjector, + cx: Float, cy: Float, r: Float, + colors: GlobeColors, + clip: ClipRect, + pointStride: Int, + centerLat: Double, + centerLon: Double +): List>>? { + val n = ((ring.size - 1) / pointStride) + 1 + if (n < 3 || n * 3 > scratch.size) return null + + var anyFront = false + var anyBack = false + var i = 0 + while (i < n) { + val sourceIndex = (i * pointStride).coerceAtMost(ring.size - 1) + projector.project(ring.projectionTerms, sourceIndex * 4, scratch, i * 3) + if (scratch[i * 3 + 2] >= 0f) { + anyFront = true + } else { + anyBack = true + } + i++ + } + if (!anyFront) return null + + val pts = ArrayList(n) + i = 0 + while (i < n) { + pts.add(DiscPt(scratch[i * 3], scratch[i * 3 + 1], scratch[i * 3 + 2] > 0.005f)) + i++ + } + val runs = buildFrontRuns(pts) + val polygon = buildFillPolygon(pts, cx, cy, r) + fillPolygonClipped( + pts = pts, + cx = cx, + cy = cy, + r = r, + color = colors.land, + clip = clip, + invertFill = anyBack && projectedFillNeedsInversion( + polygon = polygon, + ring = ring, + cx = cx, + cy = cy, + r = r, + centerLat = centerLat, + centerLon = centerLon + ), + preparedPolygon = polygon + ) + return runs +} + +private fun DrawScope.drawGeohashGrid( + state: GlobeState, + cx: Float, cy: Float, r: Float, + cLat: Double, cLon: Double, + colors: GlobeColors, + clip: ClipRect, + includeNeighbors: Boolean +) { + val selected = state.selectedGeohash + val cells = linkedSetOf(selected) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) + + for (cell in cells) { + val isSelected = cell == selected + val b = Geohash.decodeToBounds(cell) + val spanLat = b.latMax - b.latMin + val spanLon = b.lonMax - b.lonMin + val steps = ceil(spanLat / 1.5).toInt().coerceIn(4, 48) + + // Sample the cell boundary: N edge l->r, E edge t->b, S edge r->l, W edge b->t + val pts = ArrayList>(steps * 4 + 4) + fun addPt(lat: Double, lonRaw: Double) { + var lon = lonRaw + // keep boundary continuous across the antimeridian relative to the view + val ref = cLon + while (lon - ref > 180.0) lon -= 360.0 + while (lon - ref < -180.0) lon += 360.0 + val p = GlobeMath.projectRaw(lat, lon, cLat, cLon) + pts.add(Triple(p.x, p.y, p.cosC >= 0.005f)) + } + for (s in 0..steps) { + val t = s.toDouble() / steps + addPt(b.latMax, b.lonMin + spanLon * t) + } + for (s in 1..steps) { + val t = s.toDouble() / steps + addPt(b.latMax - spanLat * t, b.lonMax) + } + for (s in 1..steps) { + val t = s.toDouble() / steps + addPt(b.latMin, b.lonMax - spanLon * t) + } + for (s in 1 until steps) { + val t = s.toDouble() / steps + addPt(b.latMin + spanLat * t, b.lonMin) + } + + if (pts.none { it.third }) continue + + val discPts = pts.map { DiscPt(it.first, it.second, it.third) } + val runs = buildFrontRuns(discPts) + + if (isSelected) { + fillPolygonClipped( + discPts, cx, cy, r, colors.accent.copy(alpha = 0.20f), clip + ) + strokeRuns(runs, cx, cy, r, colors.accent.copy(alpha = 0.35f), 7f, clip) + strokeRuns(runs, cx, cy, r, colors.accent, 3.2f, clip) + } else { + fillPolygonClipped( + discPts, cx, cy, r, colors.grid.copy(alpha = 0.05f), clip + ) + strokeRuns(runs, cx, cy, r, colors.grid, 1.6f, clip) + } + } +} + +private fun DrawScope.drawGeohashLabels( + state: GlobeState, + cx: Float, cy: Float, r: Float, + cLat: Double, cLon: Double, + colors: GlobeColors, + labelPaint: Paint, + haloPaint: Paint, + labelTypeface: Typeface?, + labelTypefaceBold: Typeface?, + selectedSize: Float, + neighborSize: Float, + includeNeighbors: Boolean +) { + val selected = state.selectedGeohash + val cells = linkedSetOf(selected) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) + val canvas = drawContext.canvas.nativeCanvas + + for (cell in cells) { + val isSelected = cell == selected + val (lat, lon) = Geohash.decodeToCenter(cell) + val p = GlobeMath.project(lat, lon, cLat, cLon) ?: continue + if (p.cosC < 0.08f) continue + val sx = cx + p.x * r + val sy = cy + p.y * r + val paint = labelPaint + paint.textSize = if (isSelected) selectedSize else neighborSize + paint.typeface = if (isSelected) (labelTypefaceBold ?: labelTypeface) else labelTypeface + paint.color = if (isSelected) { + android.graphics.Color.argb(255, (colors.accent.red * 255).toInt(), (colors.accent.green * 255).toInt(), (colors.accent.blue * 255).toInt()) + } else { + android.graphics.Color.argb( + (160 * p.cosC.coerceIn(0.4f, 1f)).toInt(), + (colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt() + ) + } + val baseline = sy - ((paint.descent() + paint.ascent()) / 2f) + haloPaint.textSize = paint.textSize + haloPaint.typeface = paint.typeface + haloPaint.strokeWidth = paint.textSize * 0.18f + haloPaint.color = android.graphics.Color.argb( + if (isSelected) 200 else 120, + (colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt() + ) + canvas.drawText(cell, sx, baseline, haloPaint) + canvas.drawText(cell, sx, baseline, paint) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt b/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt new file mode 100644 index 00000000..7348f88b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt @@ -0,0 +1,164 @@ +package com.bitchat.android.ui.globe + +import android.content.Context +import org.json.JSONObject +import kotlin.math.cos +import kotlin.math.sin + +/** + * Loads the bundled Natural Earth 110m land polygons (public domain) from assets + * and exposes them as flat rings of lat/lon pairs for vector globe rendering. + */ +object LandData { + + data class Ring( + val coords: FloatArray, + val size: Int, + /** + * Per-point sin(latitude), cos(latitude), sin(longitude), cos(longitude). + * Preparing this once removes nearly all trigonometry from animated frames. + */ + val projectionTerms: FloatArray = prepareProjectionTerms(coords, size) + ) + + data class City( + val name: String, + val lat: Float, + val lon: Float, + val rank: Int, + val capital: Boolean, + val megacity: Boolean, + val projectionTerms: FloatArray = prepareProjectionTerms( + floatArrayOf(lat, lon), + size = 1 + ) + ) + + @Volatile + private var cached: List? = null + + @Volatile + private var cachedBorders: List? = null + + @Volatile + private var cachedCities: List? = null + + /** Returns land polygon rings; each ring is a flat array of (lat, lon) pairs. */ + fun load(context: Context): List { + cached?.let { return it } + synchronized(this) { + cached?.let { return it } + val rings = mutableListOf() + val text = context.assets.open("world_land.geojson").bufferedReader().use { it.readText() } + val root = JSONObject(text) + val geometries = root.getJSONArray("geometries") + for (i in 0 until geometries.length()) { + val geom = geometries.getJSONObject(i) + when (geom.getString("type")) { + "Polygon" -> parsePolygon(geom.getJSONArray("coordinates"), rings) + "MultiPolygon" -> { + val polys = geom.getJSONArray("coordinates") + for (j in 0 until polys.length()) { + parsePolygon(polys.getJSONArray(j), rings) + } + } + } + } + cached = rings + return rings + } + } + + /** Returns country border lines (Natural Earth admin-0 boundary lines, public domain). */ + fun loadBorders(context: Context): List { + cachedBorders?.let { return it } + synchronized(this) { + cachedBorders?.let { return it } + val lines = mutableListOf() + val text = context.assets.open("world_borders.geojson").bufferedReader().use { it.readText() } + val root = JSONObject(text) + val geometries = root.getJSONArray("geometries") + for (i in 0 until geometries.length()) { + val geom = geometries.getJSONObject(i) + when (geom.getString("type")) { + "LineString" -> parseLine(geom.getJSONArray("coordinates"), lines) + "MultiLineString" -> { + val parts = geom.getJSONArray("coordinates") + for (j in 0 until parts.length()) { + parseLine(parts.getJSONArray(j), lines) + } + } + } + } + cachedBorders = lines + return lines + } + } + + /** Returns populated places (Natural Earth 50m, public domain) with name and scale rank. */ + fun loadCities(context: Context): List { + cachedCities?.let { return it } + synchronized(this) { + cachedCities?.let { return it } + val cities = mutableListOf() + val text = context.assets.open("world_cities.geojson").bufferedReader().use { it.readText() } + val root = JSONObject(text) + val arr = root.getJSONArray("cities") + for (i in 0 until arr.length()) { + val c = arr.getJSONObject(i) + cities.add( + City( + name = c.getString("n"), + lat = c.getDouble("lat").toFloat(), + lon = c.getDouble("lon").toFloat(), + rank = c.getInt("r"), + capital = c.optInt("cap", 0) == 1, + megacity = c.optInt("mega", 0) == 1 + ) + ) + } + cachedCities = cities + return cities + } + } + + private fun parseLine(lineJson: org.json.JSONArray, out: MutableList) { + val n = lineJson.length() + if (n < 2) return + val coords = FloatArray(n * 2) + for (p in 0 until n) { + val pt = lineJson.getJSONArray(p) + coords[p * 2] = pt.getDouble(1).toFloat() // lat + coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon + } + out.add(Ring(coords, n)) + } + + private fun parsePolygon(ringsJson: org.json.JSONArray, out: MutableList) { + for (r in 0 until ringsJson.length()) { + val ringJson = ringsJson.getJSONArray(r) + val n = ringJson.length() + if (n < 3) continue + val coords = FloatArray(n * 2) + for (p in 0 until n) { + val pt = ringJson.getJSONArray(p) + coords[p * 2] = pt.getDouble(1).toFloat() // lat + coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon + } + out.add(Ring(coords, n)) + } + } + + private fun prepareProjectionTerms(coords: FloatArray, size: Int): FloatArray { + val result = FloatArray(size * 4) + for (index in 0 until size) { + val latRadians = Math.toRadians(coords[index * 2].toDouble()) + val lonRadians = Math.toRadians(coords[index * 2 + 1].toDouble()) + result[index * 4] = sin(latRadians).toFloat() + result[index * 4 + 1] = cos(latRadians).toFloat() + result[index * 4 + 2] = sin(lonRadians).toFloat() + result[index * 4 + 3] = cos(lonRadians).toFloat() + } + return result + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt new file mode 100644 index 00000000..0fc3b622 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -0,0 +1,166 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import androidx.compose.material3.ColorScheme +import com.bitchat.android.ui.theme.LocalBitchatPalette +import java.text.SimpleDateFormat +import androidx.compose.ui.platform.LocalContext + +@Composable +fun AudioMessageItem( + message: BitchatMessage, + currentUserNickname: String, + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((BitchatMessage) -> Unit)?, + onCancelTransfer: ((BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier, + showSender: Boolean = true, + bubbles: Boolean = false +) { + val palette = LocalBitchatPalette.current + val context = LocalContext.current + val liveMessageIDs by com.bitchat.android.features.voice.LiveVoiceManager + .getInstance(context).liveMessageIDs.collectAsState() + val isLive = message.id in liveMessageIDs + val path = message.content.trim() + // Derive sending progress if applicable + val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) { + is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> { + if (st.total > 0 && st.reached < st.total) { + (st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending + } else null to null + } + else -> null to null + } + // Bubble mode wraps the voice note in the same tinted shell as text bubbles; Matrix mode + // keeps the flat header-plus-player layout. + if (bubbles) { + MediaBubbleShell( + message = message, + currentUserNickname = currentUserNickname, + myPeerID = meshService.myPeerID, + showSender = showSender, + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onLongPress = { onMessageLongPress?.invoke(message) }, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isLive) { + androidx.compose.material3.Text( + text = "LIVE", + color = Color(0xFFFFB300), + style = androidx.compose.material3.MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(end = 8.dp) + ) + } + VoiceNotePlayer( + path = path, + progressOverride = overrideProgress, + progressColor = overrideColor, + modifier = Modifier.widthIn(max = 260.dp) + ) + val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered) + if (showCancel) { + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(26.dp) + .background(Color.Gray.copy(alpha = 0.6f), CircleShape) + .clickable { onCancelTransfer?.invoke(message) }, + contentAlignment = Alignment.Center + ) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp)) + } + } + } + } + return + } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + // Header: nickname + timestamp line above the audio note, identical styling to text messages + val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( + message = message, + currentUserNickname = currentUserNickname, + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender + ) + val haptic = LocalHapticFeedback.current + AnnotatedClickableText( + text = headerText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = { onMessageLongPress?.invoke(message) }, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurface, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + if (isLive) { + androidx.compose.material3.Text( + text = "LIVE", + color = Color(0xFFFFB300), + style = androidx.compose.material3.MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(end = 8.dp) + ) + } + VoiceNotePlayer( + path = path, + progressOverride = overrideProgress, + progressColor = overrideColor, + ) + val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered) + if (showCancel) { + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(26.dp) + .background(Color.Gray.copy(alpha = 0.6f), CircleShape) + .clickable { onCancelTransfer?.invoke(message) }, + contentAlignment = Alignment.Center + ) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp)) + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt b/app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt new file mode 100644 index 00000000..e0f08ccc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt @@ -0,0 +1,95 @@ +package com.bitchat.android.ui.media + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +/** + * Draws an image progressively, revealing it block-by-block based on progress [0f..1f]. + * blocksX * blocksY defines the grid density; higher numbers look more "modem-era". + */ +@Composable +fun BlockRevealImage( + bitmap: ImageBitmap, + progress: Float, + blocksX: Int = 24, + blocksY: Int = 16, + modifier: Modifier = Modifier +) { + val frac = progress.coerceIn(0f, 1f) + Canvas(modifier = modifier.fillMaxWidth()) { + drawProgressive(bitmap, frac, blocksX, blocksY) + } +} + +private fun DrawScope.drawProgressive( + bitmap: ImageBitmap, + progress: Float, + blocksX: Int, + blocksY: Int +) { + val canvasW = size.width + val canvasH = size.height + if (canvasW <= 0f || canvasH <= 0f) return + + val totalBlocks = (blocksX * blocksY).coerceAtLeast(1) + val toShow = (totalBlocks * progress).toInt().coerceIn(0, totalBlocks) + if (toShow <= 0) return + + val imgW = bitmap.width + val imgH = bitmap.height + if (imgW <= 0 || imgH <= 0) return + + // Compute scaled destination rect maintaining aspect fit + val canvasRatio = canvasW / canvasH + val imageRatio = imgW.toFloat() / imgH.toFloat() + val dstW: Float + val dstH: Float + if (imageRatio >= canvasRatio) { + dstW = canvasW + dstH = canvasW / imageRatio + } else { + dstH = canvasH + dstW = canvasH * imageRatio + } + val left = 0f + val top = (canvasH - dstH) / 2f + + // Precompute integer edges to avoid 1px gaps due to rounding + val xDstEdges = IntArray(blocksX + 1) { i -> (left + (dstW * i / blocksX)).toInt().coerceAtLeast(0) } + val yDstEdges = IntArray(blocksY + 1) { i -> (top + (dstH * i / blocksY)).toInt().coerceAtLeast(0) } + val xSrcEdges = IntArray(blocksX + 1) { i -> (imgW * i / blocksX) } + val ySrcEdges = IntArray(blocksY + 1) { i -> (imgH * i / blocksY) } + + var shown = 0 + outer@ for (by in 0 until blocksY) { + for (bx in 0 until blocksX) { + if (shown >= toShow) break@outer + val sx = xSrcEdges[bx] + val sy = ySrcEdges[by] + val sw = xSrcEdges[bx + 1] - xSrcEdges[bx] + val sh = ySrcEdges[by + 1] - ySrcEdges[by] + val dx = xDstEdges[bx] + val dy = yDstEdges[by] + val dw = xDstEdges[bx + 1] - xDstEdges[bx] + val dh = yDstEdges[by + 1] - yDstEdges[by] + + drawImage( + image = bitmap, + srcOffset = IntOffset(sx, sy), + srcSize = IntSize(sw, sh), + dstOffset = IntOffset(dx, dy), + dstSize = IntSize(dw.coerceAtLeast(1), dh.coerceAtLeast(1)), + alpha = 1f + ) + shown++ + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt new file mode 100644 index 00000000..7a289686 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt @@ -0,0 +1,157 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.bitchat.android.features.file.FileUtils +import com.bitchat.android.model.BitchatFilePacket + +/** + * Modern chat-style file message display + */ +@Composable +fun FileMessageItem( + packet: BitchatFilePacket, + onFileClick: () -> Unit, + modifier: Modifier = Modifier +) { + var showDialog by remember { mutableStateOf(false) } + + Card( + modifier = modifier + .fillMaxWidth(0.8f) + .clickable { showDialog = true }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // File icon + Icon( + imageVector = Icons.Filled.Description, + contentDescription = stringResource(com.bitchat.android.R.string.cd_file), + tint = getFileIconColor(packet.fileName), + modifier = Modifier.size(32.dp) + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // File name + Text( + text = packet.fileName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // File details + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = FileUtils.formatFileSize(packet.fileSize), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // File type indicator + FileTypeBadge(mimeType = packet.mimeType) + } + } + } + } + + // File viewer dialog + if (showDialog) { + FileViewerDialog( + packet = packet, + onDismiss = { showDialog = false }, + onSaveToDevice = { content, fileName -> + // In a real implementation, this would save to Downloads + // For now, just log that file was "saved" + android.util.Log.d("FileSharing", "Would save file: $fileName") + } + ) + } +} + +/** + * Small badge showing file type + */ +@Composable +private fun FileTypeBadge(mimeType: String) { + val (text, color) = when { + mimeType.startsWith("application/pdf") -> "PDF" to Color(0xFFDC2626) + mimeType.startsWith("text/") -> "TXT" to Color(0xFF059669) + mimeType.startsWith("image/") -> "IMG" to Color(0xFF7C3AED) + mimeType.startsWith("audio/") -> "AUD" to Color(0xFFEA580C) + mimeType.startsWith("video/") -> "VID" to Color(0xFF2563EB) + mimeType.contains("document") -> "DOC" to Color(0xFF1D4ED8) + mimeType.contains("zip") || mimeType.contains("rar") -> "ZIP" to Color(0xFF7C2D12) + else -> "FILE" to MaterialTheme.colorScheme.onSurfaceVariant + } + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ) +} + +/** + * Get appropriate icon color based on file extension + */ +private fun getFileIconColor(fileName: String): Color { + val extension = fileName.substringAfterLast(".", "").lowercase() + return when (extension) { + "pdf" -> Color(0xFFDC2626) // Red + "doc", "docx" -> Color(0xFF1D4ED8) // Blue + "xls", "xlsx" -> Color(0xFF059669) // Green + "ppt", "pptx" -> Color(0xFFEA580C) // Orange + "txt", "json", "xml" -> Color(0xFF7C3AED) // Purple + "jpg", "png", "gif", "webp" -> Color(0xFF2563EB) // Blue + "mp3", "wav", "m4a" -> Color(0xFFEA580C) // Orange + "mp4", "avi", "mov" -> Color(0xFFDC2626) // Red + "zip", "rar", "7z" -> Color(0xFF7C2D12) // Brown + else -> Color(0xFF6B7280) // Gray + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt new file mode 100644 index 00000000..bd5f30e0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Attachment +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R +import com.bitchat.android.features.file.FileUtils + +@Composable +fun FilePickerButton( + modifier: Modifier = Modifier, + onFileReady: (String) -> Unit +) { + val context = LocalContext.current + + // Use SAF - supports all file types + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) { + // Persist temporary read permission so we can copy + try { context.contentResolver.takePersistableUriPermission(uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {} + val path = FileUtils.copyFileForSending(context, uri) + if (!path.isNullOrBlank()) onFileReady(path) + } + } + + IconButton( + onClick = { + // Allow any MIME type; user asked to choose between image or file at higher level UI + filePicker.launch(arrayOf("*/*")) + }, + modifier = modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Filled.Attachment, + contentDescription = stringResource(R.string.cd_pick_file), + tint = Color.Gray, + modifier = Modifier.size(20.dp).rotate(90f) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt new file mode 100644 index 00000000..51e65108 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt @@ -0,0 +1,158 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import kotlinx.coroutines.delay + +/** + * Matrix-style file sending animation with character-by-character reveal + * Shows a file icon with filename being "typed" out character by character + * and progress visualization + */ +@Composable +fun FileSendingAnimation( + modifier: Modifier = Modifier, + fileName: String, + progress: Float = 0f +) { + var revealedChars by remember(fileName) { mutableFloatStateOf(0f) } + var showCursor by remember { mutableStateOf(true) } + + // Animate character reveal + val animatedChars by animateFloatAsState( + targetValue = revealedChars, + animationSpec = tween( + durationMillis = 50 * fileName.length, + easing = LinearEasing + ), + label = "fileNameReveal" + ) + + // Cursor blinking + LaunchedEffect(Unit) { + while (true) { + delay(500) + showCursor = !showCursor + } + } + + // Trigger reveal animation + LaunchedEffect(fileName) { + revealedChars = fileName.length.toFloat() + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // File icon + Icon( + imageVector = Icons.Filled.Description, + contentDescription = stringResource(R.string.cd_file), + tint = Color(0xFF00C851), // Green like app theme + modifier = Modifier.size(32.dp) + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Filename reveal animation (Matrix-style) + Row(verticalAlignment = Alignment.Bottom) { + // Revealed part of filename + val revealedText = fileName.substring(0, animatedChars.toInt()) + androidx.compose.material3.Text( + text = revealedText, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + color = Color.White + ), + modifier = Modifier.padding(end = 2.dp) + ) + + // Blinking cursor (only if not fully revealed) + if (animatedChars < fileName.length && showCursor) { + androidx.compose.material3.Text( + text = stringResource(R.string.underscore), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = BitchatFontFamily, + color = Color.White + ) + ) + } + } + + // Progress visualization + FileProgressBars( + progress = progress, + modifier = Modifier.fillMaxWidth().height(20.dp) + ) + } + } +} + +/** + * ASCII-style progress bars for file transfer + */ +@Composable +private fun FileProgressBars( + progress: Float, + modifier: Modifier = Modifier +) { + val bars = 12 + val filledBars = (progress * bars).toInt() + + // Create a matrix-style progress bar string + val ctx = LocalContext.current + val progressString = buildString { + val brackets = ctx.getString(R.string.progress_bar_brackets, "", 0) + append("[") + for (i in 0 until bars) { + append(if (i < filledBars) ctx.getString(R.string.progress_filled) else ctx.getString(R.string.progress_empty)) + } + append("] ") + append("${(progress * 100).toInt()}%") + } + + androidx.compose.material3.Text( + text = progressString, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = BitchatFontFamily, + color = Color(0xFF00FF7F) // Matrix green + ), + modifier = modifier + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileViewerDialog.kt b/app/src/main/java/com/bitchat/android/ui/media/FileViewerDialog.kt new file mode 100644 index 00000000..6ef262e5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/FileViewerDialog.kt @@ -0,0 +1,163 @@ +package com.bitchat.android.ui.media + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.bitchat.android.R +import com.bitchat.android.features.file.FileUtils +import com.bitchat.android.model.BitchatFilePacket +import kotlinx.coroutines.launch +import java.io.File + +/** + * Dialog for handling received file messages in modern chat style + */ +@Composable +fun FileViewerDialog( + packet: BitchatFilePacket, + onDismiss: () -> Unit, + onSaveToDevice: (ByteArray, String) -> Unit +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + Dialog(onDismissRequest = onDismiss) { + androidx.compose.material3.Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // File received header + Text( + text = stringResource(R.string.file_viewer_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + // File info + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = stringResource(R.string.file_viewer_name, packet.fileName), + style = MaterialTheme.typography.bodyLarge, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium + ) + Text( + text = stringResource(R.string.file_viewer_size, FileUtils.formatFileSize(packet.fileSize)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.file_viewer_type, packet.mimeType), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Open/Save button + Button( + onClick = { + coroutineScope.launch { + // Try to save to Downloads first + try { + onSaveToDevice(packet.content, packet.fileName) + onDismiss() + } catch (e: Exception) { + // If save fails, try to open directly + tryOpenFile(context, packet) + onDismiss() + } + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text(stringResource(R.string.file_viewer_open_save)) + } + + // Dismiss button + Button( + onClick = onDismiss, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text(stringResource(R.string.close_with_emoji)) + } + } + } + } + } +} + +/** + * Attempts to open a file using system viewers or save to device + */ +private fun tryOpenFile(context: Context, packet: BitchatFilePacket) { + try { + // First try to save to temp file and open + val tempFile = File.createTempFile("bitchat_", ".${packet.fileName.substringAfterLast(".")}", context.cacheDir) + tempFile.writeBytes(packet.content) + tempFile.deleteOnExit() + + val uri = androidx.core.content.FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + tempFile + ) + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, packet.mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + // No app can handle this file type - just show a message + // In a real app, you'd show a toast or snackbar + } + } catch (e: Exception) { + // Handle any errors gracefully + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt new file mode 100644 index 00000000..3544b637 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -0,0 +1,173 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import android.content.ContentValues +import android.os.Build +import android.provider.MediaStore +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.R +import java.io.File + +/** + * Fullscreen image viewer with swipe navigation between multiple images + * @param imagePaths List of all image file paths in the current chat + * @param initialIndex Starting index of the current image in the list + * @param onClose Callback when the viewer should be dismissed + */ +// Backward compatibility for single image (can be removed after updating all callers) +@Composable +fun FullScreenImageViewer(path: String, onClose: () -> Unit) { + FullScreenImageViewer(listOf(path), 0, onClose) +} + +/** + * Fullscreen image viewer with swipe navigation between multiple images + * @param imagePaths List of all image file paths in the current chat + * @param initialIndex Starting index of the current image in the list + * @param onClose Callback when the viewer should be dismissed + */ +@Composable +fun FullScreenImageViewer(imagePaths: List, initialIndex: Int = 0, onClose: () -> Unit) { + val context = LocalContext.current + val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = imagePaths::size) + + if (imagePaths.isEmpty()) { + onClose() + return + } + + Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(color = Color.Black) { + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + val currentPath = imagePaths[page] + val bmp = remember(currentPath) { try { android.graphics.BitmapFactory.decodeFile(currentPath) } catch (_: Exception) { null } } + + bmp?.let { + androidx.compose.foundation.Image( + bitmap = it.asImageBitmap(), + contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } ?: run { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(R.string.image_unavailable), color = Color.White) + } + } + } + + // Image counter + if (imagePaths.size > 1) { + Box( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .align(Alignment.TopCenter) + .background(Color(0x66000000), androidx.compose.foundation.shape.RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size), + color = Color.White, + fontSize = 14.sp, + fontFamily = BitchatFontFamily + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .align(Alignment.TopEnd), + horizontalArrangement = Arrangement.End + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color(0x66000000), CircleShape) + .clickable { saveToDownloads(context, imagePaths[pagerState.currentPage].toString()) }, + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Icon(Icons.Filled.Download, stringResource(R.string.cd_save_current_image), tint = Color.White) + } + Spacer(Modifier.width(12.dp)) + Box( + modifier = Modifier + .size(36.dp) + .background(Color(0x66000000), CircleShape) + .clickable { onClose() }, + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Icon(Icons.Filled.Close, stringResource(R.string.cd_close), tint = Color.White) + } + } + } + } + } +} + +private fun saveToDownloads(context: android.content.Context, path: String) { + runCatching { + val name = File(path).name + val mime = when { + name.endsWith(".png", true) -> "image/png" + name.endsWith(".webp", true) -> "image/webp" + else -> "image/jpeg" + } + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, name) + put(MediaStore.Downloads.MIME_TYPE, mime) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Downloads.IS_PENDING, 1) + } + } + val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + if (uri != null) { + context.contentResolver.openOutputStream(uri)?.use { out -> + File(path).inputStream().use { it.copyTo(out) } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val v2 = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) } + context.contentResolver.update(uri, v2, null, null) + } + // Show toast message indicating the image has been saved + Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT).show() + } + }.onFailure { + // Optionally handle failure case (e.g., show error toast) + Toast.makeText(context, context.getString(R.string.toast_failed_to_save_image), Toast.LENGTH_SHORT).show() + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt new file mode 100644 index 00000000..5644e094 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -0,0 +1,216 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import androidx.compose.material3.ColorScheme +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.ui.theme.LocalBitchatPalette +import java.text.SimpleDateFormat + +@Composable +fun ImageMessageItem( + message: BitchatMessage, + messages: List, + currentUserNickname: String, + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((BitchatMessage) -> Unit)?, + onCancelTransfer: ((BitchatMessage) -> Unit)?, + onImageClick: ((String, List, Int) -> Unit)?, + modifier: Modifier = Modifier, + showSender: Boolean = true, + bubbles: Boolean = false +) { + val palette = LocalBitchatPalette.current + val path = message.content.trim() + // Bubble mode wraps the image in the same tinted shell as text bubbles; Matrix mode keeps + // the flat header-plus-card layout. + val bubblesMode = bubbles + val imageShape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp) + + val context = LocalContext.current + val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } } + + // Collect all image paths from messages for swipe navigation + val imagePaths = remember(messages) { + messages.filter { it.type == BitchatMessageType.Image } + .map { it.content.trim() } + } + val haptic = LocalHapticFeedback.current + + if (bubblesMode) { + MediaBubbleShell( + message = message, + currentUserNickname = currentUserNickname, + myPeerID = meshService.myPeerID, + showSender = showSender, + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onLongPress = { onMessageLongPress?.invoke(message) }, + modifier = modifier, + ) { + ImageMessageCard( + message = message, + currentUserNickname = currentUserNickname, + path = path, + bmp = bmp, + imagePaths = imagePaths, + imageShape = imageShape, + onImageClick = onImageClick, + onCancelTransfer = onCancelTransfer, + onLongPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongPress?.invoke(message) + }, + ) + } + return + } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( + message = message, + currentUserNickname = currentUserNickname, + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender + ) + AnnotatedClickableText( + text = headerText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = { onMessageLongPress?.invoke(message) }, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurface, + ) + + if (bmp != null) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + ImageMessageCard( + message = message, + currentUserNickname = currentUserNickname, + path = path, + bmp = bmp, + imagePaths = imagePaths, + imageShape = imageShape, + onImageClick = onImageClick, + onCancelTransfer = onCancelTransfer, + onLongPress = null, + ) + } + } else { + Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ImageMessageCard( + message: BitchatMessage, + currentUserNickname: String, + path: String, + bmp: android.graphics.Bitmap?, + imagePaths: List, + imageShape: androidx.compose.foundation.shape.RoundedCornerShape, + onImageClick: ((String, List, Int) -> Unit)?, + onCancelTransfer: ((BitchatMessage) -> Unit)?, + onLongPress: (() -> Unit)?, +) { + if (bmp == null) { + Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray) + return + } + val img = bmp.asImageBitmap() + val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f + val progressFraction: Float? = when (val st = message.deliveryStatus) { + is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f + else -> null + } + Box { + val imageModifier = Modifier + .widthIn(max = 300.dp) + .aspectRatio(aspect) + .clip(imageShape) + .combinedClickable( + onClick = { + val currentIndex = imagePaths.indexOf(path) + onImageClick?.invoke(path, imagePaths, currentIndex) + }, + onLongClick = { onLongPress?.invoke() }, + ) + if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) { + // Cyberpunk block-reveal while sending + BlockRevealImage( + bitmap = img, + progress = progressFraction, + blocksX = 24, + blocksY = 16, + modifier = imageModifier + ) + } else { + // Fully revealed image + Image( + bitmap = img, + contentDescription = stringResource(com.bitchat.android.R.string.cd_image), + modifier = imageModifier, + contentScale = ContentScale.Fit + ) + } + // Cancel button overlay during sending + val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered) + if (showCancel) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(22.dp) + .background(Color.Gray.copy(alpha = 0.6f), CircleShape) + .clickable { onCancelTransfer?.invoke(message) }, + contentAlignment = Alignment.Center + ) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp)) + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt new file mode 100644 index 00000000..97ee01a4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -0,0 +1,119 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoCamera +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.bitchat.android.features.media.ImageUtils +import com.bitchat.android.ui.ComposerActionSurface +import com.bitchat.android.ui.ComposerIconSize +import java.io.File + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ImagePickerButton( + modifier: Modifier = Modifier, + onImageReady: (String) -> Unit +) { + val context = LocalContext.current + var capturedImagePath by remember { mutableStateOf(null) } + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: android.net.Uri? -> + if (uri != null) { + val outPath = ImageUtils.downscaleAndSaveToAppFiles(context, uri) + if (!outPath.isNullOrBlank()) onImageReady(outPath) + } + } + + val takePictureLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + val path = capturedImagePath + if (success && !path.isNullOrBlank()) { + // Downscale + correct orientation, then send; delete original + val outPath = com.bitchat.android.features.media.ImageUtils.downscalePathAndSaveToAppFiles(context, path) + if (!outPath.isNullOrBlank()) { + onImageReady(outPath) + } + runCatching { File(path).delete() } + } else { + // Cleanup on cancel/failure + path?.let { runCatching { File(it).delete() } } + } + capturedImagePath = null + } + + fun startCameraCapture() { + try { + val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() } + val file = File(dir, "camera_${System.currentTimeMillis()}.jpg") + val uri = FileProvider.getUriForFile( + context, + context.packageName + ".fileprovider", + file + ) + capturedImagePath = file.absolutePath + takePictureLauncher.launch(uri) + } catch (e: Exception) { + android.util.Log.e("ImagePickerButton", "Camera capture failed", e) + } + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + startCameraCapture() + } + } + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // Shares the composer's button treatment so camera, microphone and send read as one set. + ComposerActionSurface( + isActive = false, + isPressed = isPressed, + modifier = modifier.combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = { imagePicker.launch("image/*") }, + onLongClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCameraCapture() + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + ) + ) { tint -> + Icon( + imageVector = Icons.Filled.PhotoCamera, + contentDescription = stringResource(com.bitchat.android.R.string.pick_image), + tint = tint, + modifier = Modifier.size(ComposerIconSize) + ) + } + + // No custom preview: native camera UI handles confirmation +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt new file mode 100644 index 00000000..bc69dec3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt @@ -0,0 +1,155 @@ +package com.bitchat.android.ui.media + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.DeliveryStatusIcon +import com.bitchat.android.ui.formatTextMessageMetadata +import com.bitchat.android.ui.formatTextMessageSender +import com.bitchat.android.ui.isFromSelf +import com.bitchat.android.ui.peerIdentityForMessage +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeer +import java.text.SimpleDateFormat + +/** + * Classic messenger shell for media messages (images, voice notes) in `ChatUiMode.Bubbles`, + * mirroring the text-bubble treatment: the author's identity colour washes the fill and + * hairline, the speaker-side corner tightens into a tail, the sender's name heads the first + * bubble of a run, and the timestamp (plus delivery ticks for own private messages) rides + * flush-right beneath the media. The shell itself is long-clickable so media bubbles always + * open the message action sheet even when no sender label is shown and the media consumes + * touches (voice-note player controls). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MediaBubbleShell( + message: BitchatMessage, + currentUserNickname: String, + myPeerID: String, + showSender: Boolean, + timeFormatter: SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onLongPress: (() -> Unit)?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val palette = LocalBitchatPalette.current + val haptic = LocalHapticFeedback.current + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) + + val authorColor = remember(message, isSelf, palette) { + if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette) + } + + val corner = ChatVisualTokens.BubbleCornerRadius + val tail = ChatVisualTokens.BubbleTailRadius + val bubbleShape = if (isSelf) { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner) + } else { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail) + } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start, + ) { + Box( + modifier = Modifier + .border( + width = 1.dp, + color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha), + shape = bubbleShape + ) + .background( + color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha), + shape = bubbleShape + ) + .combinedClickable( + enabled = onLongPress != null, + onClick = {}, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongPress?.invoke() + }, + ) + .padding( + horizontal = ChatVisualTokens.BubblePaddingHorizontal, + vertical = ChatVisualTokens.BubblePaddingVertical, + ) + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (showSender && !isSelf) { + val senderText = remember(message, currentUserNickname, myPeerID, palette) { + formatTextMessageSender( + message = message, + currentUserNickname = currentUserNickname, + myPeerID = myPeerID, + palette = palette, + ) + } + AnnotatedClickableText( + text = senderText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = { onLongPress?.invoke() }, + fontFamily = BitchatFontFamily, + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = MessageSenderTextStyle, + ) + } + + content() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.End), + ) { + Text( + text = formatTextMessageMetadata(message, timeFormatter), + fontFamily = BitchatFontFamily, + ) + if (isSelf && message.isPrivate) { + message.deliveryStatus?.let { status -> + Spacer(Modifier.width(4.dp)) + DeliveryStatusIcon(status = status) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt new file mode 100644 index 00000000..6909a52d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt @@ -0,0 +1,151 @@ +package com.bitchat.android.ui.media + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Description +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R + +/** + * Media picker that offers image and file options + * Clicking opens a quick selection menu + */ +@Composable +fun MediaPickerOptions( + modifier: Modifier = Modifier, + onImagePick: (() -> Unit)? = null, + onFilePick: (() -> Unit)? = null +) { + var showOptions by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + // Main button + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = Color.Gray.copy(alpha = 0.5f)) + .clickable { + showOptions = true + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.cd_pick_media), + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + } + + // Options menu (shown when clicked) + if (showOptions) { + Column( + modifier = Modifier + .graphicsLayer { + translationY = -120f // Position above the button + scaleX = 0.8f + scaleY = 0.8f + } + .zIndex(1f) + .clip(RoundedCornerShape(8.dp)) + .background(color = MaterialTheme.colorScheme.surface) + .clickable { + showOptions = false + } + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Image option + onImagePick?.let { imagePick -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .clickable { + showOptions = false + imagePick() + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(16.dp) + ) + androidx.compose.material3.Text( + text = stringResource(R.string.media_type_image), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + // File option + onFilePick?.let { filePick -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color = MaterialTheme.colorScheme.secondaryContainer) + .clickable { + showOptions = false + filePick() + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(16.dp) + ) + androidx.compose.material3.Text( + text = stringResource(R.string.media_type_file), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + } + + // Clickable overlay to dismiss options + if (showOptions) { + Box( + modifier = Modifier + .size(400.dp) + .clickable { + showOptions = false + } + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt b/app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt new file mode 100644 index 00000000..484c18b7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt @@ -0,0 +1,79 @@ +package com.bitchat.android.ui.media + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.dp + +/** + * Real-time scrolling waveform for recording: maintains a dense sliding window of bars. + * Pass in normalized amplitude [0f..1f]; the component handles sampling and drawing. + */ +@Composable +fun RealtimeScrollingWaveform( + modifier: Modifier = Modifier, + amplitudeNorm: Float, + bars: Int = 240, + barColor: Color = Color(0xFF00FF7F), + baseColor: Color = Color(0xFF444444) +) { + val latestAmp by rememberUpdatedState(amplitudeNorm) + val samples: SnapshotStateList = remember { + mutableStateListOf().also { list -> repeat(bars) { list.add(0f) } } + } + + // Append samples on a steady cadence to create a smooth scroll + LaunchedEffect(bars) { + while (true) { + withFrameNanos { _: Long -> } + val v = latestAmp.coerceIn(0f, 1f) + samples.add(v) + val overflow = samples.size - bars + if (overflow > 0) repeat(overflow) { if (samples.isNotEmpty()) samples.removeAt(0) } + kotlinx.coroutines.delay(20) + } + } + + Canvas(modifier = modifier.fillMaxWidth()) { + val w = size.width + val h = size.height + if (w <= 0f || h <= 0f) return@Canvas + val n = samples.size + if (n <= 0) return@Canvas + val stepX = w / n + val midY = h / 2f + val stroke = .5f.dp.toPx() + + // Optional faint base to match chat density + // Draw bars with heavy dynamic range compression: quiet sounds almost at zero, loud sounds still prominent + for (i in 0 until n) { + val amp = samples[i].coerceIn(0f, 1f) + // Use squared amplitude to heavily compress small values while preserving high amplitudes + // This makes quiet sounds almost invisible but loud sounds still show prominently + val compressedAmp = amp * amp // amp^2 + val lineH = (compressedAmp * (h * 0.9f)).coerceAtLeast(1f) + val x = i * stepX + stepX / 2f + val yTop = midY - lineH / 2f + val yBot = midY + lineH / 2f + drawLine( + color = barColor, + start = Offset(x, yTop), + end = Offset(x, yBot), + strokeWidth = stroke, + cap = StrokeCap.Round + ) + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt new file mode 100644 index 00000000..1f3f567e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.ui.media + +import com.bitchat.android.ui.theme.BitchatFontFamily +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import android.media.MediaPlayer +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun VoiceNotePlayer( + path: String, + modifier: Modifier = Modifier, + progressOverride: Float? = null, + progressColor: Color? = null +) { + var isPlaying by remember { mutableStateOf(false) } + var isPrepared by remember { mutableStateOf(false) } + var isError by remember { mutableStateOf(false) } + var progress by remember { mutableStateOf(0f) } + var durationMs by remember { mutableStateOf(0) } + val player = remember { MediaPlayer() } + + // Seek function - position is a fraction from 0.0 to 1.0 + val seekTo: (Float) -> Unit = { position -> + if (isPrepared && durationMs > 0) { + val seekMs = (position * durationMs).toInt().coerceIn(0, durationMs) + try { + player.seekTo(seekMs) + progress = position // Update progress immediately for UI responsiveness + } catch (_: Exception) {} + } + } + + LaunchedEffect(path) { + isPrepared = false + isError = false + progress = 0f + durationMs = 0 + isPlaying = false + try { + player.reset() + player.setOnPreparedListener { + isPrepared = true + durationMs = try { player.duration } catch (_: Exception) { 0 } + } + player.setOnCompletionListener { + isPlaying = false + progress = 1f + } + player.setOnErrorListener { _, _, _ -> + isError = true + isPlaying = false + true + } + player.setDataSource(path) + player.prepareAsync() + } catch (_: Exception) { + isError = true + } + } + + LaunchedEffect(isPlaying, isPrepared) { + try { + if (isPlaying && isPrepared) player.start() else if (isPrepared && player.isPlaying) player.pause() + } catch (_: Exception) {} + } + LaunchedEffect(isPlaying, isPrepared) { + while (isPlaying && isPrepared) { + progress = try { player.currentPosition.toFloat() / (player.duration.toFloat().coerceAtLeast(1f)) } catch (_: Exception) { 0f } + kotlinx.coroutines.delay(100) + } + } + DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } } + + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Disable play/pause while showing send progress override (optional UX choice) + val controlsEnabled = isPrepared && !isError && progressOverride == null + FilledTonalIconButton(onClick = { if (controlsEnabled) isPlaying = !isPlaying }, enabled = controlsEnabled, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play" + ) + } + val progressBarColor = progressColor ?: MaterialTheme.colorScheme.primary + com.bitchat.android.ui.media.WaveformPreview( + modifier = Modifier + .height(24.dp) + .weight(1f) + .padding(horizontal = 8.dp, vertical = 4.dp), + path = path, + sendProgress = progressOverride, + playbackProgress = if (progressOverride == null) progress else null, + onSeek = seekTo + ) + val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--" + Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp) + } +} + diff --git a/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt new file mode 100644 index 00000000..64261d47 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt @@ -0,0 +1,134 @@ +package com.bitchat.android.ui.media + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import com.bitchat.android.features.voice.AudioWaveformExtractor +import com.bitchat.android.features.voice.VoiceWaveformCache +import com.bitchat.android.features.voice.resampleWave + +@Composable +fun ScrollingWaveformRecorder( + modifier: Modifier = Modifier, + currentAmplitude: Float, + samples: SnapshotStateList, + maxSamples: Int = 120 +) { + // Append samples at a fixed cadence while visible + val latestAmp by rememberUpdatedState(currentAmplitude) + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { _: Long -> } + val v = latestAmp.coerceIn(0f, 1f) + samples.add(v) + val overflow = samples.size - maxSamples + if (overflow > 0) repeat(overflow) { if (samples.isNotEmpty()) samples.removeAt(0) } + kotlinx.coroutines.delay(80) + } + } + WaveformCanvas(modifier = modifier, samples = samples, fillProgress = 1f, baseColor = Color(0xFF444444), fillColor = Color(0xFF00FF7F)) +} + +@Composable +fun WaveformPreview( + modifier: Modifier = Modifier, + path: String, + sendProgress: Float?, + playbackProgress: Float?, + onLoaded: ((FloatArray) -> Unit)? = null, + onSeek: ((Float) -> Unit)? = null +) { + val cached = remember(path) { VoiceWaveformCache.get(path) } + val stateSamples = remember { mutableStateListOf() } + val progress = (sendProgress ?: playbackProgress)?.coerceIn(0f, 1f) ?: 0f + LaunchedEffect(cached) { + if (cached != null) { + val normalized = if (cached.size != 120) resampleWave(cached, 120) else cached + stateSamples.clear(); stateSamples.addAll(normalized.toList()) + } else { + AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> + if (arr != null) { + VoiceWaveformCache.put(path, arr) + stateSamples.clear(); stateSamples.addAll(arr.toList()) + onLoaded?.invoke(arr) + } + } + } + } + WaveformCanvas( + modifier = modifier, + samples = stateSamples, + fillProgress = if (stateSamples.isEmpty()) 0f else progress, + baseColor = Color(0x2200FF7F), + fillColor = when { + sendProgress != null -> Color(0xFF1E88E5) // blue while sending + else -> Color(0xFF00C851) // green during playback + }, + onSeek = onSeek + ) +} + +@Composable +private fun WaveformCanvas( + modifier: Modifier, + samples: List, + fillProgress: Float, + baseColor: Color, + fillColor: Color, + onSeek: ((Float) -> Unit)? = null +) { + val seekModifier = if (onSeek != null) { + modifier.pointerInput(onSeek) { + detectTapGestures { offset -> + // Calculate the seek position as a fraction (0.0 to 1.0) + val position = offset.x / size.width.toFloat() + val clampedPosition = position.coerceIn(0f, 1f) + onSeek(clampedPosition) + } + } + } else { + modifier + } + + Canvas(modifier = seekModifier.fillMaxWidth()) { + val w = size.width + val h = size.height + if (w <= 0f || h <= 0f) return@Canvas + val n = samples.size + if (n <= 0) return@Canvas + val stepX = w / n + val midY = h / 2f + val radius = 2.dp.toPx() + val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round) + val filledUntil = (n * fillProgress).toInt() + for (i in 0 until n) { + val amp = samples[i].coerceIn(0f, 1f) + val lineH = (amp * (h * 0.8f)).coerceAtLeast(2f) + val x = i * stepX + stepX / 2f + val yTop = midY - lineH / 2f + val yBot = midY + lineH / 2f + drawLine( + color = if (i <= filledUntil) fillColor else baseColor, + start = Offset(x, yTop), + end = Offset(x, yBot), + strokeWidth = stroke.width, + cap = StrokeCap.Round + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..8dfb87b0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -0,0 +1,90 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role. + * + * Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong + * to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics + * here lets Material components inherit correct defaults without losing Bitchat's identity. + */ +@Immutable +data class BitchatPalette( + // MARK: - Form controls + /** + * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted + * Material outline: the composer is the one surface the user stares at while typing. + */ + val inputOutline: Color, + /** Border for a focused text input. A step brighter, still neutral. */ + val inputOutlineFocused: Color, + /** + * Fill for text inputs. Near-black / near-white and completely untinted, for the same reason + * as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any + * tint of its own compounds into something muddy. + */ + val inputSurface: Color, + /** Fill for a focused text input. A barely perceptible lift. */ + val inputSurfaceFocused: Color, + /** Resting disc behind the composer's action glyphs. Neutral grey. */ + val inputButton: Color, + + // MARK: - Extra semantics + /** Timestamps, placeholders, section labels, disabled states. */ + val textTertiary: Color, + /** Self, mentions targeting you, unread DMs. */ + val accentOrange: Color, + /** Nostr reachability. */ + val accentPurple: Color, + + // MARK: - Deterministic peer colors + /** + * Saturation/value applied after deriving a peer's stable hue. Swap this when adding a + * new theme — see [PeerColorStyle] for contrast guidelines. + */ + val peerColors: PeerColorStyle, +) + +val DarkBitchatPalette = BitchatPalette( + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), + textTertiary = Color(0xFF6B776B), + accentOrange = Color(0xFFFF9F0A), + accentPurple = Color(0xFFBF5AF2), + peerColors = PeerColorStyle.Dark, +) + +val LightBitchatPalette = BitchatPalette( + inputOutline = Color(0xFFCFD3D1), + inputOutlineFocused = Color(0xFF8E9490), + inputSurface = Color(0xFFFAFAFA), + inputSurfaceFocused = Color(0xFFF2F2F2), + inputButton = Color(0xFFE8E8E8), + textTertiary = Color(0xFF757F75), + accentOrange = Color(0xFFFF9500), + accentPurple = Color(0xFFAF52DE), + peerColors = PeerColorStyle.Light, +) + +val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } + +/** + * Motion tokens. The redesign leans on short, snappy transitions: long durations read as + * sluggish on a chat surface where the user is scanning quickly. + */ +object BitchatMotion { + /** Icon tints, text colors, small fills. */ + const val QUICK_MS = 120 + + /** Tab indicators, pill growth, chip reveals. */ + const val STANDARD_MS = 180 + + /** Sheet-level fades and scroll-driven top bars. */ + const val EMPHASIZED_MS = 240 +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt new file mode 100644 index 00000000..36c9fb7f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui.theme + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Chat transcript presentation. + * + * [Matrix] is the established terminal-style transcript: a flat, left-aligned monochrome + * stream where colour is reserved for `@names` and links. + * + * [Bubbles] is the classic messenger layout: messages hug their content inside rounded + * bubbles, own messages on the right and everyone else on the left. Each bubble is tinted + * with its author's stable peer colour, so the speaker stays identifiable without reading + * the name. Colours, surfaces, and typography are untouched — only the message layout + * changes. + */ +enum class ChatUiMode { + Matrix, + Bubbles; + + val isMatrix: Boolean get() = this == Matrix + val isBubbles: Boolean get() = this == Bubbles +} + +/** + * Simple SharedPreferences-backed manager for the chat UI mode with a StateFlow. + * Mirrors [ThemePreferenceManager]. + */ +object ChatUiModeManager { + private const val PREFS_NAME = "bitchat_settings" + private const val KEY_CHAT_UI_MODE = "chat_ui_mode" + + private val _modeFlow = MutableStateFlow(ChatUiMode.Bubbles) + val modeFlow: StateFlow = _modeFlow + + fun init(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val saved = prefs.getString(KEY_CHAT_UI_MODE, ChatUiMode.Bubbles.name) + _modeFlow.value = runCatching { ChatUiMode.valueOf(saved!!) }.getOrDefault(ChatUiMode.Bubbles) + } + + fun set(context: Context, mode: ChatUiMode) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_CHAT_UI_MODE, mode.name).apply() + _modeFlow.value = mode + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt new file mode 100644 index 00000000..10ab9e9e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -0,0 +1,88 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.R + +/** + * The bundled Geist Mono family used throughout the app. + * + * Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec + * metrics do not depend on which monospace family a device happens to provide. + */ +internal val BitchatFontFamily = FontFamily( + Font(R.font.geist_mono_regular, FontWeight.Normal), + Font(R.font.geist_mono_medium, FontWeight.Medium), + Font(R.font.geist_mono_semibold, FontWeight.SemiBold), + Font(R.font.geist_mono_bold, FontWeight.Bold), +) + +/** Exact typography, spacing, and opacity values exported for the chat transcript. */ +internal object ChatVisualTokens { + val MessageBodyFontSize: TextUnit = 14.sp + val MessageBodyLineHeight: TextUnit = 20.sp + val SenderFontSize: TextUnit = 14.sp + val SenderLineHeight: TextUnit = 16.sp + val SystemActionFontSize: TextUnit = 12.sp + val SystemActionLineHeight: TextUnit = 16.sp + val SystemTimeFontSize: TextUnit = 10.sp + + val MessageItemSpacing: Dp = 8.dp + val SenderTopPadding: Dp = 8.dp + val SenderToBodySpacing: Dp = 4.dp + + // MARK: - Bubble geometry (ChatUiMode.Bubbles) + + /** Rounded corner on the three "free" corners of a message bubble. */ + val BubbleCornerRadius: Dp = 16.dp + + /** Tightened corner on the speaker's own side, giving the bubble a subtle tail. */ + val BubbleTailRadius: Dp = 4.dp + + /** Padding inside a bubble, around the text. */ + val BubblePaddingHorizontal: Dp = 12.dp + val BubblePaddingVertical: Dp = 8.dp + + /** A bubble never grows past this fraction of the list width, so long lines still wrap. */ + const val BubbleMaxWidthFraction: Float = 0.80f + + /** + * Author-colour wash inside a bubble. Matches the mention-chip treatment so a tinted + * bubble stays legible on both the near-black and near-white chat surfaces. + */ + const val BubbleBackgroundAlpha: Float = 0.18f + + /** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */ + const val BubbleBorderAlpha: Float = 0.38f + + const val SenderSuffixAlpha: Float = 0.60f + const val HighlightAlpha: Float = 0.20f + const val MutedTextAlpha: Float = 0.50f + + val MessageBodyStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Normal, + fontSize = MessageBodyFontSize, + lineHeight = MessageBodyLineHeight, + ) + + val SenderStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = SenderFontSize, + lineHeight = SenderLineHeight, + ) + + val SystemActionStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = SystemActionFontSize, + lineHeight = SystemActionLineHeight, + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt new file mode 100644 index 00000000..168c9863 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -0,0 +1,58 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.bitchat.android.ui.PeerIdentity +import kotlin.math.abs + +/** + * Theme-specific chroma applied after a peer's stable hue is derived. + * + * Hue stays identity-stable across themes (and byte-identical to iOS). Only saturation + * and value change so peer labels remain readable on each background. + * + * Guidelines when adding a future theme: + * - Dim / dark backgrounds: keep [value] high so colors are not lost against the surface; + * prefer muted [saturation] over neon. + * - Light backgrounds: keep [value] moderate-low so colors are not blinding; avoid + * near-full saturation. + */ +@Immutable +data class PeerColorStyle( + val saturation: Float, + val value: Float, +) { + companion object { + /** Soft pastels that stay bright enough on near-black chat surfaces. */ + val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f) + + /** Deeper, less saturated tones that stay readable on near-white surfaces. */ + val Light = PeerColorStyle(saturation = 0.70f, value = 0.42f) + } +} + +/** + * The single identity-to-color boundary used by chat, people sheets, and mentions. + * + * The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is + * avoided because it is reserved for the current user. + */ +fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in identity.stableKey.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + val style = palette.peerColors + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = style.saturation, + value = style.value + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 31dbc80a..f2c4b0b7 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -1,38 +1,67 @@ package com.bitchat.android.ui.theme +import android.app.Activity +import android.os.Build +import android.view.View +import android.view.WindowInsetsController import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView -// Colors that match the iOS bitchat theme -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF39FF14), // Bright green (terminal-like) +// Standard UI semantics live in Material so stock components and custom Bitchat composables +// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors. +internal val DarkBitchatColorScheme = darkColorScheme( + primary = Color(0xFF32D74B), onPrimary = Color.Black, - secondary = Color(0xFF2ECB10), // Darker green + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), onSecondary = Color.Black, - background = Color.Black, - onBackground = Color(0xFF39FF14), // Green on black - surface = Color(0xFF111111), // Very dark gray - onSurface = Color(0xFF39FF14), // Green text - error = Color(0xFFFF5555), // Red for errors + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = DarkBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surface = Color(0xFF0E150E), + onSurface = Color(0xFFF5F5F5), + surfaceVariant = Color(0xFF182118), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), onError = Color.Black ) -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF008000), // Dark green +internal val LightBitchatColorScheme = lightColorScheme( + primary = Color(0xFF248A3D), onPrimary = Color.White, - secondary = Color(0xFF006600), // Even darker green + primaryContainer = Color(0xFFD5F1D8), + onPrimaryContainer = Color(0xFF0A3212), + secondary = Color(0xFF007AFF), onSecondary = Color.White, - background = Color.White, - onBackground = Color(0xFF008000), // Dark green on white - surface = Color(0xFFF8F8F8), // Very light gray - onSurface = Color(0xFF008000), // Dark green text - error = Color(0xFFCC0000), // Dark red for errors + secondaryContainer = Color(0xFFD6E9FF), + onSecondaryContainer = Color(0xFF002C5C), + tertiary = LightBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF131A13), + surface = Color(0xFFF2F6F2), + onSurface = Color(0xFF131A13), + surfaceVariant = Color(0xFFE7EDE7), + onSurfaceVariant = Color(0xFF4C574C), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + error = Color(0xFFD70015), onError = Color.White ) @@ -53,11 +82,35 @@ fun BitchatTheme( } } - val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme + val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + val view = LocalView.current + SideEffect { + (view.context as? Activity)?.window?.let { window -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.setSystemBarsAppearance( + if (!shouldUseDark) WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS else 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ) + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = if (!shouldUseDark) { + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } else 0 + } + window.navigationBarColor = colorScheme.background.toArgb() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + } + } + + CompositionLocalProvider(LocalBitchatPalette provides palette) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt b/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt index 9e00bde9..7a040d9c 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt @@ -8,7 +8,13 @@ import kotlinx.coroutines.flow.StateFlow * App theme preference: System default, Light, or Dark. */ enum class ThemePreference { - System, Light, Dark + System, + Light, + Dark; + + val isSystem : Boolean get() = this == System + val isLight : Boolean get() = this == Light + val isDark : Boolean get() = this == Dark } /** diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index 04a0b14c..2002a44a 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -2,53 +2,81 @@ package com.bitchat.android.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Base font size for consistent scaling across the app -const val BASE_FONT_SIZE = 15 // sp - increased from 14sp for better readability +internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP + +/** + * Message body style. The generous leading (1.4x) is what gives the redesigned chat surface + * its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own + * metrics and lines sit too tightly for long paragraphs. + */ +val MessageBodyTextStyle = ChatVisualTokens.MessageBodyStyle + +/** Sender label above a message group. Single line, never wraps. */ +val MessageSenderTextStyle = ChatVisualTokens.SenderStyle // Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency val Typography = Typography( bodyLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), bodyMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = BASE_FONT_SIZE.sp, - lineHeight = (BASE_FONT_SIZE + 3).sp + lineHeight = (BASE_FONT_SIZE + 6).sp ), bodySmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 3).sp, lineHeight = (BASE_FONT_SIZE + 1).sp ), headlineSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 3).sp, lineHeight = (BASE_FONT_SIZE + 9).sp ), + // Previously unset, which leaked the Roboto default into onboarding + sheet titles. + headlineLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = (BASE_FONT_SIZE + 13).sp, + lineHeight = (BASE_FONT_SIZE + 21).sp + ), + titleLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE + 5).sp, + lineHeight = (BASE_FONT_SIZE + 13).sp + ), titleMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), + labelLarge = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = (BASE_FONT_SIZE - 1).sp, + lineHeight = (BASE_FONT_SIZE + 5).sp + ), labelMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE - 2).sp, lineHeight = (BASE_FONT_SIZE + 3).sp ), labelSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 4).sp, lineHeight = (BASE_FONT_SIZE + 1).sp diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt new file mode 100644 index 00000000..226e6842 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -0,0 +1,281 @@ +package com.bitchat.android.util + +import androidx.annotation.StringRes +import com.bitchat.android.R +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +/** + * A trusted location that serves the latest signed universal BitChat APK. + * + * Sources are tried in order. A source may list compatibility filenames, which + * are only used when the preferred asset is absent. Adding a mirror should only + * require another entry; resume, retry, and verification do not depend on the host. + */ +data class ApkDownloadSource( + val id: String, + val displayName: String, + val latestApkUrls: List +) { + constructor(id: String, displayName: String, latestApkUrl: String) : this( + id = id, + displayName = displayName, + latestApkUrls = listOf(latestApkUrl) + ) + + init { + require(id.isNotBlank()) { "Download source id must not be blank" } + require(displayName.isNotBlank()) { "Download source name must not be blank" } + require(latestApkUrls.isNotEmpty()) { "Download source must have at least one URL" } + require(latestApkUrls.distinct().size == latestApkUrls.size) { + "Download source URLs must be unique" + } + require(latestApkUrls.all { it.startsWith("https://") }) { + "APK download sources must use HTTPS" + } + } +} + +internal object DefaultApkDownloadSources { + const val GITHUB_ID = "github-releases" + + val all = listOf( + ApkDownloadSource( + id = GITHUB_ID, + displayName = "GitHub Releases", + latestApkUrls = listOf( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk", + // Releases published before the stable asset-name rollout use + // this filename. Remove when supported releases all use the primary URL. + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/app-universal-release.apk" + ) + ) + ) +} + +/** + * Why a download failed, and which string says so. + * + * Crosses a WorkManager `Data` boundary by [name], never by resource id: WorkManager keeps failed + * records in its own database across app updates, and AAPT2 reassigns `R.string` ids on every + * build, so a persisted id would resolve against the wrong resource table after an update. Same + * reasoning as [ApkDownloader.DownloadPhase.fromKey]. + */ +enum class ApkDownloadFailureReason(@StringRes val messageRes: Int) { + Generic(R.string.prepare_apk_error_generic), + Cancelled(R.string.prepare_apk_download_cancelled), + RateLimited(R.string.prepare_apk_error_rate_limited), + NoUniversalApk(R.string.prepare_apk_error_no_universal), + HttpFailure(R.string.prepare_apk_error_http), + InsufficientStorage(R.string.prepare_apk_error_storage_needed), + NoSources(R.string.prepare_apk_error_no_sources), + TorConnecting(R.string.prepare_apk_error_tor_connecting), + NoUsableUrl(R.string.prepare_apk_error_no_url), + Unreachable(R.string.prepare_apk_error_unreachable), + InsecureRedirect(R.string.prepare_apk_error_insecure_redirect), + ResumeRejected(R.string.prepare_apk_error_resume_rejected), + Incomplete(R.string.prepare_apk_error_incomplete), + InvalidResume(R.string.prepare_apk_error_invalid_resume), + UntrustedKey(R.string.prepare_apk_error_untrusted_key), + NotUniversal(R.string.prepare_apk_error_not_universal), + ApkUnreadable(R.string.prepare_apk_error_apk_unreadable), + NotBitchat(R.string.prepare_apk_error_not_bitchat), + NoVersion(R.string.prepare_apk_error_no_version), + SourceFailed(R.string.prepare_apk_error_source_failed), + AllSourcesFailed(R.string.prepare_apk_error_all_sources); + + companion object { + /** Work enqueued by an older build may name a reason this build no longer has. */ + fun fromKey(key: String?): ApkDownloadFailureReason = + entries.firstOrNull { it.name == key } ?: Generic + } +} + +/** + * A host-neutral download failure that tells the worker whether backoff can help. + * + * [reason] and [messageArgs] name what the user should be told without saying it in any + * particular language. This layer has no Context by design — that is what keeps its tests plain + * JUnit — so the ViewModel resolves them. The inherited [message] stays English for logs and + * stack traces, and is never shown. + */ +class ApkDownloadException( + message: String, + val reason: ApkDownloadFailureReason, + val messageArgs: List = emptyList(), + val retryable: Boolean, + val sourceId: String? = null, + val httpCode: Int? = null, + val retryAtMillis: Long? = null, + cause: Throwable? = null +) : IOException(message, cause) + +internal object ApkDownloadRetryPolicy { + const val MAX_ATTEMPTS = 3 + + fun shouldRetry(runAttemptCount: Int, error: Throwable?): Boolean { + val retryable = when (error) { + is ApkDownloadException -> error.retryable + is IOException -> true + else -> false + } + val attemptNumber = runAttemptCount + 1 + return retryable && attemptNumber < MAX_ATTEMPTS + } +} + +internal fun shouldTryNextSourceUrl( + error: ApkDownloadException, + hasMoreUrls: Boolean +): Boolean = hasMoreUrls && error.httpCode == 404 + +internal object ApkDownloadHttpErrors { + fun fromResponse( + source: ApkDownloadSource, + code: Int, + responseMessage: String, + retryAfter: String?, + rateLimitRemaining: String?, + rateLimitResetEpochSeconds: String?, + nowMillis: Long = System.currentTimeMillis() + ): ApkDownloadException { + val retryAt = retryAtMillis( + retryAfter = retryAfter, + rateLimitResetEpochSeconds = rateLimitResetEpochSeconds, + nowMillis = nowMillis + ) + // X-RateLimit-Reset rides on every GitHub response, an ordinary 403 included, so it + // cannot tell an exhausted quota from a permissions failure. Only a spent quota or an + // explicit Retry-After says this request was the one that got limited. The reset header + // still supplies the deadline below, once being limited is established some other way. + val retryAfterMillis = retryAtMillis( + retryAfter = retryAfter, + rateLimitResetEpochSeconds = null, + nowMillis = nowMillis + ) + val rateLimited = code == 429 || + (code == 403 && (rateLimitRemaining?.trim() == "0" || retryAfterMillis != null)) + + if (rateLimited) { + return ApkDownloadException( + message = "${source.id} rate limited: HTTP $code, retryAt=$retryAt", + reason = ApkDownloadFailureReason.RateLimited, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id, + httpCode = code, + retryAtMillis = retryAt + ) + } + + val retryable = code == 408 || code == 425 || code >= 500 + return ApkDownloadException( + message = "${source.id} failed: HTTP $code $responseMessage", + reason = if (code == 404) { + ApkDownloadFailureReason.NoUniversalApk + } else { + ApkDownloadFailureReason.HttpFailure + }, + messageArgs = if (code == 404) { + listOf(source.displayName) + } else { + listOf(source.displayName, code.toString(), responseMessage) + }, + retryable = retryable, + sourceId = source.id, + httpCode = code + ) + } + + internal fun retryAtMillis( + retryAfter: String?, + rateLimitResetEpochSeconds: String?, + nowMillis: Long + ): Long? { + retryAfter?.trim()?.toLongOrNull() + ?.takeIf { it > 0L } + ?.let { seconds -> + runCatching { + Math.addExact(nowMillis, Math.multiplyExact(seconds, 1000L)) + }.getOrNull()?.let { return it } + } + + retryAfter?.trim()?.takeIf { it.isNotEmpty() }?.let { value -> + val parsed = runCatching { + ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli() + }.getOrNull() + if (parsed != null && parsed > nowMillis) return parsed + } + + return rateLimitResetEpochSeconds?.trim()?.toLongOrNull() + ?.let { runCatching { Instant.ofEpochSecond(it).toEpochMilli() }.getOrNull() } + ?.takeIf { it > nowMillis } + } +} + +internal object AppVersion { + fun isNewer(currentVersion: String, candidateVersion: String): Boolean { + val current = currentVersion.removePrefix("v").trim() + val candidate = candidateVersion.removePrefix("v").trim() + if (current == candidate) return false + + val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } + val candidateParts = candidate.split(".").mapNotNull { it.toIntOrNull() } + val maxLength = maxOf(currentParts.size, candidateParts.size) + + for (index in 0 until maxLength) { + val currentPart = currentParts.getOrNull(index) ?: 0 + val candidatePart = candidateParts.getOrNull(index) ?: 0 + if (candidatePart != currentPart) return candidatePart > currentPart + } + return false + } +} + +internal data class ContentRange( + val start: Long, + val endInclusive: Long, + val total: Long? +) + +/** + * Makes a response body safe to append after resume metadata is updated. + * A full 200 replacement must discard bytes from the release that supplied the Range request. + */ +internal fun prepareApkTempFileForResponse(tempFile: File, appendResponse: Boolean) { + if (!appendResponse) FileOutputStream(tempFile, false).use { } +} + +internal fun parseContentRange(value: String?): ContentRange? { + if (value == null) return null + val match = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""", RegexOption.IGNORE_CASE) + .matchEntire(value.trim()) + ?: return null + val start = match.groupValues[1].toLongOrNull() ?: return null + val end = match.groupValues[2].toLongOrNull() ?: return null + if (end < start) return null + val total = match.groupValues[3].takeUnless { it == "*" }?.toLongOrNull() + if (total != null && end >= total) return null + return ContentRange( + start = start, + endInclusive = end, + total = total + ) +} + +internal fun parseUnsatisfiedContentRangeTotal(value: String?): Long? { + if (value == null) return null + return Regex("""bytes\s+\*/(\d+)""", RegexOption.IGNORE_CASE) + .matchEntire(value.trim()) + ?.groupValues + ?.get(1) + ?.toLongOrNull() +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt new file mode 100644 index 00000000..40edde59 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -0,0 +1,199 @@ +package com.bitchat.android.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.bitchat.android.R + +/** + * WorkManager worker that downloads the universal APK in the background. + * Survives app backgrounding and process death. Transient network errors are + * retried with backoff; partial downloads resume via HTTP Range requests. + * + * Runs as foreground (dataSync) work when possible so slow transfers (e.g. + * over Tor) are not killed by WorkManager's background execution window. + */ +class ApkDownloadWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + + companion object { + const val TAG = "ApkDownloadWorker" + const val WORK_NAME = "apk_download" + + // Progress keys + const val KEY_PROGRESS = "progress" + const val KEY_PHASE = "phase" + const val KEY_VERSION = "version" + const val KEY_SIZE_MB = "size_mb" + const val KEY_ERROR_REASON = "error_reason" + const val KEY_ERROR_ARGS = "error_args" + const val KEY_RESUMABLE_PERCENT = "resumable_percent" + + private const val CHANNEL_ID = "apk_download" + private const val NOTIFICATION_ID = 4201 + private const val NOTIFY_STEP_PERCENT = 5 + } + + private val apkManager = UniversalApkManager(applicationContext) + private val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT + private var lastProgress = 0 + private var currentPhase = ApkDownloader.DownloadPhase.SelectingSource + + override suspend fun doWork(): Result { + Log.d(TAG, "Starting APK download work") + + // Promote to foreground so long transfers aren't stopped by the + // ~10-minute background execution window. Android 12+ can reject the + // promotion when the app is backgrounded — continue as regular + // background work and rely on Range-resume in that case. + try { + setForeground(createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0)) + } catch (e: Exception) { + Log.w(TAG, "Could not promote download to foreground work", e) + } + + val result = apkManager.downloadUniversalApk( + progressCallback = { progress -> + lastProgress = progress + publishProgress(progress, currentPhase) + updateNotification(progress) + }, + phaseCallback = { phase -> + currentPhase = phase + publishProgress(lastProgress, phase) + // Forced: a phase change is exactly the moment the percentage stops meaning + // anything, so the every-5% threshold must not suppress the redraw. + updateNotification(lastProgress, force = true) + } + ) + + return if (result.isSuccess) { + val info = apkManager.getCachedApkInfo() + val outputData = Data.Builder() + .putString(KEY_VERSION, info?.version ?: "") + .putInt(KEY_SIZE_MB, ((info?.size ?: 0L) / 1024 / 1024).toInt()) + .build() + Result.success(outputData) + } else { + val error = result.exceptionOrNull() + + // Retry transient network errors with backoff; the partial file + // is kept on disk, so the retry resumes where it left off. + val attemptNumber = runAttemptCount + 1 + if (ApkDownloadRetryPolicy.shouldRetry(runAttemptCount, error)) { + Log.w( + TAG, + "Transient download error " + + "(attempt $attemptNumber/${ApkDownloadRetryPolicy.MAX_ATTEMPTS}), retrying", + error + ) + return Result.retry() + } + + val partial = apkManager.getPartialDownloadProgress() + // Only a named failure carries a localizable message; anything else falls back to a + // generic one rather than leaking an untranslated exception string to the user. + val failure = error as? ApkDownloadException + val outputData = Data.Builder() + // The reason's name, never its resource id: this record can outlive the build + // that wrote it, and resource ids are reassigned on every build. + .putString( + KEY_ERROR_REASON, + (failure?.reason ?: ApkDownloadFailureReason.Generic).name + ) + .putStringArray( + KEY_ERROR_ARGS, + failure?.messageArgs.orEmpty().toTypedArray() + ) + .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) + // No retry deadline is recorded: a rate-limit cooldown belongs to the route it was + // earned on, and ApkRateLimitStore already keeps it that way. A copy frozen here + // would outlive both the route and the cooldown. + .build() + Result.failure(outputData) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0) + } + + private fun createForegroundInfo(progress: Int): ForegroundInfo { + ensureChannel() + val notification = buildNotification(progress) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + private fun publishProgress(progress: Int, phase: ApkDownloader.DownloadPhase) { + setProgressAsync( + Data.Builder() + .putInt(KEY_PROGRESS, progress) + .putString(KEY_PHASE, phase.name) + .build() + ) + } + + private fun buildNotification(progress: Int): android.app.Notification { + val cancelIntent = WorkManager.getInstance(applicationContext) + .createCancelPendingIntent(id) + + return NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(applicationContext.getString(R.string.apk_download_notification_title)) + .setContentText(applicationContext.getString(downloadPhaseLabel(currentPhase))) + .setSmallIcon(R.drawable.ic_notification) + .setOngoing(true) + .setOnlyAlertOnce(true) + // A percentage is a lie outside the transfer: the release lookup, the Tor bootstrap + // and both verification passes have no measurable progress at all. + .setProgress(100, progress, !currentPhase.hasMeasurableProgress || progress <= 0) + .addAction( + android.R.drawable.ic_delete, + applicationContext.getString(android.R.string.cancel), + cancelIntent + ) + .build() + } + + private fun updateNotification(progress: Int, force: Boolean = false) { + if (!force && progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return + lastNotifiedProgress = progress + try { + notificationManager.notify(NOTIFICATION_ID, buildNotification(progress)) + } catch (e: Exception) { + // Missing POST_NOTIFICATIONS permission just drops the update; + // the download itself is unaffected. + Log.w(TAG, "Could not update download notification", e) + } + } + + private fun ensureChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + applicationContext.getString(R.string.apk_download_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt new file mode 100644 index 00000000..01be6280 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -0,0 +1,111 @@ +package com.bitchat.android.util + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for APK download operations. + * Abstracts the download mechanism so it can be swapped + * (e.g., WorkManager, ForegroundService, plain coroutine). + */ +interface ApkDownloader { + + /** + * Current download state as an observable flow. + */ + val downloadState: Flow + + /** + * Start or resume a download. If a partial download exists, it resumes automatically. + */ + fun startDownload() + + /** + * Cancel an in-progress download. The partial file is kept for future resume. + */ + fun cancelDownload() + + /** + * Download state reported by the downloader. + */ + sealed class DownloadState { + object Idle : DownloadState() + data class Downloading( + val progressPercent: Int, + val phase: DownloadPhase = DownloadPhase.Transferring + ) : DownloadState() + data class Success(val version: String, val sizeMB: Int) : DownloadState() + /** + * [reason] and [messageArgs] stay structured until the presentation boundary. Carrying + * them instead of formatted text keeps the failure localizable across WorkManager. + */ + data class Failed( + val reason: ApkDownloadFailureReason, + val messageArgs: List, + val resumablePercent: Int? + ) : DownloadState() + } + + /** + * What a download is actually doing. + * + * Only [Transferring] has meaningful percentage progress; selecting a mirror, + * waiting for connectivity, and checking the signature are indeterminate. + */ + enum class DownloadPhase { + AwaitingConnectivity, + /** + * Waiting out the backoff before another attempt. Distinct from + * [AwaitingConnectivity] because WorkManager parks a retry in ENQUEUED + * whether or not the device is online, and claiming a network wait there + * would be false on a connected device. + */ + Retrying, + SelectingSource, + AwaitingNetworkRoute, + Transferring, + VerifyingSignature; + + /** A percentage is only honest while bytes are actually moving. */ + val hasMeasurableProgress: Boolean get() = this == Transferring + + companion object { + /** Tolerates an unknown or absent key, since it crosses a WorkManager Data boundary. */ + fun fromKey(key: String?): DownloadPhase = when (key) { + // Work created by the previous implementation may still be observable. + "ResolvingRelease" -> SelectingSource + "VerifyingChecksum" -> VerifyingSignature + else -> entries.firstOrNull { it.name == key } ?: Transferring + } + } + } +} + +/** + * What a queued work record is actually waiting for. + * + * WorkManager parks both cases in ENQUEUED, so the state alone cannot tell them apart. A non-zero + * [runAttemptCount] means the work already ran and failed, which makes this the retry backoff + * rather than an unmet network constraint. + */ +internal fun queuedPhase(runAttemptCount: Int): ApkDownloader.DownloadPhase = + if (runAttemptCount > 0) { + ApkDownloader.DownloadPhase.Retrying + } else { + ApkDownloader.DownloadPhase.AwaitingConnectivity + } + +/** Shared by the notification and the About sheet so both name a phase identically. */ +internal fun downloadPhaseLabel(phase: ApkDownloader.DownloadPhase): Int = when (phase) { + ApkDownloader.DownloadPhase.AwaitingConnectivity -> + com.bitchat.android.R.string.prepare_apk_phase_awaiting_connectivity + ApkDownloader.DownloadPhase.Retrying -> + com.bitchat.android.R.string.prepare_apk_phase_retrying + ApkDownloader.DownloadPhase.SelectingSource -> + com.bitchat.android.R.string.prepare_apk_phase_selecting_source + ApkDownloader.DownloadPhase.AwaitingNetworkRoute -> + com.bitchat.android.R.string.prepare_apk_phase_awaiting_route + ApkDownloader.DownloadPhase.Transferring -> + com.bitchat.android.R.string.prepare_apk_phase_transferring + ApkDownloader.DownloadPhase.VerifyingSignature -> + com.bitchat.android.R.string.prepare_apk_phase_verifying_signature +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt new file mode 100644 index 00000000..963099b3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.core.content.edit +import com.bitchat.android.net.OkHttpProvider + +/** Persistent, route-specific cooldowns for APK-related network requests. */ +internal class ApkRateLimitStore(context: Context) { + companion object { + private const val PREFS_NAME = "apk_network_cooldowns" + private const val FALLBACK_COOLDOWN_MILLIS = 60_000L + private const val MAX_COOLDOWN_MILLIS = 60 * 60_000L + } + + private val preferences = context.applicationContext.getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + + fun retryAtMillis( + scope: String, + route: OkHttpProvider.Route, + nowMillis: Long = System.currentTimeMillis() + ): Long? { + val key = key(scope, route) + val deadline = preferences.getLong(key, 0L) + if (deadline <= nowMillis) { + if (deadline != 0L) preferences.edit { remove(key) } + return null + } + return deadline + } + + fun recordRateLimit( + scope: String, + route: OkHttpProvider.Route, + serverRetryAtMillis: Long?, + nowMillis: Long = System.currentTimeMillis() + ): Long { + val fallback = nowMillis + FALLBACK_COOLDOWN_MILLIS + val maximum = nowMillis + MAX_COOLDOWN_MILLIS + val deadline = (serverRetryAtMillis ?: fallback).coerceIn(nowMillis + 1_000L, maximum) + // Persist before reporting the failure so a process restart cannot bypass the cooldown. + preferences.edit(commit = true) { putLong(key(scope, route), deadline) } + return deadline + } + + fun clear(scope: String, route: OkHttpProvider.Route) { + preferences.edit { remove(key(scope, route)) } + } + + fun blockedException( + source: ApkDownloadSource, + retryAtMillis: Long + ): ApkDownloadException { + return ApkDownloadException( + message = "${source.id} is in a persisted rate-limit cooldown until $retryAtMillis", + reason = ApkDownloadFailureReason.RateLimited, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id, + retryAtMillis = retryAtMillis + ) + } + + private fun key(scope: String, route: OkHttpProvider.Route): String = + "${scope}_${route.name.lowercase()}" +} diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt new file mode 100644 index 00000000..b1822806 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -0,0 +1,145 @@ +package com.bitchat.android.util + +import java.util.UUID + +/** + * Centralized application-wide constants. + */ +object AppConstants { + // Packet time-to-live (hops) + val MESSAGE_TTL_HOPS: UByte = 7u // Default TTL for regular packets + val SYNC_TTL_HOPS: UByte = 0u // TTL for neighbor-only sync packets + + object Mesh { + // Peer lifecycle + const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes + const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L + const val PEER_DISCONNECT_GRACE_MS: Long = 10_000L + + // BLE connection tracking + const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L + const val MAX_CONNECTION_ATTEMPTS: Int = 3 + const val CONNECTION_CLEANUP_DELAY_MS: Long = 500L + const val CONNECTION_CLEANUP_INTERVAL_MS: Long = 30_000L + const val BROADCAST_CLEANUP_DELAY_MS: Long = 500L + + object Gatt { + val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") + val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") + val DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + } + } + + object Sync { + const val CLEANUP_INTERVAL_MS: Long = 60_000L + } + + object Fragmentation { + const val FRAGMENT_SIZE_THRESHOLD: Int = 512 + const val MAX_FRAGMENT_SIZE: Int = 469 + const val FRAGMENT_TIMEOUT_MS: Long = 30_000L + const val CLEANUP_INTERVAL_MS: Long = 10_000L + const val MAX_FRAGMENTS_PER_ID: Int = 256 + const val MAX_FRAGMENT_TOTAL_BYTES: Int = 1_048_576 + const val MAX_ACTIVE_FRAGMENT_SETS: Int = 64 + const val MAX_GLOBAL_FRAGMENT_TOTAL_BYTES: Long = 4L * 1_048_576L + } + + object Security { + const val MESSAGE_TIMEOUT_MS: Long = 300_000L + const val CLEANUP_INTERVAL_MS: Long = 300_000L + const val MAX_PROCESSED_MESSAGES: Int = 10_000 + const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000 + const val KEY_EXCHANGE_DEDUP_TIMEOUT_MS: Long = 60_000L + } + + object Noise { + const val REKEY_TIME_LIMIT_MS: Long = 3_600_000L // 1 hour + const val REKEY_MESSAGE_LIMIT_ENCRYPTION: Long = 1_000L // per session, encryption service policy + const val REKEY_MESSAGE_LIMIT_SESSION: Long = 10_000L // session-level ceiling + const val MAX_PAYLOAD_SIZE_BYTES: Int = 256 + const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L + } + + object Verification { + const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes + } + + object Protocol { + const val COMPRESSION_THRESHOLD_BYTES: Int = 100 + const val MAX_PAYLOAD_LENGTH: Int = 10_485_760 + } + + object StoreForward { + const val MESSAGE_CACHE_TIMEOUT_MS: Long = 43_200_000L // 12h + const val MAX_CACHED_MESSAGES: Int = 100 + const val MAX_CACHED_MESSAGES_FAVORITES: Int = 1_000 + const val CLEANUP_INTERVAL_MS: Long = 600_000L + } + + object Power { + const val CRITICAL_BATTERY_PERCENT: Int = 10 + const val LOW_BATTERY_PERCENT: Int = 20 + const val MEDIUM_BATTERY_PERCENT: Int = 50 + const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L + const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L + const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L + const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L + const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L + const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L + const val MAX_CONNECTIONS_NORMAL: Int = 8 + const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 + const val MAX_CONNECTIONS_ULTRA_LOW: Int = 8 + } + + object Nostr { + // Relay backoff + const val INITIAL_BACKOFF_INTERVAL_MS: Long = 1_000L + const val MAX_BACKOFF_INTERVAL_MS: Long = 300_000L + const val BACKOFF_MULTIPLIER: Double = 2.0 + const val MAX_RECONNECT_ATTEMPTS: Int = 10 + + // Transport + const val READ_ACK_INTERVAL_MS: Long = 350L + + // Deduplicator + const val DEFAULT_DEDUP_CAPACITY: Int = 10_000 + + // Relay subscription validation + const val SUBSCRIPTION_VALIDATION_INTERVAL_MS: Long = 30_000L + } + + object Tor { + const val DEFAULT_SOCKS_PORT: Int = 9060 + const val RESTART_DELAY_MS: Long = 2_000L + const val INACTIVITY_TIMEOUT_MS: Long = 5_000L + const val MAX_RETRY_ATTEMPTS: Int = 5 + const val STOP_TIMEOUT_MS: Long = 7_000L + } + + object UI { + const val MAX_NICKNAME_LENGTH: Int = 15 + const val BASE_FONT_SIZE_SP: Int = 14 + const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L + const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L + const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH" + const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH" + } + + object Media { + // A file is currently encoded into one protocol payload before BLE fragmentation. + // Reserve room for maximum filename/MIME TLVs and encryption envelope overhead. + const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024) + } + + object Router { + const val OUTBOX_TICK_MS: Long = 2_000L + const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours + const val OUTBOX_MAX_PER_PEER: Int = 100 + val HANDSHAKE_RETRY_BACKOFF_MS: LongArray = longArrayOf(5_000L, 15_000L, 30_000L, 60_000L) + } + + object Services { + const val SEEN_MESSAGE_MAX_IDS: Int = 10_000 + } +} diff --git a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt new file mode 100644 index 00000000..82d51033 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt @@ -0,0 +1,214 @@ +package com.bitchat.android.util + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import com.bitchat.android.BuildConfig +import java.io.File +import java.security.MessageDigest +import java.util.zip.ZipFile + +/** + * Read-only diagnostics describing how the currently running app was packaged + * and installed. These values are facts about the installed artifact, not + * settings that can be changed at runtime. + */ +object DistributionInfoProvider { + private val UNIVERSAL_RELEASE_ABIS = setOf( + "arm64-v8a", + "armeabi-v7a", + "x86_64", + "x86" + ) + + fun inspect(context: Context): DistributionInfo { + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + signingFlags() + ) + val applicationInfo = context.applicationInfo + val splitApks = applicationInfo.splitSourceDirs.orEmpty() + val installerPackage = installerPackageName(context) + val certificateSha256 = signingCertificateSha256(packageInfo) + val installedApkVariant = if (splitApks.isEmpty()) { + shareableApkVariant(File(applicationInfo.sourceDir)) + } else { + null + } + + return DistributionInfo( + installSource = installSourceLabel(installerPackage), + installerPackage = installerPackage, + packageFormat = if (splitApks.isEmpty()) "Standalone APK" else "Split APK set", + architecture = architectureLabel(applicationInfo.sourceDir, splitApks), + sharingSource = when (installedApkVariant) { + ShareableApkVariant.UNIVERSAL -> "Current installed APK" + ShareableApkVariant.ARM64 -> "Current installed APK (ARM64)" + null -> "Verified GitHub universal APK" + }, + versionName = packageInfo.versionName ?: BuildConfig.VERSION_NAME, + versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + }, + signingChannel = signingChannel(installerPackage, certificateSha256), + certificateSha256 = certificateSha256 + ) + } + + private fun installerPackageName(context: Context): String? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.packageManager + .getInstallSourceInfo(context.packageName) + .installingPackageName + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstallerPackageName(context.packageName) + } + } catch (_: Exception) { + null + } + } + + private fun installSourceLabel(installerPackage: String?): String { + return when (installerPackage) { + "com.android.vending" -> "Google Play" + "com.amazon.venezia" -> "Amazon Appstore" + "org.fdroid.fdroid" -> "F-Droid" + "com.android.packageinstaller", + "com.google.android.packageinstaller", + "com.android.permissioncontroller" -> "Android package installer" + null -> if (BuildConfig.DEBUG) "ADB / local install" else "Unknown / local install" + else -> installerPackage + } + } + + private fun architectureLabel(baseApkPath: String, splitApkPaths: Array): String { + val apkPaths = listOf(baseApkPath) + splitApkPaths + val packagedAbis = buildSet { + apkPaths.forEach { path -> + addAll(nativeAbisInApk(File(path))) + addAll(abisInSplitName(File(path).name)) + } + } + + return when { + packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) -> + "Universal (${packagedAbis.joinToString()})" + packagedAbis.size > 1 -> "Multi-ABI (${packagedAbis.joinToString()})" + packagedAbis.size == 1 -> packagedAbis.single() + splitApkPaths.isNotEmpty() -> "Device ABI (${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"})" + else -> "Universal (no native ABI payload)" + } + } + + /** + * An APK with no native payload works across ABIs. When native libraries + * are present, require every ABI produced by the release workflow. + */ + fun isUniversalApk(apk: File): Boolean { + val packagedAbis = nativeAbisInApk(apk) + return packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) + } + + /** + * Returns the compatibility of an APK that is safe to offer for sharing. + * ARM64 is intentionally the only architecture-limited release variant + * supported because it is the project's primary per-ABI build. + */ + fun shareableApkVariant(apk: File): ShareableApkVariant? { + val packagedAbis = nativeAbisInApk(apk) + return when { + packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) -> + ShareableApkVariant.UNIVERSAL + packagedAbis == setOf("arm64-v8a") -> ShareableApkVariant.ARM64 + else -> null + } + } + + internal fun nativeAbisInApk(apk: File): Set { + if (!apk.isFile) return emptySet() + return try { + ZipFile(apk).use { zip -> + buildSet { + val entries = zip.entries() + while (entries.hasMoreElements()) { + val path = entries.nextElement().name + if (path.startsWith("lib/")) { + path.split('/').getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let(::add) + } + } + } + } + } catch (_: Exception) { + emptySet() + } + } + + private fun abisInSplitName(fileName: String): Set { + val normalizedName = fileName.replace('_', '-') + return Build.SUPPORTED_ABIS + .filter { abi -> normalizedName.contains(abi.replace('_', '-'), ignoreCase = true) } + .toSet() + } + + private fun signingChannel(installerPackage: String?, certificateSha256: String?): String { + if (BuildConfig.DEBUG) return "Debug" + if (installerPackage == "com.android.vending") return "Google Play" + + val pinnedGitHubCert = BuildConfig.GITHUB_RELEASE_CERT_SHA256 + .replace(":", "") + .lowercase() + .takeIf { it.matches(Regex("[a-f0-9]{64}")) } + return if (certificateSha256 != null && certificateSha256 == pinnedGitHubCert) { + "GitHub release" + } else { + "Release / unknown channel" + } + } + + private fun signingCertificateSha256(packageInfo: PackageInfo): String? { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.signingInfo?.apkContentsSigners + } else { + @Suppress("DEPRECATION") + packageInfo.signatures + } + val signature = signatures?.firstOrNull() ?: return null + return MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { "%02x".format(it) } + } + + private fun signingFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_SIGNATURES + } + } + + data class DistributionInfo( + val installSource: String, + val installerPackage: String?, + val packageFormat: String, + val architecture: String, + val sharingSource: String, + val versionName: String, + val versionCode: Long, + val signingChannel: String, + val certificateSha256: String? + ) +} + +enum class ShareableApkVariant { + UNIVERSAL, + ARM64 +} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt new file mode 100644 index 00000000..d00122d8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -0,0 +1,266 @@ +package com.bitchat.android.util + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +internal interface LatestReleaseProvider { + suspend fun latestRelease(): Result +} + +/** Fetches GitHub release metadata without participating in APK availability. */ +internal class GitHubReleaseClient( + context: Context, + private val apiUrl: String = GITHUB_API_URL, + private val nowMillis: () -> Long = System::currentTimeMillis, + private val routedClient: () -> OkHttpProvider.RoutedClient = OkHttpProvider::routedHttpClient, + private val awaitRoute: suspend () -> Boolean = { + ArtiTorManager.getInstance().awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS) + }, + private val rateLimits: ApkRateLimitStore = ApkRateLimitStore(context) +) : LatestReleaseProvider { + companion object { + private const val TAG = "GitHubRelease" + private const val GITHUB_API_URL = + "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest" + private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L + private const val CACHE_TTL_MILLIS = 30 * 60_000L + private const val PREFS_NAME = "apk_release_metadata" + private const val RATE_LIMIT_SCOPE = "github_release_metadata" + private const val USER_AGENT = "BitChat-Android" + + private val SOURCE = ApkDownloadSource( + id = DefaultApkDownloadSources.GITHUB_ID, + displayName = "GitHub Releases", + latestApkUrl = "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk" + ) + + internal fun parseRelease(jsonString: String): Release? = runCatching { + val json = JSONObject(jsonString) + val tagName = json.optString("tag_name") + val versionName = tagName.removePrefix("v").trim() + if (versionName.isBlank()) return null + + val assets = json.optJSONArray("assets") ?: return null + for (index in 0 until assets.length()) { + val asset = assets.getJSONObject(index) + val name = asset.optString("name") + val url = asset.optString("browser_download_url") + if (name.contains("universal", ignoreCase = true) && + name.endsWith(".apk", ignoreCase = true) && + url.startsWith("https://") + ) { + return Release( + versionName = versionName, + universalApkSize = asset.optLong("size", 0L), + universalApkUrl = url, + universalApkName = name + ) + } + } + null + }.getOrNull() + } + + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val mutex = Mutex() + + override suspend fun latestRelease(): Result = withContext(Dispatchers.IO) { + mutex.withLock { + val cached = readCache() + val now = nowMillis() + val cacheAge = cached?.let { now - it.fetchedAtMillis } + if (cached != null && cacheAge != null && cacheAge in 0 until CACHE_TTL_MILLIS) { + return@withLock Result.success(ReleaseSnapshot(cached.release, isStale = false)) + } + + if (!awaitRoute()) return@withLock cached.orRouteFailure() + + val routeSnapshot = routedClient() + // Route readiness can take longer than a cooldown. Judge an existing deadline at the + // point where the request can actually start, not with the pre-wait cache timestamp. + val routeReadyNow = nowMillis() + rateLimits.retryAtMillis( + RATE_LIMIT_SCOPE, + routeSnapshot.route, + routeReadyNow + )?.let { deadline -> + return@withLock cached.orFailure( + rateLimits.blockedException(SOURCE, deadline) + ) + } + + val request = Request.Builder() + .url(apiUrl) + .addHeader("User-Agent", USER_AGENT) + .addHeader("Accept", "application/vnd.github+json") + .addHeader("X-GitHub-Api-Version", "2022-11-28") + .apply { cached?.etag?.let { addHeader("If-None-Match", it) } } + .build() + val client = routeSnapshot.client.newBuilder() + .callTimeout(45, TimeUnit.SECONDS) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + try { + client.newCall(request).awaitResponse().use { response -> + // The route wait and the call itself can each take a minute, so `now` is too + // old to interpret a relative Retry-After: anchoring the cooldown there can + // date it into the past and let the very next check reach GitHub. + val responseNow = nowMillis() + if (apiUrl.startsWith("https://") && !response.request.url.isHttps) { + return@withLock cached.orFailure( + IOException("GitHub redirected release metadata to an insecure URL") + ) + } + if (response.code == 304 && cached != null) { + val refreshed = cached.copy(fetchedAtMillis = responseNow) + writeCache(refreshed) + rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) + return@withLock Result.success( + ReleaseSnapshot(refreshed.release, isStale = false) + ) + } + if (!response.isSuccessful) { + val failure = ApkDownloadHttpErrors.fromResponse( + source = SOURCE, + code = response.code, + responseMessage = response.message, + retryAfter = response.header("Retry-After"), + rateLimitRemaining = response.header("X-RateLimit-Remaining"), + rateLimitResetEpochSeconds = response.header("X-RateLimit-Reset"), + nowMillis = responseNow + ) + val persistedFailure = if ( + failure.reason == ApkDownloadFailureReason.RateLimited + ) { + val deadline = rateLimits.recordRateLimit( + RATE_LIMIT_SCOPE, + routeSnapshot.route, + failure.retryAtMillis, + responseNow + ) + rateLimits.blockedException(SOURCE, deadline) + } else { + failure + } + return@withLock cached.orFailure(persistedFailure) + } + + val rawBody = response.body.string() + val release = parseRelease(rawBody) + ?: return@withLock cached.orFailure( + IOException("GitHub's latest release has no universal APK asset") + ) + val entry = CachedRelease( + release = release, + etag = response.header("ETag"), + fetchedAtMillis = responseNow + ) + writeCache(entry) + rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) + Result.success(ReleaseSnapshot(release, isStale = false)) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w(TAG, "Could not refresh release metadata; using cache when available", error) + cached.orFailure(error) + } + } + } + + private fun CachedRelease?.orRouteFailure(): Result = orFailure( + IOException("The selected network route is not ready") + ) + + private fun CachedRelease?.orFailure(error: Throwable): Result = + if (this != null) { + Result.success(ReleaseSnapshot(release, isStale = true)) + } else { + Result.failure(error) + } + + private fun readCache(): CachedRelease? = runCatching { + val version = preferences.getString("version", null)?.takeIf { it.isNotBlank() } ?: return null + val url = preferences.getString("url", null)?.takeIf { it.startsWith("https://") } ?: return null + val name = preferences.getString("name", null)?.takeIf { it.isNotBlank() } ?: return null + CachedRelease( + release = Release( + versionName = version, + universalApkSize = preferences.getLong("size", 0L), + universalApkUrl = url, + universalApkName = name + ), + etag = preferences.getString("etag", null), + fetchedAtMillis = preferences.getLong("fetched_at", 0L) + ) + }.getOrNull() + + private fun writeCache(entry: CachedRelease) { + preferences.edit(commit = true) { + putString("version", entry.release.versionName) + putLong("size", entry.release.universalApkSize) + putString("url", entry.release.universalApkUrl) + putString("name", entry.release.universalApkName) + putString("etag", entry.etag) + putLong("fetched_at", entry.fetchedAtMillis) + } + } + + private suspend fun Call.awaitResponse(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + continuation.resumeWith(Result.failure(e)) + } + } + + override fun onResponse(call: Call, response: Response) { + if (continuation.isActive) { + continuation.resumeWith(Result.success(response)) + } else { + response.close() + } + } + }) + } + + data class Release( + val versionName: String, + val universalApkSize: Long, + val universalApkUrl: String, + val universalApkName: String + ) + + data class ReleaseSnapshot( + val release: Release, + val isStale: Boolean + ) + + private data class CachedRelease( + val release: Release, + val etag: String?, + val fetchedAtMillis: Long + ) +} diff --git a/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt b/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt deleted file mode 100644 index ead07448..00000000 --- a/app/src/main/java/com/bitchat/android/util/NotificationIntervalManager.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.bitchat.android.util - -class NotificationIntervalManager { - private var _lastNetworkNotificationTime = 0L - val lastNetworkNotificationTime: Long - get() = _lastNetworkNotificationTime - - val recentlySeenPeers: MutableSet = mutableSetOf() - - fun setLastNetworkNotificationTime(notificationTime: Long) { - _lastNetworkNotificationTime = notificationTime - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt new file mode 100644 index 00000000..8480fc27 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -0,0 +1,1029 @@ +package com.bitchat.android.util + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import com.bitchat.android.BuildConfig +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +/** + * Manages local and downloaded APK artifacts for offline sharing. + */ +class UniversalApkManager( + private val context: Context, + private val downloadSources: List = DefaultApkDownloadSources.all +) { + init { + require(downloadSources.map { it.id }.distinct().size == downloadSources.size) { + "APK download source ids must be unique" + } + } + + companion object { + private const val TAG = "UniversalApk" + private const val CACHE_DIR_NAME = "universal_apk" + private const val METADATA_FILE_NAME = "universal_apk_info.json" + private const val PROGRESS_FILE_NAME = "download_progress.json" + private const val APK_FILE_PREFIX = "bitchat-universal-" + private const val TEMP_FILE_NAME = "download_temp.apk" + private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L + + // Download buffer size (128KB) + private const val BUFFER_SIZE = 128 * 1024 + } + + private val cacheDir: File + get() = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } + + private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME) + private val progressFile: File get() = File(cacheDir, PROGRESS_FILE_NAME) + private val rateLimits = ApkRateLimitStore(context) + + // Download client: inherits the current client's actual route but has no call timeout for + // large files that can take minutes. Keep the route attached for route-specific cooldowns. + private fun downloadClient(): OkHttpProvider.RoutedClient { + val routed = OkHttpProvider.routedHttpClient() + return routed.copy( + client = routed.client.newBuilder() + .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .build() + ) + } + + /** + * Get information about the cached sharing APK, if it exists. + */ + fun getCachedApkInfo(): ApkInfo? { + return try { + if (!metadataFile.exists()) { + return null + } + + val json = JSONObject(metadataFile.readText()) + val version = json.optString("version", "") + val downloadDate = json.optLong("downloadDate", 0L) + val size = json.optLong("size", 0L) + val fileName = json.optString("fileName", "") + val source = when (json.optString("source")) { + ApkSource.INSTALLED.name -> ApkSource.INSTALLED + // Migrate metadata written before downloads became mirror-agnostic. + else -> ApkSource.DOWNLOADED + } + val downloadSourceId = json.optString("downloadSourceId") + .takeIf { it.isNotBlank() } + + if (version.isBlank() || fileName.isBlank()) { + return null + } + + val apkFile = File(cacheDir, fileName) + if (!apkFile.exists()) { + Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}") + return null + } + val variant = runCatching { + ShareableApkVariant.valueOf(json.optString("variant")) + }.getOrNull() ?: DistributionInfoProvider.shareableApkVariant(apkFile) + if (variant == null) { + Log.w(TAG, "Cached APK is not a supported sharing variant") + return null + } + + ApkInfo( + version = version, + downloadDate = downloadDate, + size = size, + file = apkFile, + source = source, + variant = variant, + downloadSourceId = downloadSourceId + ) + } catch (e: Exception) { + Log.e(TAG, "Error reading cached APK info", e) + null + } + } + + /** + * Get the cached APK file, if it exists. + */ + fun getCachedApk(): File? { + return getCachedApkInfo()?.file + } + + /** + * Check if a partial (resumable) download exists. + * Returns the progress percentage (0-100) or null if no partial download. + */ + fun getPartialDownloadProgress(): Int? { + val tempFile = File(cacheDir, TEMP_FILE_NAME) + val resumeInfo = loadResumeInfo() + if (tempFile.exists() && resumeInfo != null) { + val expectedSize = resumeInfo.expectedSize + if (expectedSize > 0) { + return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99) + } + } + return null + } + + /** + * Prepare or read the best local sharing artifact. This never performs a + * network request, so opening the About sheet cannot consume API quota or + * wait for Tor. + */ + suspend fun prepareLocalApkInfo(): ApkInfo? = withContext(Dispatchers.IO) { + cacheInstalledApkIfPreferred() ?: getCachedApkInfo() + } + + /** + * Check if there's enough disk space to download the APK. + * Requires 1.5x the file size for safety margin (temp + final file). + * @throws IOException if insufficient space + */ + private fun checkDiskSpace(requiredSize: Long) { + val availableSpace = cacheDir.usableSpace + val requiredWithMargin = (requiredSize * 1.5).toLong() + + if (availableSpace < requiredWithMargin) { + val requiredMB = requiredWithMargin / 1024 / 1024 + val availableMB = availableSpace / 1024 / 1024 + val error = "Insufficient storage: need ${requiredMB}MB, have ${availableMB}MB" + Log.e(TAG, error) + throw ApkDownloadException( + message = error, + reason = ApkDownloadFailureReason.InsufficientStorage, + messageArgs = listOf(requiredMB.toString(), availableMB.toString()), + retryable = false + ) + } + } + + /** + * Download from the configured sources. Each source gets one attempt in this + * worker run; WorkManager owns retry/backoff across runs. + */ + suspend fun downloadUniversalApk( + progressCallback: ((Int) -> Unit)? = null, + phaseCallback: ((ApkDownloader.DownloadPhase) -> Unit)? = null + ): Result = withContext(Dispatchers.IO) { + if (downloadSources.isEmpty()) { + return@withContext Result.failure( + ApkDownloadException( + message = "No APK download sources are configured.", + reason = ApkDownloadFailureReason.NoSources, + retryable = false + ) + ) + } + + try { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) + if (!ArtiTorManager.getInstance().awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS)) { + return@withContext Result.failure( + ApkDownloadException( + message = "Tor is still connecting.", + reason = ApkDownloadFailureReason.TorConnecting, + retryable = true + ) + ) + } + + val failures = mutableListOf() + val sources = sourcesWithResumeFirst() + for ((index, source) in sources.withIndex()) { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.SelectingSource) + if (index > 0) clearPartialDownload() + + try { + Log.d(TAG, "Downloading universal APK from ${source.displayName}") + phaseCallback?.invoke(ApkDownloader.DownloadPhase.Transferring) + val tempFile = downloadFromSource(source, progressCallback) + + phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingSignature) + validateDownloadedApk(tempFile, source) + + // Everything from here to the metadata write is plain blocking code, so a + // cancellation arriving during the (slow) signature check would otherwise go + // unobserved and commit the APK anyway. The verified temp file survives for + // resume; only the promotion is abandoned. + ensureActive() + + val version = downloadedVersionName(tempFile) + val safeVersion = version.replace(Regex("[^A-Za-z0-9._-]"), "_") + val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk" + val finalFile = File(cacheDir, finalFileName) + // Package parsing above is blocking. A cancellation arriving while it runs + // must not promote the verified temporary file into the shareable slot. + ensureActive() + replaceFileSafely(tempFile, finalFile) + progressFile.delete() + + saveMetadata( + version = version, + size = finalFile.length(), + fileName = finalFileName, + source = ApkSource.DOWNLOADED, + variant = ShareableApkVariant.UNIVERSAL, + downloadSourceId = source.id + ) + cleanupOldApks(except = finalFile) + Log.d(TAG, "Universal APK downloaded successfully from ${source.displayName}") + return@withContext Result.success(finalFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val failure = e.asDownloadException(source) + failures += failure + Log.w(TAG, "${source.displayName} download failed", failure) + if (index < sources.lastIndex) { + Log.i(TAG, "Trying the next configured APK source") + } + } + } + + Result.failure(combineSourceFailures(failures)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error downloading APK", e) + Result.failure(e) + } + } + + private fun sourcesWithResumeFirst(): List { + val tempFile = File(cacheDir, TEMP_FILE_NAME) + val resume = loadResumeInfo() + if (!tempFile.exists() || resume == null) return downloadSources + + val resumedSource = downloadSources.firstOrNull { it.id == resume.sourceId } + if (resumedSource == null) { + clearPartialDownload() + return downloadSources + } + return listOf(resumedSource) + downloadSources.filterNot { it.id == resumedSource.id } + } + + private suspend fun downloadFromSource( + source: ApkDownloadSource, + progressCallback: ((Int) -> Unit)? + ): File { + val tempFile = File(cacheDir, TEMP_FILE_NAME) + var resume = loadResumeInfo() + if (resume?.sourceId != source.id) { + clearPartialDownload() + resume = null + } + + var existingBytes = if (resume != null && tempFile.exists()) tempFile.length() else 0L + if (resume != null && resume.expectedSize > 0L && existingBytes == resume.expectedSize) { + Log.d(TAG, "Partial file is complete; continuing with APK verification") + return tempFile + } + if (resume != null && resume.expectedSize > 0L && existingBytes > resume.expectedSize) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + val resumeUrl = resume?.endpointUrl?.takeIf { it in source.latestApkUrls } + if (resume != null && resumeUrl == null) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + val endpoints = listOfNotNull(resumeUrl) + + source.latestApkUrls.filterNot { it == resumeUrl } + var lastFailure: ApkDownloadException? = null + for ((index, endpointUrl) in endpoints.withIndex()) { + if (index > 0) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + // A Range request is only safe with a validator. Without If-Range, a + // newly published release could be appended to bytes from the old one. + if (existingBytes > 0L && resume?.validator == null) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + try { + executeDownloadRequest( + source = source, + endpointUrl = endpointUrl, + tempFile = tempFile, + existingBytes = existingBytes, + resume = resume, + progressCallback = progressCallback + ) + return tempFile + } catch (e: ApkDownloadException) { + lastFailure = e + val assetNameFallback = shouldTryNextSourceUrl( + error = e, + hasMoreUrls = index < endpoints.lastIndex + ) + if (!assetNameFallback) throw e + Log.i(TAG, "APK filename not found; trying ${source.displayName}'s fallback URL") + } + } + throw lastFailure + ?: ApkDownloadException( + message = "${source.id} has no usable APK URL.", + reason = ApkDownloadFailureReason.NoUsableUrl, + messageArgs = listOf(source.displayName), + retryable = false + ) + } + + private suspend fun executeDownloadRequest( + source: ApkDownloadSource, + endpointUrl: String, + tempFile: File, + existingBytes: Long, + resume: ResumeInfo?, + progressCallback: ((Int) -> Unit)? + ) { + val routedClient = downloadClient() + val rateLimitScope = "apk_asset_${source.id}" + val now = System.currentTimeMillis() + rateLimits.retryAtMillis(rateLimitScope, routedClient.route, now)?.let { deadline -> + throw rateLimits.blockedException(source, deadline) + } + + val request = Request.Builder() + // Always start from the configured source endpoint. If a release changed, + // If-Range makes the server return 200 and we overwrite the partial. + .url(endpointUrl) + .addHeader("User-Agent", "BitChat-Android") + .apply { + if (existingBytes > 0L) { + addHeader("Range", "bytes=$existingBytes-") + resume?.validator?.let { addHeader("If-Range", it) } + } + } + .build() + + downloadToTempFile( + call = routedClient.client.newCall(request), + source = source, + rateLimitScope = rateLimitScope, + route = routedClient.route, + endpointUrl = endpointUrl, + tempFile = tempFile, + existingBytes = existingBytes, + previousResume = resume, + progressCallback = progressCallback + ) + } + + /** + * Streams an HTTP response into [tempFile]. Cancellation cancels the OkHttp + * call, and resume metadata is committed before bytes are appended. + */ + private suspend fun downloadToTempFile( + call: Call, + source: ApkDownloadSource, + rateLimitScope: String, + route: OkHttpProvider.Route, + endpointUrl: String, + tempFile: File, + existingBytes: Long, + previousResume: ResumeInfo?, + progressCallback: ((Int) -> Unit)? + ) = suspendCancellableCoroutine { continuation -> + fun completeSuccessfully() { + if (continuation.isActive) continuation.resumeWith(Result.success(Unit)) + } + + fun completeWithError(error: Throwable) { + if (continuation.isActive) continuation.resumeWith(Result.failure(error)) + } + + continuation.invokeOnCancellation { call.cancel() } + + try { + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + completeWithError( + ApkDownloadException( + message = "${source.id} could not be reached" + + (e.message?.let { ": $it" } ?: "."), + reason = ApkDownloadFailureReason.Unreachable, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + cause = e + ) + ) + } + + override fun onResponse(call: Call, response: Response) { + try { + response.use { + if (!response.request.url.isHttps) { + throw ApkDownloadException( + message = "${source.id} redirected to an insecure URL.", + reason = ApkDownloadFailureReason.InsecureRedirect, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + if (response.code == 416) { + val total = parseUnsatisfiedContentRangeTotal( + response.header("Content-Range") + ) + if (total != null && total == existingBytes && tempFile.length() == total) { + completeSuccessfully() + return + } + clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} rejected the saved download position.", + reason = ApkDownloadFailureReason.ResumeRejected, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + httpCode = response.code + ) + } + if (!response.isSuccessful) { + val failure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = response.code, + responseMessage = response.message, + retryAfter = response.header("Retry-After"), + rateLimitRemaining = response.header("X-RateLimit-Remaining"), + rateLimitResetEpochSeconds = + response.header("X-RateLimit-Reset") + ) + if (failure.reason == ApkDownloadFailureReason.RateLimited) { + val now = System.currentTimeMillis() + val deadline = rateLimits.recordRateLimit( + rateLimitScope, + route, + failure.retryAtMillis, + now + ) + throw rateLimits.blockedException(source, deadline) + } + throw failure + } + + rateLimits.clear(rateLimitScope, route) + + val body = response.body + val range = if (response.code == 206) { + parseContentRange(response.header("Content-Range")) + ?: throw invalidResumeResponse(source, tempFile) + } else { + null + } + if (range != null && range.start != existingBytes) { + throw invalidResumeResponse(source, tempFile) + } + + val append = range != null + val resumedBytes = if (append) existingBytes else 0L + val expectedSize = range?.total + ?: body.contentLength().takeIf { it >= 0L }?.let { length -> + resumedBytes + length + } + ?: previousResume?.expectedSize?.takeIf { append } + ?: 0L + if (expectedSize > 0L) { + checkDiskSpace((expectedSize - resumedBytes).coerceAtLeast(0L)) + } + + val validator = response.header("ETag") + ?: response.header("Last-Modified") + ?: previousResume?.validator?.takeIf { append } + + // A server may ignore Range and return a replacement 200 response. + // Remove the old bytes before recording the new validator. If the + // process dies at any later point, old and new release bytes cannot be + // combined on the next resume. + prepareApkTempFileForResponse(tempFile, append) + if (validator != null) { + saveResumeInfo( + ResumeInfo( + sourceId = source.id, + endpointUrl = endpointUrl, + expectedSize = expectedSize, + validator = validator + ) + ) + } else { + progressFile.delete() + } + + if (resumedBytes > 0L && expectedSize > 0L) { + progressCallback?.invoke( + ((resumedBytes * 100L) / expectedSize).toInt() + ) + } + + body.byteStream().use { input -> + // Non-resume responses were already truncated above; always append + // after resume metadata is safely committed. + FileOutputStream(tempFile, true).use { output -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead = resumedBytes + var lastProgress = if (expectedSize > 0L) { + ((resumedBytes * 100L) / expectedSize).toInt() + } else { + 0 + } + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + if (expectedSize > 0L) { + val progress = ( + (totalBytesRead * 100L) / expectedSize + ).toInt().coerceIn(0, 100) + if (progress != lastProgress) { + lastProgress = progress + progressCallback?.invoke(progress) + } + } + } + } + } + + if (expectedSize > 0L && tempFile.length() != expectedSize) { + if (tempFile.length() > expectedSize) clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} download ended before all bytes arrived.", + reason = ApkDownloadFailureReason.Incomplete, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id + ) + } + } + completeSuccessfully() + } catch (e: Exception) { + completeWithError(e) + } + } + }) + } catch (e: Exception) { + completeWithError(e) + } + } + + private fun invalidResumeResponse( + source: ApkDownloadSource, + tempFile: File + ): ApkDownloadException { + tempFile.delete() + progressFile.delete() + return ApkDownloadException( + message = "${source.id} returned an invalid resume response.", + reason = ApkDownloadFailureReason.InvalidResume, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id + ) + } + + private fun validateDownloadedApk(tempFile: File, source: ApkDownloadSource) { + if (!verifyApkSignature(tempFile)) { + clearPartialDownload() + throw ApkDownloadException( + message = "APK from ${source.id} is not signed by a trusted BitChat release key.", + reason = ApkDownloadFailureReason.UntrustedKey, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + if (!DistributionInfoProvider.isUniversalApk(tempFile)) { + clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} returned an architecture-specific APK.", + reason = ApkDownloadFailureReason.NotUniversal, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + } + + private fun downloadedVersionName(apkFile: File): String { + val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + ?: invalidDownloadedApk(ApkDownloadFailureReason.ApkUnreadable, "unreadable APK") + if (packageInfo.packageName != context.packageName) { + invalidDownloadedApk(ApkDownloadFailureReason.NotBitchat, "wrong package") + } + return packageInfo.versionName + ?.takeIf { it.isNotBlank() } + ?: invalidDownloadedApk(ApkDownloadFailureReason.NoVersion, "no version name") + } + + private fun invalidDownloadedApk( + reason: ApkDownloadFailureReason, + logReason: String + ): Nothing { + clearPartialDownload() + throw ApkDownloadException( + message = "Downloaded APK rejected: $logReason", + reason = reason, + retryable = false + ) + } + + private fun Exception.asDownloadException(source: ApkDownloadSource): ApkDownloadException { + if (this is ApkDownloadException) return this + return ApkDownloadException( + message = "${source.id} download failed" + (message?.let { ": $it" } ?: "."), + reason = ApkDownloadFailureReason.SourceFailed, + messageArgs = listOf(source.displayName), + retryable = this is IOException, + sourceId = source.id, + cause = this + ) + } + + private fun combineSourceFailures( + failures: List + ): ApkDownloadException { + if (failures.size == 1) return failures.single() + if (failures.isEmpty()) { + return ApkDownloadException( + message = "APK download failed with no recorded source failure.", + reason = ApkDownloadFailureReason.Generic, + retryable = false + ) + } + // The per-source detail stays in the log line. Concatenating each source's sentence would + // mean re-assembling localized text here, where there is no Context to resolve it with. + return ApkDownloadException( + message = "All configured APK sources failed: " + + failures.joinToString(" • ") { it.message ?: "Unknown error" }, + reason = ApkDownloadFailureReason.AllSourcesFailed, + retryable = failures.any { it.retryable }, + cause = failures.last() + ) + } + + private fun clearPartialDownload() { + File(cacheDir, TEMP_FILE_NAME).delete() + progressFile.delete() + } + + /** + * Cache the APK this process was installed from when it is a standalone + * universal or ARM64 artifact. A base APK from a split install is incomplete. + */ + private fun cacheInstalledApkIfPreferred(): ApkInfo? { + return try { + val applicationInfo = context.applicationInfo + if (!applicationInfo.splitSourceDirs.isNullOrEmpty()) { + return null + } + + val installedApk = File(applicationInfo.sourceDir) + if (!installedApk.isFile || installedApk.length() <= 0L) { + return null + } + val installedVariant = DistributionInfoProvider.shareableApkVariant(installedApk) + if (installedVariant == null) { + Log.d(TAG, "Installed APK is not a supported sharing variant") + return null + } + + val installedVersion = installedVersionName() + val cachedInfo = getCachedApkInfo() + + // Downloading the universal release is an explicit compatibility + // choice. Keep it even when the running ARM64 build is newer; the + // user can delete it from the UI to return to the local artifact. + if (installedVariant == ShareableApkVariant.ARM64 && + cachedInfo?.source == ApkSource.DOWNLOADED && + cachedInfo.variant == ShareableApkVariant.UNIVERSAL + ) { + return cachedInfo + } + + // Keep an already cached artifact if it is the same version or + // newer. Otherwise prefer the running build so sharing cannot + // silently downgrade recipients to an older downloadable release. + if (cachedInfo != null && + !AppVersion.isNewer(cachedInfo.version, installedVersion) + ) { + return cachedInfo + } + + checkDiskSpace(installedApk.length()) + val safeVersion = installedVersion.replace(Regex("[^A-Za-z0-9._-]"), "_") + val variantSuffix = when (installedVariant) { + ShareableApkVariant.UNIVERSAL -> "" + ShareableApkVariant.ARM64 -> "-arm64-v8a" + } + val finalFileName = "$APK_FILE_PREFIX$safeVersion$variantSuffix.apk" + val finalFile = File(cacheDir, finalFileName) + val pendingFile = File(cacheDir, "$finalFileName.new") + + installedApk.inputStream().use { input -> + FileOutputStream(pendingFile).use { output -> + input.copyTo(output, BUFFER_SIZE) + } + } + replaceFileSafely(pendingFile, finalFile) + + saveMetadata( + version = installedVersion, + size = finalFile.length(), + fileName = finalFileName, + source = ApkSource.INSTALLED, + variant = installedVariant, + downloadSourceId = null + ) + cleanupOldApks(except = finalFile) + + Log.d(TAG, "Cached running standalone APK for offline sharing") + getCachedApkInfo() + } catch (e: Exception) { + Log.w(TAG, "Running APK cannot be used as a standalone sharing artifact", e) + null + } + } + + private fun installedVersionName(): String { + return context.packageManager + .getPackageInfo(context.packageName, 0) + .versionName + ?.takeIf { it.isNotBlank() } + ?: BuildConfig.VERSION_NAME + } + + /** + * Verify the downloaded APK against either the running app's signing lineage + * or the pinned release certificate. The latter supports Play installs when + * downloadable artifacts use a separate, explicitly trusted release key. + */ + private fun verifyApkSignature(apkFile: File): Boolean { + return try { + val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, signingFlags()) + ?: run { + Log.e(TAG, "Could not parse APK for signature verification") + return false + } + val apkCerts = signatureDigests(packageInfo) + if (apkCerts.isEmpty()) { + Log.e(TAG, "No signatures found in downloaded APK") + return false + } + + val ownCerts = signatureDigests( + context.packageManager.getPackageInfo(context.packageName, signingFlags()) + ) + // Every mirror must serve the same official release-signed APK. + // The BuildConfig field keeps its historical name for configuration compatibility. + val pinnedReleaseCert = normalizeCertificateDigest( + BuildConfig.GITHUB_RELEASE_CERT_SHA256 + ) + val trustedCerts = ownCerts + listOfNotNull(pinnedReleaseCert) + + // Debug builds may use a different local signing key, but still + // require the downloaded artifact itself to be signed. Production + // builds must match either this installation's signing lineage or + // the explicitly pinned release certificate. + if (BuildConfig.DEBUG && pinnedReleaseCert == null) { + Log.w(TAG, "Debug build has no pinned release certificate; accepting signed APK") + return true + } + + if (trustedCerts.isEmpty()) { + Log.e(TAG, "No trusted APK signing certificates are configured") + return false + } + + val matches = apkCerts.intersect(trustedCerts).isNotEmpty() + if (!matches) { + Log.e(TAG, "Signature mismatch!") + Log.e(TAG, "Trusted cert(s): $trustedCerts") + Log.e(TAG, "APK cert(s): $apkCerts") + } + matches + } catch (e: Exception) { + Log.e(TAG, "Error verifying APK signature", e) + false + } + } + + private fun signingFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_SIGNATURES + } + } + + private fun signatureDigests(packageInfo: android.content.pm.PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = packageInfo.signingInfo ?: return emptySet() + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + packageInfo.signatures + } + if (signatures.isNullOrEmpty()) return emptySet() + + val digest = MessageDigest.getInstance("SHA-256") + return signatures.map { sig -> + digest.digest(sig.toByteArray()).joinToString("") { "%02x".format(it) } + }.toSet() + } + + private fun normalizeCertificateDigest(value: String): String? { + return value + .replace(":", "") + .trim() + .lowercase() + .takeIf { it.matches(Regex("[a-f0-9]{64}")) } + } + + /** + * Delete the cached universal APK. + */ + fun deleteCachedApk(): Boolean { + return try { + val info = getCachedApkInfo() + if (info != null) { + info.file.delete() + metadataFile.delete() + progressFile.delete() + Log.d(TAG, "Deleted cached APK: ${info.version}") + true + } else { + Log.w(TAG, "No cached APK to delete") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error deleting cached APK", e) + false + } + } + + /** + * Clean up old APK files (keep only the current one). + */ + private fun cleanupOldApks(except: File) { + try { + cacheDir.listFiles()?.forEach { file -> + if (file != except && + file.name.startsWith(APK_FILE_PREFIX) && + file.name.endsWith(".apk") + ) { + file.delete() + Log.d(TAG, "Cleaned up old APK: ${file.name}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up old APKs", e) + } + } + + /** + * Save metadata about the downloaded APK. + */ + private fun saveMetadata( + version: String, + size: Long, + fileName: String, + source: ApkSource, + variant: ShareableApkVariant, + downloadSourceId: String? + ) { + val json = JSONObject().apply { + put("version", version) + put("downloadDate", System.currentTimeMillis()) + put("size", size) + put("fileName", fileName) + put("source", source.name) + put("variant", variant.name) + downloadSourceId?.let { put("downloadSourceId", it) } + } + + val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new") + pendingMetadata.writeText(json.toString()) + replaceFileSafely(pendingMetadata, metadataFile) + Log.d(TAG, "Saved metadata: $version") + } + + private fun saveResumeInfo(info: ResumeInfo) { + try { + val json = JSONObject().apply { + put("sourceId", info.sourceId) + put("endpointUrl", info.endpointUrl) + put("expectedSize", info.expectedSize) + info.validator?.let { put("validator", it) } + } + progressFile.writeText(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "Error saving resume info", e) + } + } + + private fun loadResumeInfo(): ResumeInfo? { + return try { + if (!progressFile.exists()) return null + val json = JSONObject(progressFile.readText()) + val endpointUrl = json.optString("endpointUrl") + .ifBlank { json.optString("url") } + if (endpointUrl.isBlank()) return null + ResumeInfo( + sourceId = json.optString("sourceId") + .ifBlank { DefaultApkDownloadSources.GITHUB_ID }, + endpointUrl = endpointUrl, + expectedSize = json.optLong("expectedSize", 0L), + validator = json.optString("validator").takeIf { it.isNotBlank() } + ) + } catch (e: Exception) { + Log.e(TAG, "Error loading resume info", e) + null + } + } + + private data class ResumeInfo( + val sourceId: String, + val endpointUrl: String, + val expectedSize: Long, + val validator: String? + ) + + /** + * Commit [source] to [target] without removing a valid target first. + * Both files live in the same cache directory, so this is a rename, not a + * copy — no extra disk space is needed and ATOMIC_MOVE either fully + * succeeds or leaves both files intact. + */ + private fun replaceFileSafely(source: File, target: File) { + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } + + /** + * Information about a cached APK. + */ + data class ApkInfo( + val version: String, + val downloadDate: Long, + val size: Long, + val file: File, + val source: ApkSource, + val variant: ShareableApkVariant, + val downloadSourceId: String? + ) + + enum class ApkSource { + INSTALLED, + DOWNLOADED + } +} diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt new file mode 100644 index 00000000..6d482c34 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -0,0 +1,112 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.work.Constraints +import androidx.work.BackoffPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.concurrent.TimeUnit + +/** + * WorkManager-backed implementation of [ApkDownloader]. + * Downloads survive app backgrounding, process death, and device reboots. + */ +class WorkManagerApkDownloader(context: Context) : ApkDownloader { + + private val appContext = context.applicationContext + private val workManager = WorkManager.getInstance(appContext) + private val apkManager = UniversalApkManager(appContext) + + override val downloadState: Flow = + workManager.getWorkInfosForUniqueWorkFlow(ApkDownloadWorker.WORK_NAME) + .map { workInfos -> mapWorkInfoToState(workInfos.firstOrNull()) } + + override fun startDownload() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15, + TimeUnit.SECONDS + ) + .addTag(ApkDownloadWorker.TAG) + .build() + + workManager.enqueueUniqueWork( + ApkDownloadWorker.WORK_NAME, + ExistingWorkPolicy.KEEP, + request + ) + } + + override fun cancelDownload() { + workManager.cancelUniqueWork(ApkDownloadWorker.WORK_NAME) + } + + private fun mapWorkInfoToState(workInfo: WorkInfo?): ApkDownloader.DownloadState { + if (workInfo == null) return ApkDownloader.DownloadState.Idle + + return when (workInfo.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED -> { + // ENQUEUED covers two different waits. A non-zero attempt count means the work + // already ran and failed, so this is the retry backoff rather than a missing + // network — saying "waiting for network" there would be false while online. + val partial = apkManager.getPartialDownloadProgress() + ApkDownloader.DownloadState.Downloading( + partial ?: 0, + queuedPhase(workInfo.runAttemptCount) + ) + } + WorkInfo.State.RUNNING -> { + val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0) + val phase = ApkDownloader.DownloadPhase.fromKey( + workInfo.progress.getString(ApkDownloadWorker.KEY_PHASE) + ) + ApkDownloader.DownloadState.Downloading(progress, phase) + } + WorkInfo.State.SUCCEEDED -> { + val version = workInfo.outputData.getString(ApkDownloadWorker.KEY_VERSION) ?: "" + val sizeMB = workInfo.outputData.getInt(ApkDownloadWorker.KEY_SIZE_MB, 0) + ApkDownloader.DownloadState.Success(version, sizeMB) + } + WorkInfo.State.FAILED -> { + // Tolerates a missing or retired reason from an older build's record. + val reason = ApkDownloadFailureReason.fromKey( + workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR_REASON) + ) + val args = workInfo.outputData + .getStringArray(ApkDownloadWorker.KEY_ERROR_ARGS) + ?.toList() + .orEmpty() + val resumable = workInfo.outputData.getInt(ApkDownloadWorker.KEY_RESUMABLE_PERCENT, -1) + ApkDownloader.DownloadState.Failed( + reason = reason, + messageArgs = args, + resumablePercent = if (resumable >= 0) resumable else null + ) + } + WorkInfo.State.CANCELLED -> { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkDownloader.DownloadState.Failed( + reason = ApkDownloadFailureReason.Cancelled, + messageArgs = emptyList(), + resumablePercent = partial + ) + } else { + ApkDownloader.DownloadState.Idle + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/utils/DeviceUtils.kt b/app/src/main/java/com/bitchat/android/utils/DeviceUtils.kt new file mode 100644 index 00000000..c4054a70 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/utils/DeviceUtils.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.utils + +import android.content.Context +import android.content.res.Configuration +import android.util.DisplayMetrics +import android.view.WindowManager +import androidx.core.content.getSystemService +import kotlin.math.sqrt + +object DeviceUtils { + + /** + * Determines if the current device is a tablet based on screen size and density. + * Uses multiple criteria to accurately detect tablets vs phones. + */ + fun isTablet(context: Context): Boolean { + val windowManager = context.getSystemService() + val displayMetrics = DisplayMetrics() + windowManager?.defaultDisplay?.getMetrics(displayMetrics) + + // Calculate screen size in inches + val widthInches = displayMetrics.widthPixels / displayMetrics.xdpi + val heightInches = displayMetrics.heightPixels / displayMetrics.ydpi + val diagonalInches = sqrt((widthInches * widthInches) + (heightInches * heightInches)) + + // Check if device has tablet configuration + val configuration = context.resources.configuration + val isLargeScreen = (configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE + val isXLargeScreen = (configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE + + // A device is considered a tablet if: + // 1. Screen diagonal is 7 inches or larger, OR + // 2. Configuration indicates large or xlarge screen, OR + // 3. Smallest width is 600dp or more (sw600dp) + val smallestWidthDp = context.resources.configuration.smallestScreenWidthDp + + return diagonalInches >= 7.0 || isLargeScreen || isXLargeScreen || smallestWidthDp >= 600 + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt new file mode 100644 index 00000000..f5572e17 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt @@ -0,0 +1,22 @@ +package com.bitchat.android.wifiaware + +/** Resolves a packet to the exact still-active ingress link that delivered it. */ +internal object IngressLinkPolicy { + data class Link( + val relayAddress: String, + val transport: T + ) + + fun resolve( + ingressLinkID: String?, + relayAddress: String?, + links: Map>, + currentTransportForRelay: (String) -> T? + ): Link? { + val linkID = ingressLinkID ?: return null + val relayAddress = relayAddress ?: return null + val link = links[linkID] ?: return null + if (link.relayAddress != relayAddress) return null + return link.takeIf { currentTransportForRelay(relayAddress) === it.transport } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt new file mode 100644 index 00000000..9688f04f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt @@ -0,0 +1,98 @@ +package com.bitchat.android.wifiaware + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.Socket +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import android.util.Log + +/** + * A synchronized wrapper around a raw Socket that implements a framed protocol: + * [4 bytes length][N bytes payload] + */ +class SyncedSocket( + val rawSocket: Socket, + readTimeoutMs: Int = DEFAULT_READ_TIMEOUT_MS +) { + private val TAG = "SyncedSocket" + private val writeLock = ReentrantLock() + private val readLock = ReentrantLock() + + private val inputStream: DataInputStream + private val outputStream: DataOutputStream + + companion object { + // Critical-background peers send at most every 30s. Tolerate three missed frames so a + // faster local profile cannot drop a healthy, slower remote peer merely for being idle. + const val DEFAULT_READ_TIMEOUT_MS = 90_000 + } + + init { + // A read timeout converts dead/half-open connections into a SocketTimeoutException + // (an IOException) so read() returns null and the peer is cleaned up. + try { rawSocket.soTimeout = readTimeoutMs } catch (_: Exception) {} + // We wrap streams to create DataInput/Output helpers + inputStream = DataInputStream(rawSocket.getInputStream()) + outputStream = DataOutputStream(rawSocket.getOutputStream()) + } + + /** + * Writes a framed message to the socket. + * Thread-safe. + */ + fun write(data: ByteArray) { + writeLock.withLock { + Log.v(TAG, "Writing frame of size: ${data.size}") + outputStream.writeInt(data.size) + if (data.isNotEmpty()) { + outputStream.write(data) + } + outputStream.flush() + } + } + + /** + * Reads a framed message from the socket. + * Blocks until a full frame is available. + * Returns null if socket is closed or EOF. + * Returns empty byte array for keep-alive (0 length frame). + */ + fun read(): ByteArray? { + readLock.withLock { + try { + // Read length prefix + val length = try { + inputStream.readInt() + } catch (e: java.io.EOFException) { + return null + } + Log.v(TAG, "Reading frame of size: $length") + + if (length < 0) throw IOException("Negative frame length: $length") + if (length > 64 * 1024) throw IOException("Frame length exceeds 64KB limit: $length") + + if (length == 0) { + return ByteArray(0) + } + + val buf = ByteArray(length) + inputStream.readFully(buf) + return buf + } catch (e: IOException) { + Log.e(TAG, "Socket read failed: ${e.message}") + // Socket closed or error + return null + } + } + } + + fun close() { + try { rawSocket.close() } catch (_: Exception) {} + } + + fun isClosed() = rawSocket.isClosed + fun isConnected() = rawSocket.isConnected + val inetAddress get() = rawSocket.inetAddress +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt new file mode 100644 index 00000000..2b5ab61f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -0,0 +1,277 @@ +package com.bitchat.android.wifiaware + +import android.net.ConnectivityManager +import android.util.Log +import com.bitchat.android.mesh.MeshConnectionTracker +import kotlinx.coroutines.CoroutineScope +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks Wi-Fi Aware connections and manages retry logic using the shared state machine. + */ +class WifiAwareConnectionTracker( + scope: CoroutineScope, + private val cm: ConnectivityManager +) : MeshConnectionTracker(scope, TAG) { + + companion object { + private const val TAG = "WifiAwareConnectionTracker" + } + + // Active resources per peer + val peerSockets = ConcurrentHashMap() + private val socketAliases = ConcurrentHashMap() + private val socketBindingLock = Any() + val serverSockets = ConcurrentHashMap() + val networkCallbacks = ConcurrentHashMap() + + override fun isConnected(id: String): Boolean { + // We consider it connected if we have a client socket to them + return getSocketForPeer(id) != null + } + + override fun disconnect(id: String) { + synchronized(socketBindingLock) { + Log.d(TAG, "Disconnecting peer $id") + val canonicalId = resolveCanonicalPeerId(id) + + // 1. Close client socket + peerSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } + } + socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId } + + // 2. Close server socket + serverSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") } + } + + // Ensure any pending/active network request is explicitly released + releaseNetworkRequest(canonicalId) + removePendingConnection(id) + removePendingConnection(canonicalId) + } + } + + fun releaseNetworkRequest(id: String) { + val canonicalId = resolveCanonicalPeerId(id) + if (!networkCallbacks.containsKey(canonicalId)) return + + // 3. Unregister network callback properly from ConnectivityManager + networkCallbacks.remove(canonicalId)?.let { + try { + Log.d(TAG, "Unregistering network callback for $canonicalId") + cm.unregisterNetworkCallback(it) + } catch (e: Exception) { Log.w(TAG, "Error unregistering callback for $canonicalId: ${e.message}") } + } + } + + override fun getConnectionCount(): Int = peerSockets.size + + /** + * Successfully established a client connection + */ + fun onClientConnected(peerId: String, socket: SyncedSocket) { + synchronized(socketBindingLock) { + val canonicalPeerId = resolveCanonicalPeerId(peerId) + // Close previous socket if one exists to prevent zombie readers + peerSockets[canonicalPeerId]?.let { + try { it.close() } catch (_: Exception) {} + } + peerSockets[canonicalPeerId] = socket + removePendingConnection(peerId) // Clear retry state on success + if (canonicalPeerId != peerId) removePendingConnection(canonicalPeerId) + } + } + + fun getSocketForPeer(peerId: String): SyncedSocket? { + val canonicalId = resolveCanonicalPeerId(peerId) + return peerSockets[canonicalId] + } + + fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId) + + fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String { + return synchronized(socketBindingLock) { + rebindPeerIdLocked(previousPeerId, resolvedPeerId, socket) + } + } + + /** + * Atomically require that [expectedSocket] is still the active provisional transport before + * rebinding it. This closes the gap where a replacement socket could land after ANNOUNCE + * validation but before mutation and the stale socket would become canonical. + */ + fun rebindPeerIdIfCurrent( + previousPeerId: String, + resolvedPeerId: String, + expectedSocket: SyncedSocket + ): Boolean = synchronized(socketBindingLock) { + val previousCanonical = resolveCanonicalPeerId(previousPeerId) + if (peerSockets[previousCanonical] !== expectedSocket) return@synchronized false + val resolvedCanonical = resolveCanonicalPeerId(resolvedPeerId) + val existingResolvedSocket = peerSockets[resolvedCanonical] + if (existingResolvedSocket != null && existingResolvedSocket !== expectedSocket) { + return@synchronized false + } + rebindPeerIdLocked(previousPeerId, resolvedPeerId, expectedSocket) + true + } + + private fun rebindPeerIdLocked( + previousPeerId: String, + resolvedPeerId: String, + socket: SyncedSocket + ): String { + if (previousPeerId == resolvedPeerId) { + peerSockets[resolvedPeerId] = socket + return resolvedPeerId + } + + val previousCanonical = resolveCanonicalPeerId(previousPeerId) + val existing = peerSockets[previousCanonical] + if (existing === socket) { + peerSockets.remove(previousCanonical) + } + + peerSockets[resolvedPeerId]?.let { current -> + if (current !== socket) { + try { current.close() } catch (_: Exception) { } + } + } + peerSockets[resolvedPeerId] = socket + serverSockets.remove(previousCanonical)?.let { serverSockets[resolvedPeerId] = it } + networkCallbacks.remove(previousCanonical)?.let { networkCallbacks[resolvedPeerId] = it } + socketAliases[previousPeerId] = resolvedPeerId + if (previousCanonical != previousPeerId) { + socketAliases[previousCanonical] = resolvedPeerId + } + removePendingConnection(previousPeerId) + removePendingConnection(resolvedPeerId) + + Log.i(TAG, "Rebound Wi-Fi Aware socket ${previousPeerId.take(8)} -> ${resolvedPeerId.take(8)}") + return resolvedPeerId + } + + private fun resolveCanonicalPeerId(peerId: String): String { + var current = peerId + val visited = mutableSetOf() + while (visited.add(current)) { + val next = socketAliases[current] ?: return current + current = next + } + return current + } + + fun addServerSocket(peerId: String, socket: ServerSocket) { + val canonicalId = resolveCanonicalPeerId(peerId) + serverSockets.put(canonicalId, socket)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing replaced server socket for $peerId: ${e.message}") } + } + } + + fun hasOpenServerSocket(peerId: String): Boolean { + val canonicalId = resolveCanonicalPeerId(peerId) + val socket = serverSockets[canonicalId] ?: return false + if (!socket.isClosed) return true + serverSockets.remove(canonicalId) + return false + } + + fun closeServerSocket(peerId: String) { + val canonicalId = resolveCanonicalPeerId(peerId) + serverSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $peerId: ${e.message}") } + } + } + + fun hasPendingDataPathRequest(exceptPeerId: String? = null): Boolean { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + return pendingDataPathPeerIds(exceptCanonical).isNotEmpty() + } + + fun pendingDataPathPeerIds(exceptPeerId: String? = null): Set { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + val pendingIds = linkedSetOf() + + pendingConnections.keys.forEach { peerId -> + val canonicalId = resolveCanonicalPeerId(peerId) + if (canonicalId != exceptCanonical && !isConnected(canonicalId)) { + pendingIds.add(canonicalId) + } + } + + networkCallbacks.keys.forEach { peerId -> + val canonicalId = resolveCanonicalPeerId(peerId) + if (canonicalId != exceptCanonical && !isConnected(canonicalId)) { + pendingIds.add(canonicalId) + } + } + + return pendingIds + } + + fun pendingServerDataPathPeerIds(exceptPeerId: String? = null): Set { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + return serverSockets.keys.map { resolveCanonicalPeerId(it) } + .filter { peerId -> + peerId != exceptCanonical && + !isConnected(peerId) && + networkCallbacks.containsKey(peerId) && + serverSockets[peerId]?.isClosed == false + } + .toSet() + } + + fun cancelPendingServerDataPaths(exceptPeerId: String? = null): Set { + val cancelled = pendingServerDataPathPeerIds(exceptPeerId) + cancelled.forEach { disconnect(it) } + return cancelled + } + + fun addNetworkCallback(peerId: String, callback: ConnectivityManager.NetworkCallback) { + val canonicalId = resolveCanonicalPeerId(peerId) + networkCallbacks.put(canonicalId, callback)?.let { + try { + Log.d(TAG, "Replacing network callback for $canonicalId") + cm.unregisterNetworkCallback(it) + } catch (e: Exception) { + Log.w(TAG, "Error unregistering replaced callback for $canonicalId: ${e.message}") + } + } + } + + /** + * Clean up all resources + */ + override fun stop() { + super.stop() + val allIds = peerSockets.keys + serverSockets.keys + networkCallbacks.keys + allIds.toSet().forEach { disconnect(it) } + } + + fun getDebugInfo(): String { + return buildString { + appendLine("Aware Connections: ${getConnectionCount()}") + peerSockets.keys.forEach { pid -> + appendLine(" - $pid (Socket)") + } + if (socketAliases.isNotEmpty()) { + appendLine("Socket aliases:") + socketAliases.forEach { (alias, canonical) -> + appendLine(" - $alias -> $canonical") + } + } + appendLine("Server Sockets: ${serverSockets.size}") + serverSockets.keys.forEach { pid -> + appendLine(" - $pid (Listening)") + } + appendLine("Pending Attempts: ${pendingConnections.size}") + pendingConnections.forEach { (pid, attempt) -> + appendLine(" - $pid: ${attempt.attempts} attempts") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt new file mode 100644 index 00000000..d5a694f1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -0,0 +1,414 @@ +package com.bitchat.android.wifiaware + +import android.content.Context +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import androidx.annotation.VisibleForTesting +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * WifiAwareController manages lifecycle and debug surfacing for the WifiAwareMeshService. + * It starts/stops the service based on debug preferences and exposes simple flows for UI. + */ +object WifiAwareController { + private const val TAG = "WifiAwareController" + private const val MAX_RESTART_ATTEMPTS = 15 + private const val RESTART_RETRY_DELAY_MS = 2_000L + + private var service: WifiAwareMeshService? = null + private var appContext: Context? = null + private val lifecycleLock = Any() + private var starting = false + private val restartInFlight = AtomicBoolean(false) + private val restartRequested = AtomicBoolean(false) + private var awareReceiverRegistered = false + private var lastBlockedReason: String? = null + + /** + * How many hotspot sessions currently need the radio. + * + * Wi-Fi Aware (NAN) and Wi-Fi Direct (P2P) cannot hold interfaces at the same time on + * common chipsets — the HAL fails to create the P2P iface and every createGroup is + * answered with BUSY. A non-zero count also blocks [startIfPossible], so a resume or + * mesh-service restart cannot bring Aware back while a hotspot is up. + * + * A count rather than a flag because teardown is asynchronous and sessions can + * overlap. Callers receive a once-releasable [HotspotLease], so one session cannot + * accidentally consume another session's hold. + */ + private val hotspotHolds = AtomicInteger(0) + + /** True while any hotspot session still needs the radio. */ + @VisibleForTesting + internal fun isHeldForHotspot(): Boolean = hotspotHolds.get() > 0 + + private fun heldForHotspot(): Boolean = isHeldForHotspot() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _enabled = MutableStateFlow(false) + val enabled: StateFlow = _enabled.asStateFlow() + + private val _supported = MutableStateFlow(false) + val supported: StateFlow = _supported.asStateFlow() + + private val _available = MutableStateFlow(false) + val available: StateFlow = _available.asStateFlow() + + private val _supportStatus = MutableStateFlow(null) + val supportStatus: StateFlow = _supportStatus.asStateFlow() + + private val _running = MutableStateFlow(false) + val running: StateFlow = _running.asStateFlow() + + // Simple debug surfacing + private val _connectedPeers = MutableStateFlow>(emptyMap()) // peerID -> ip + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() + + private val _knownPeers = MutableStateFlow>(emptyMap()) // peerID -> nickname + val knownPeers: StateFlow> = _knownPeers.asStateFlow() + + private val _discoveredPeers = MutableStateFlow>(emptySet()) + val discoveredPeers: StateFlow> = _discoveredPeers.asStateFlow() + + fun initialize(context: Context, enabledByDefault: Boolean) { + appContext = context.applicationContext + val status = refreshSupportStatus(appContext!!) + if (status.supported) { + registerAwareStateReceiver(appContext!!) + } else { + Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}") + } + setEnabled(enabledByDefault) + } + + internal fun publishDebugSnapshot( + connected: Map, + known: Map, + discovered: Set + ) { + _connectedPeers.value = connected + _knownPeers.value = known + _discoveredPeers.value = discovered + } + + private fun clearDebugSnapshot() { + publishDebugSnapshot(emptyMap(), emptyMap(), emptySet()) + } + + fun setEnabled(value: Boolean) { + _enabled.value = value + if (value) startIfPossible() else stop() + } + + /** + * A session-scoped claim on the Wi-Fi radio. + * + * Closing the same lease twice is a no-op; raw decrements are intentionally not + * exposed because lifecycle retries and duplicate teardown calls must not release a + * different hotspot session's hold. + */ + class HotspotLease internal constructor( + private val releaseAction: () -> Unit + ) : AutoCloseable { + private val released = AtomicBoolean(false) + + override fun close() { + if (released.compareAndSet(false, true)) { + releaseAction() + } + } + } + + /** + * Releases the Wi-Fi radio so a Wi-Fi Direct hotspot can create its P2P interface, + * and prevents Aware restarting until the returned lease is closed. + * + * Counted because a second share can start while the first is still cleaning up. + */ + fun acquireHotspotLease(): HotspotLease { + if (hotspotHolds.getAndIncrement() == 0) { + Log.i(TAG, "Holding Wi-Fi Aware down so the hotspot can use the radio") + stop() + } + return HotspotLease(::releaseHotspotLease) + } + + private fun releaseHotspotLease() { + var remaining: Int + while (true) { + val current = hotspotHolds.get() + if (current == 0) return + remaining = current - 1 + if (hotspotHolds.compareAndSet(current, remaining)) break + } + + if (remaining != 0) { + Log.d(TAG, "Hotspot finished, but $remaining other hold(s) remain") + return + } + + Log.i(TAG, "Hotspot finished; restoring Wi-Fi Aware if enabled") + restartIfStillEnabled() + } + + @VisibleForTesting + internal fun resetHotspotLeasesForTest() { + hotspotHolds.set(0) + } + + fun startIfPossible() { + val reusableService = synchronized(lifecycleLock) { + if (!_enabled.value) return + if (heldForHotspot()) { + Log.d(TAG, "Not starting Wi-Fi Aware: held down for the hotspot") + return + } + val existing = service + if (existing?.isRunning() == true) { + _running.value = true + return + } + if (starting) return + starting = true + existing + } + + val ctx = appContext ?: run { + synchronized(lifecycleLock) { starting = false } + return + } + + val status = refreshSupportStatus(ctx) + if (!status.supported) { + val reason = status.reason ?: "not supported" + Log.w(TAG, "Wi‑Fi Aware unsupported; not starting ($reason)") + addBlockedDebugMessage("unsupported:$reason", "Wi-Fi Aware not supported on this device ($reason)") + synchronized(lifecycleLock) { starting = false } + return + } + + val awareManager = WifiAwareSupport.getManager(ctx) + if (awareManager == null || !status.available) { + Log.w(TAG, "Wi-Fi Aware is not currently available; not starting") + addBlockedDebugMessage("unavailable", "Wi-Fi Aware is not available right now") + synchronized(lifecycleLock) { starting = false } + return + } + + // Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled + val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager + val locationEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lm?.isLocationEnabled == true + } else { + @Suppress("DEPRECATION") + lm?.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) == true || + lm?.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) == true + } + + if (!locationEnabled) { + Log.w(TAG, "Location services are disabled; Wi-Fi Aware cannot start.") + addBlockedDebugMessage("location-disabled", "Enable Location Services to start Wi-Fi Aware") + synchronized(lifecycleLock) { starting = false } + return + } + + // Android 13+: require NEARBY_WIFI_DEVICES runtime permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.NEARBY_WIFI_DEVICES) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!granted) { + Log.w(TAG, "Missing NEARBY_WIFI_DEVICES permission; not starting Wi‑Fi Aware") + addBlockedDebugMessage("missing-nearby-wifi", "Grant Nearby Wi-Fi Devices to start Wi-Fi Aware") + synchronized(lifecycleLock) { starting = false } + return + } + } + if (!_enabled.value || heldForHotspot()) { + synchronized(lifecycleLock) { starting = false } + return + } + try { + val startedService = reusableService ?: run { + Log.i(TAG, "Instantiating WifiAwareMeshService...") + WifiAwareMeshService(ctx) + } + startedService.startServices() + + // Test the hold inside the same lock that publishes the service, and that + // stop() takes. Testing it outside leaves a window where acquiring a lease + // sets the flag and stop() finds nothing published yet, and this block then + // publishes anyway — resurrecting NAN while the hotspot owns the radio. + // Ordering holds because acquireHotspotLease() increments before calling + // stop(): either we see the flag here, or stop() sees our published service. + val published = synchronized(lifecycleLock) { + val canPublish = !heldForHotspot() && startedService.isRunning() + if (canPublish) { + service = startedService + _running.value = true + } else { + if (service === startedService) service = null + _running.value = false + } + canPublish + } + + if (published) { + try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { } + clearBlockedDebugMessage() + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } catch (_: Exception) {} + } else { + // stopServices() can block, so keep it out of the lock. + val heldForHotspot = heldForHotspot() + if (heldForHotspot || reusableService == null) { + try { startedService.stopServices() } catch (_: Exception) { } + } + if (heldForHotspot) { + Log.i(TAG, "Abandoned Wi-Fi Aware start: hotspot claimed the radio") + } + val detail = if (heldForHotspot) "held down for the hotspot" else "did not start" + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware $detail")) } catch (_: Exception) {} + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to start WifiAwareMeshService", e) + _running.value = false + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware failed to start: ${e.message}")) } catch (_: Exception) {} + } finally { + synchronized(lifecycleLock) { starting = false } + } + } + + fun stop() { + val stopped = synchronized(lifecycleLock) { + val current = service + service = null + starting = false + _running.value = false + current + } + try { stopped?.stopServices() } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + clearDebugSnapshot() + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} + } + + internal fun onServiceStopped(stoppedService: WifiAwareMeshService) { + synchronized(lifecycleLock) { + if (service !== stoppedService) return + service = null + _running.value = false + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + clearDebugSnapshot() + } + } + + /** + * Schedules a restart of the Wi-Fi Aware transport. Concurrent requests are coalesced into + * a single in-flight loop that retries with backoff. This is important because a single fixed + * delay can land while the service is still tearing down (recoveryInProgress), in which case + * startServices() defers and we must try again rather than give up. + */ + internal fun restartIfStillEnabled(delayMs: Long = 0L) { + // Record the request before coalescing. A request that arrives while a loop is + // already running — the final lease closing during a loop that has been + // burning attempts against the hold — would otherwise be dropped, and the old + // loop would exhaust MAX_RESTART_ATTEMPTS without ever seeing the cleared hold, + // leaving Aware down despite the user's setting. + restartRequested.set(true) + + if (!restartInFlight.compareAndSet(false, true)) { + Log.d(TAG, "Restart already in flight; coalescing request") + return + } + scope.launch { + try { + if (delayMs > 0L) delay(delayMs) + do { + // Consume the request before working, so anything arriving during + // the attempts below schedules another pass. + restartRequested.set(false) + var attempt = 0 + while (_enabled.value && !_running.value && attempt < MAX_RESTART_ATTEMPTS) { + val ctx = appContext + if (ctx != null && !refreshSupportStatus(ctx).supported) break + startIfPossible() + if (_running.value) break + attempt++ + delay(RESTART_RETRY_DELAY_MS) + } + } while (restartRequested.get() && _enabled.value && !_running.value) + } finally { + restartInFlight.set(false) + } + + // A request landing between the loop exiting and the flag clearing finds + // restartInFlight still set and returns; pick it up here. Terminates because + // each pass consumes the flag before doing any work. + if (restartRequested.get() && _enabled.value && !_running.value) { + restartIfStillEnabled() + } + } + } + + /** + * Listens for system Wi-Fi Aware availability changes. Aware can flip off/on at runtime + * (Wi-Fi toggling, hotspot/SoftAP, location changes); without this we would only recover on + * an unrelated trigger. + */ + private fun registerAwareStateReceiver(ctx: Context) { + if (awareReceiverRegistered) return + if (!refreshSupportStatus(ctx).supported) return + try { + val filter = android.content.IntentFilter( + android.net.wifi.aware.WifiAwareManager.ACTION_WIFI_AWARE_STATE_CHANGED + ) + ctx.registerReceiver(object : android.content.BroadcastReceiver() { + override fun onReceive(c: Context?, intent: android.content.Intent?) { + val status = refreshSupportStatus(ctx) + Log.i(TAG, "Wi-Fi Aware availability changed: supported=${status.supported} available=${status.available} enabled=${_enabled.value} running=${_running.value}") + if (status.available) { + if (_enabled.value) restartIfStillEnabled(500) + } else if (_running.value) { + // Aware went away; tear down cleanly so we can re-attach when it returns. + // Note: this does not change the enabled preference. + stop() + } + } + }, filter) + awareReceiverRegistered = true + } catch (e: Exception) { + Log.w(TAG, "Failed to register Wi-Fi Aware state receiver: ${e.message}") + } + } + + private fun refreshSupportStatus(ctx: Context): WifiAwareSupport.Status { + val status = WifiAwareSupport.evaluate(ctx) + _supported.value = status.supported + _available.value = status.available + _supportStatus.value = status + return status + } + + private fun addBlockedDebugMessage(key: String, message: String) { + if (lastBlockedReason == key) return + lastBlockedReason = key + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + .addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage(message)) + } catch (_: Exception) { } + } + + private fun clearBlockedDebugMessage() { + lastBlockedReason = null + } + + fun getService(): WifiAwareMeshService? = service +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt new file mode 100644 index 00000000..066a8b82 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt @@ -0,0 +1,3 @@ +package com.bitchat.android.wifiaware + +typealias WifiAwareMeshDelegate = com.bitchat.android.mesh.MeshDelegate diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt new file mode 100644 index 00000000..02bfb2d3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -0,0 +1,1662 @@ +package com.bitchat.android.wifiaware + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.net.* +import android.net.wifi.aware.* +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.system.OsConstants +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy +import com.bitchat.android.mesh.FragmentingPacketSender +import com.bitchat.android.mesh.MeshCore +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.MeshTransport +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +import java.io.InterruptedIOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.IOException +import java.net.Inet6Address +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * WifiAware mesh service - LATEST + * + * This is now a coordinator that orchestrates the following components: + * - PeerManager: Peer lifecycle management + * - FragmentManager: Message fragmentation and reassembly + * - SecurityManager: Security, duplicate detection, encryption + * - StoreForwardManager: Offline message caching + * - MessageHandler: Message type processing and relay logic + * - PacketProcessor: Incoming packet routing + */ +class WifiAwareMeshService(private val context: Context) : MeshService, TransportBridgeService.TransportLayer { + + companion object { + private const val TAG = "WifiAwareMeshService" + private const val MAX_TTL: UByte = 7u + private const val SERVICE_NAME = "bitchat" + private const val PSK = "bitchat_secret" + // Network request / socket timeouts + private const val NETWORK_REQUEST_TIMEOUT_MS = 30_000 + private const val ACCEPT_TIMEOUT_MS = 30_000 + private const val CLIENT_CONNECT_TIMEOUT_MS = 7_000 + private const val CLIENT_SOCKET_READY_DELAY_MS = 750L + private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L + private const val CLIENT_SOCKET_ATTEMPTS = 3 + private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 + private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:" + } + + // Core crypto/services + private val encryptionService = EncryptionService(context) + + // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) + override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val powerManager = com.bitchat.android.mesh.PowerManager.getInstance(context.applicationContext) + private val wifiTransport = WifiAwareTransport() + private lateinit var meshCore: MeshCore + private lateinit var fragmentingSender: FragmentingPacketSender + + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext) + ) + + // Wi-Fi Aware transport + private val awareManager = context.getSystemService(WifiAwareManager::class.java) + @Volatile private var wifiAwareSession: WifiAwareSession? = null + @Volatile private var publishSession: PublishDiscoverySession? = null + @Volatile private var subscribeSession: SubscribeDiscoverySession? = null + private val listenerExec = Executors.newCachedThreadPool() + @Volatile private var isActive = false + @Volatile private var recoveryInProgress = false + private val sessionGeneration = AtomicInteger(0) + + // Delegate + override var delegate: WifiAwareMeshDelegate? = null + set(value) { + field = value + if (::meshCore.isInitialized) { + meshCore.delegate = value + meshCore.refreshPeerList() + } + } + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Transport state + private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) + private val ingressLinks = ConcurrentHashMap< + String, + IngressLinkPolicy.Link + >() + private val handleToPeerId = ConcurrentHashMap() // discovery mapping + private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time + // Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained + // from the publish session is NOT valid for subscribeSession.sendMessage(). Maintenance re-pings + // (subscriber -> publisher) must use a handle that originated from the subscribe session. + private val subscribeHandles = ConcurrentHashMap() // peerID -> latest subscribe handle + private val publishHandles = ConcurrentHashMap() // peerID -> latest publish handle + private val forcedServerPeers = ConcurrentHashMap.newKeySet() + private val forcedClientPeers = ConcurrentHashMap.newKeySet() + private val clientSocketFailures = ConcurrentHashMap() + private val lastDiscoveryActivityAt = AtomicLong(0L) + private val lastDiscoveryRefreshAt = AtomicLong(0L) + + fun isRunning(): Boolean = isActive + + init { + // Ensure BluetoothMeshService is initialized so we share its GossipSyncManager + // This avoids race conditions and ensures a single gossip source/delegate + com.bitchat.android.service.MeshServiceHolder.getOrCreate(context) + val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager + encryptionService.onSessionEstablished = { peerID -> + Log.d(TAG, "Wi-Fi Aware Noise session established with ${peerID.take(8)}") + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.onSessionEstablished(peerID) + } catch (_: Exception) { } + } + meshCore = MeshCore( + context = context.applicationContext, + scope = serviceScope, + transport = wifiTransport, + encryptionService = encryptionService, + myPeerID = myPeerID, + maxTtl = MAX_TTL, + sharedGossipManager = shared, + gossipConfigProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + }, + hooks = MeshCore.Hooks( + onMessageReceived = { message -> handleMessageReceived(message) }, + onAnnounceProcessed = { routed, _ -> + publishControllerDebugSnapshot() + routed.peerID?.let { pid -> + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + ?.let(::observeDirectIngressLink) + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) + } catch (_: Exception) { } + } + }, + announcementNicknameProvider = { + try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } + }, + leavePayloadProvider = { + (delegate?.getNickname() ?: myPeerID).toByteArray(Charsets.UTF_8) + } + ) + ) + fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG) + } + + private fun handleMessageReceived(message: BitchatMessage): Boolean { + // Match BLE admission semantics: a private message rejected during panic or as a + // duplicate must not create a notification after the conversation state was cleared. + if ( + !com.bitchat.android.services.IncomingMessageAdmission + .admitToAppState(message) + ) return false + + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { meshCore.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } + } + return true + } + + /** + * Broadcasts raw bytes to currently connected peer. + */ + private fun broadcastRaw(bytes: ByteArray): Boolean { + var accepted = false + connectionTracker.peerSockets.forEach { (pid, sock) -> + try { + sock.write(bytes) + accepted = true + } catch (e: IOException) { + Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") + } + } + return accepted + } + + // TransportLayer implementation + override fun send(packet: RoutedPacket) { + // Received from bridge (e.g. BLE) -> Send via Wi-Fi + // Direct injection prevents routing loops (bridge handles source check) + meshCore.sendFromBridge(packet) + } + + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + return meshCore.sendFromBridgeAndReport(packet) + } + + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + sendPacketToPeer(peerID, packet) + } + + /** + * Broadcasts routed packet to currently connected peers. + */ + private fun broadcastPacket(routed: RoutedPacket): Boolean { + val packet = routed.packet + if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { + val firstHop = packet.route!![0].toHexString() + if (sendRoutedPacketToPeer(firstHop, routed)) { + return true + } + } + + val recipientId = packet.recipientID?.toHexString() + if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { + if (sendRoutedPacketToPeer(recipientId, routed)) { + return true + } + } + + return fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single -> + broadcastSinglePacket(single) + } + } + + // Expose a public method so BLE can forward relays to Wi-Fi Aware + fun broadcastRoutedPacket(routed: RoutedPacket) { + broadcastPacket(routed) + } + + /** + * Send packet to connected peer. + */ + private fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return sendRoutedPacketToPeer(peerID, RoutedPacket(packet)) + } + + private fun sendRoutedPacketToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (connectionTracker.getSocketForPeer(peerID) == null) { + return false + } + return fragmentingSender.send(routed, "Wi-Fi Aware peer ${peerID.take(8)}") { single -> + sendSinglePacketToPeer(peerID, single.packet) + } + } + + private fun broadcastSinglePacket(routed: RoutedPacket): Boolean { + val data = routed.packet.toBinaryData() ?: return false + return broadcastRaw(data) + } + + private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + val data = packet.toBinaryData() ?: return false + val sock = connectionTracker.getSocketForPeer(peerID) + if (sock == null) { + Log.d(TAG, "TX: no socket for ${peerID.take(8)}") + return false + } + try { + sock.write(data) + return true + } catch (e: IOException) { + Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") + return false + } + } + + + + /** + * Starts Wi-Fi Aware services (publish + subscribe). + * + * Requires Wi-Fi state and location permissions. This method attaches to the + * Aware session and initializes both the publisher (server role) and subscriber + * (client role). + */ + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_WIFI_STATE, + Manifest.permission.CHANGE_WIFI_STATE + ]) + override fun startServices() { + if (isActive) return + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + Log.i(TAG, "Wi-Fi Aware transport disabled by debug settings; not starting") + return + } + val supportStatus = com.bitchat.android.wifiaware.WifiAwareSupport.evaluate(context) + if (!supportStatus.supported) { + Log.i(TAG, "Wi-Fi Aware unsupported on this device; not starting (${supportStatus.reason})") + return + } + if (!supportStatus.available) { + Log.i(TAG, "Wi-Fi Aware unavailable right now; not starting (${supportStatus.reason})") + return + } + if (recoveryInProgress) { + Log.i(TAG, "Wi-Fi Aware recovery cleanup still in progress; deferring start") + return + } + val manager = awareManager + if (manager == null || !manager.isAvailable) { + Log.w(TAG, "Wi-Fi Aware manager unavailable; not starting") + return + } + isActive = true + val startTime = System.currentTimeMillis() + lastDiscoveryActivityAt.set(startTime) + lastDiscoveryRefreshAt.set(startTime) + val generation = sessionGeneration.incrementAndGet() + Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") + + manager.attach(object : AttachCallback() { + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.NEARBY_WIFI_DEVICES + ]) + override fun onAttached(session: WifiAwareSession) { + if (!isCurrentSession(generation)) { + session.close() + return + } + wifiAwareSession = session + Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe") + + // PUBLISH (server role) + session.publish( + PublishConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray()) + .build(), + object : DiscoverySessionCallback() { + override fun onPublishStarted(pub: PublishDiscoverySession) { + if (!isCurrentSession(generation)) { + pub.close() + return + } + publishSession = pub + Log.d(TAG, "Wi-Fi Aware publish started") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Publish Started")) } catch (_: Exception) {} + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + if (!isCurrentSession(generation)) return + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + if (peerId.isNotBlank()) { + rememberDiscoveredPeer(peerId) + publishHandles[peerId] = peerHandle + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + offerServerPathIfAppropriate(peerId, peerHandle, "publish discovery") + } + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (!isCurrentSession(generation)) return + if (message.isEmpty()) return + val subscriberId = try { String(message) } catch (_: Exception) { "" } + if (subscriberId.startsWith(ROLE_REVERSAL_PREFIX)) { + val requesterId = subscriberId.removePrefix(ROLE_REVERSAL_PREFIX) + handleRoleReversalRequest(peerHandle, requesterId) + return + } + if (subscriberId == myPeerID) return + + handleToPeerId[peerHandle] = subscriberId + if (subscriberId.isNotBlank()) { + rememberDiscoveredPeer(subscriberId) + publishHandles[subscriberId] = peerHandle + } + handleSubscriberPing(publishSession!!, peerHandle) + } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + publishSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + Log.w(TAG, "Wi-Fi Aware publish session terminated (restart=$shouldRestart)") + handleUnexpectedStop(generation) + if (shouldRestart) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } + }, + Handler(Looper.getMainLooper()) + ) + + // SUBSCRIBE (client role) + session.subscribe( + SubscribeConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray(Charsets.UTF_8)) + .build(), + object : DiscoverySessionCallback() { + override fun onSubscribeStarted(sub: SubscribeDiscoverySession) { + if (!isCurrentSession(generation)) { + sub.close() + return + } + subscribeSession = sub + Log.d(TAG, "Wi-Fi Aware subscribe started") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Subscribe Started")) } catch (_: Exception) {} + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + if (!isCurrentSession(generation)) return + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + // This handle came from the subscribe session, so it is valid for + // subscribeSession.sendMessage() (used by maintenance reconnection). + if (peerId.isNotBlank()) subscribeHandles[peerId] = peerHandle + sendSubscribePing(peerId, peerHandle, "discovery") + if (peerId.isNotBlank()) rememberDiscoveredPeer(peerId) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (!isCurrentSession(generation)) return + if (message.isEmpty()) return + handleServerReady(peerHandle, message) + } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + subscribeSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + Log.w(TAG, "Wi-Fi Aware subscribe session terminated (restart=$shouldRestart)") + handleUnexpectedStop(generation) + if (shouldRestart) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } + }, + Handler(Looper.getMainLooper()) + ) + } + override fun onAttachFailed() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "Wi-Fi Aware attach failed") + handleUnexpectedStop(generation) + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } + } + + override fun onAwareSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "Wi-Fi Aware session terminated unexpectedly") + wifiAwareSession = null + val shouldRestart = com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } + } + }, Handler(Looper.getMainLooper())) + + // Register with cross-layer transport bridge + TransportBridgeService.register("WIFI", this) + + meshCore.startCore() + com.bitchat.android.service.MeshServiceHolder.startSharedGossip("WIFI") + startPeriodicConnectionMaintenance() + connectionTracker.start() + } + + /** + * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. + */ + override fun stopServices() { + val wasActive = isActive + isActive = false + sessionGeneration.incrementAndGet() + Log.i(TAG, "Stopping Wi-Fi Aware mesh") + + // Unregister from bridge + TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } + + if (wasActive) { + meshCore.sendLeaveAnnouncement() + } + + serviceScope.launch { + delay(200) + + meshCore.stopCore() + connectionTracker.stop() // Handles socket closing and callback unregistration + + publishSession?.close(); publishSession = null + subscribeSession?.close(); subscribeSession = null + wifiAwareSession?.close(); wifiAwareSession = null + + handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() + ingressLinks.clear() + + meshCore.shutdown() + + // Tear down listener threads; this instance is discarded after a full stop. + try { listenerExec.shutdownNow() } catch (_: Exception) { } + + com.bitchat.android.wifiaware.WifiAwareController.onServiceStopped(this@WifiAwareMeshService) + serviceScope.cancel() + } + } + + private fun isCurrentSession(generation: Int): Boolean { + return generation == sessionGeneration.get() && isActive + } + + private fun handleUnexpectedStop(generation: Int) { + if (generation != sessionGeneration.get()) return + if (!isActive) { + return + } + recoveryInProgress = true + isActive = false + TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } + val oldPublishSession = publishSession + val oldSubscribeSession = subscribeSession + val oldWifiAwareSession = wifiAwareSession + serviceScope.launch { + try { + try { meshCore.stopCore() } catch (_: Exception) { } + try { connectionTracker.stop() } catch (_: Exception) { } + try { oldPublishSession?.close() } catch (_: Exception) { } + try { oldSubscribeSession?.close() } catch (_: Exception) { } + try { oldWifiAwareSession?.close() } catch (_: Exception) { } + if (generation == sessionGeneration.get() && !isActive) { + if (publishSession === oldPublishSession) publishSession = null + if (subscribeSession === oldSubscribeSession) subscribeSession = null + if (wifiAwareSession === oldWifiAwareSession) wifiAwareSession = null + handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() + ingressLinks.clear() + } + } finally { + recoveryInProgress = false + // Recovery cleanup is done; nudge a restart now that startServices() will no + // longer be deferred by recoveryInProgress. The controller coalesces requests. + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(500) + } + } + } + } + + private fun rememberDiscoveredPeer(peerId: String) { + if (peerId.isBlank() || peerId == myPeerID) return + val now = System.currentTimeMillis() + discoveredTimestamps[peerId] = now + lastDiscoveryActivityAt.set(now) + publishControllerDebugSnapshot() + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun offerServerPathIfAppropriate(peerId: String, peerHandle: PeerHandle, reason: String) { + val pubSession = publishSession ?: return + if (peerId.isBlank() || peerId == myPeerID || !amIServerFor(peerId)) return + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) return + + handleSubscriberPing(pubSession, peerHandle) + } + + private fun refreshDiscoverySessions(reason: String, now: Long = System.currentTimeMillis()): Boolean { + if (!isActive || recoveryInProgress) return false + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false + + val lastRefresh = lastDiscoveryRefreshAt.get() + val minRefreshMs = powerManager.profile.value.wifiAware.discoverySessionRefreshMinMs + if ((now - lastRefresh) < minRefreshMs) return false + if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false + + Log.i(TAG, "Refreshing Wi-Fi Aware discovery sessions ($reason)") + handleUnexpectedStop(sessionGeneration.get()) + return true + } + + /** + * Periodic active maintenance: retries connections to discovered but unconnected peers. + */ + private fun startPeriodicConnectionMaintenance() { + serviceScope.launch { + while (isActive) { + try { + val schedule = powerManager.profile.value.wifiAware + delay(schedule.connectionMaintenanceMs) + if (!isActive) break + + val now = System.currentTimeMillis() + + // 0. Prune stale discovery entries. PeerHandles become invalid when the + // discovery sessions restart, so we must not keep pinging old handles forever. + val staleIds = discoveredTimestamps.filter { (id, ts) -> + (now - ts) >= schedule.discoveryStaleMs && !connectionTracker.isConnected(id) + }.keys.toSet() + if (staleIds.isNotEmpty()) { + staleIds.forEach { discoveredTimestamps.remove(it) } + handleToPeerId.entries.removeIf { it.value in staleIds } + staleIds.forEach { subscribeHandles.remove(it) } + staleIds.forEach { publishHandles.remove(it) } + publishControllerDebugSnapshot() + } + + // 1. Identify peers that are discovered (recently seen) but not currently connected + val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> + (now - ts) < schedule.discoveryStaleMs + }.keys + + // 2. Filter out those who are already connected + val disconnectedPeers = recentDiscovered.filter { peerId -> + !connectionTracker.isConnected(peerId) + } + + // 3. Attempt reconnection. Aware discovery is not always symmetrical: + // subscribe handles can disappear while publish handles still see the peer. + var attemptedReconnect = false + var missingUsableHandle = false + for (peerId in disconnectedPeers) { + if (amIServerFor(peerId)) { + val handle = publishHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + offerServerPathIfAppropriate(peerId, handle, "maintenance") + attemptedReconnect = true + } + continue + } + + // Use a subscribe-session-scoped handle. A publish-scoped handle would be + // invalid for subscribeSession.sendMessage() and silently fail. + val handle = subscribeHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + + // Check tracker policy + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + + sendSubscribePing(peerId, handle, "maintenance") + attemptedReconnect = true + } + + val noActiveDataPath = connectionTracker.getConnectionCount() == 0 && + !connectionTracker.hasPendingDataPathRequest() + if (noActiveDataPath) { + val idleFor = now - lastDiscoveryActivityAt.get() + when { + disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> { + refreshDiscoverySessions("missing peer handle", now) + } + recentDiscovered.isEmpty() && idleFor >= schedule.discoveryIdleRefreshMs -> { + refreshDiscoverySessions("idle discovery", now) + } + } + } + } catch (e: CancellationException) { + break + } catch (e: Exception) { + Log.e(TAG, "Error in connection maintenance: ${e.message}") + } + } + } + } + + private fun sendSubscribePing(peerId: String, peerHandle: PeerHandle, reason: String) { + if (peerId.isBlank()) return + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + try { + subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) + } catch (e: Exception) { + Log.d(TAG, "Failed to send $reason ping to ${peerId.take(8)}: ${e.message}") + } + } + + private fun requestRoleReversal(peerId: String, allowForcedClientOverride: Boolean = false) { + if (peerId.isBlank()) return + if (forcedClientPeers.contains(peerId) && !allowForcedClientOverride) return + forcedServerPeers.add(peerId) + forcedClientPeers.remove(peerId) + + val handle = subscribeHandles[peerId] + if (handle == null) { + Log.d(TAG, "CLIENT: role reversal queued for ${peerId.take(8)} until subscribe handle is available") + return + } + + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + val payload = "$ROLE_REVERSAL_PREFIX$myPeerID".toByteArray() + try { + subscribeSession?.sendMessage(handle, msgId, payload) + Log.d(TAG, "CLIENT: requested role reversal with ${peerId.take(8)}") + } catch (e: Exception) { + Log.w(TAG, "CLIENT: failed to request role reversal with ${peerId.take(8)}: ${e.message}") + } + } + + private fun shouldRequestRoleReversalAfterClientFailure(peerId: String): Boolean { + val failures = clientSocketFailures + .computeIfAbsent(peerId) { AtomicInteger(0) } + .incrementAndGet() + val shouldReverse = failures >= CLIENT_ROLE_REVERSAL_FAILURES + if (shouldReverse) { + clientSocketFailures.remove(peerId) + Log.d(TAG, "CLIENT: ${peerId.take(8)} failed $failures client socket attempts; requesting role reversal") + } + return shouldReverse + } + + private fun handleRoleReversalRequest(peerHandle: PeerHandle, requesterId: String) { + if (requesterId.isBlank() || requesterId == myPeerID) return + handleToPeerId[peerHandle] = requesterId + discoveredTimestamps[requesterId] = System.currentTimeMillis() + forcedClientPeers.add(requesterId) + forcedServerPeers.remove(requesterId) + Log.i(TAG, "Role reversal requested by ${requesterId.take(8)}; switching to client role") + + subscribeHandles[requesterId]?.let { handle -> + sendSubscribePing(requesterId, handle, "role-reversal") + } + } + + /** + * Handles subscriber ping: spawns a server socket and responds with connection info. + * + * @param pubSession The current publish discovery session + * @param peerHandle The handle for the peer that pinged us + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleSubscriberPing( + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + val peerId = handleToPeerId[peerHandle] ?: return + if (!amIServerFor(peerId)) return + + if (connectionTracker.isConnected(peerId)) { + return + } + if (connectionTracker.hasOpenServerSocket(peerId)) { + return + } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } + + val ss = ServerSocket() + try { + ss.reuseAddress = true + val anyIpv6 = Inet6Address.getByAddress(ByteArray(16)) + ss.bind(java.net.InetSocketAddress(anyIpv6, 0)) + } catch (e: Exception) { + Log.e(TAG, "Failed to bind server socket", e) + handleNetworkFailure(peerId) + return + } + + connectionTracker.addServerSocket(peerId, ss) + val port = ss.localPort + + val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) + .setPskPassphrase(PSK) + .setPort(port) + .setTransportProtocol(OsConstants.IPPROTO_TCP) + .build() + // Default capabilities include NET_CAPABILITY_NOT_VPN. + // Keeping defaults for hardware interface handle acquisition compatibility with global VPNs. + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val acceptStarted = AtomicBoolean(false) + + override fun onAvailable(network: Network) { + // Only accept once per network request + if (!acceptStarted.compareAndSet(false, true)) return + // Offload the blocking accept() off the callback thread so we never stall + // the (main-thread) ConnectivityManager callback dispatcher. + listenerExec.execute { + try { + try { ss.soTimeout = ACCEPT_TIMEOUT_MS } catch (_: Exception) {} + val client = ss.accept() + try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } + client.keepAlive = true + Log.i(TAG, "Connected to ${peerId.take(8)} (server)") + val synced = SyncedSocket(client) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() + // We only ever accept a single data socket per server request. Close the + // listening ServerSocket now so it can't block a future re-serve (its + // presence makes hasOpenServerSocket() true for the life of the process) + // and so we free the fd/port promptly. + connectionTracker.closeServerSocket(peerId) + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + if (!ss.isClosed && isActive) { + Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) + handleNetworkFailure(peerId) + } + } + } + } + + override fun onUnavailable() { + Log.e(TAG, "SERVER: failed to acquire Aware network for ${peerId.take(8)}") + handleNetworkFailure(peerId) + } + + override fun onLost(network: Network) { + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "Disconnected from ${peerId.take(8)} (server: network lost)") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + try { + // use requestNetwork with a timeout to trigger onUnavailable if it fails + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "SERVER: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } + + val readyId = (System.nanoTime() and 0x7fffffff).toInt() + val readyPayload = buildServerReadyPayload(port) + Handler(Looper.getMainLooper()).post { + try { + pubSession.sendMessage(peerHandle, readyId, readyPayload) + } catch (e: Exception) { + Log.e(TAG, "PUBLISH: failed to send server-ready to ${peerId.take(8)}", e) + } + } + } + + /** + * Sends periodic TCP and discovery keep-alive messages to maintain a subscriber connection. + * + * @param client Connected client socket + * @param peerId ID of the connected peer + */ + private fun handleSubscriberKeepAlive( + client: SyncedSocket, + peerId: String, + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + startProfiledKeepAlive(client, peerId) { msgId -> + pubSession.sendMessage(peerHandle, msgId, ByteArray(0)) + } + } + + private fun connectAwareClientSocket( + network: Network, + scopedAddr: Inet6Address, + port: Int, + peerId: String + ): Socket { + var lastFailure: IOException? = null + for (attempt in 1..CLIENT_SOCKET_ATTEMPTS) { + val delayMs = if (attempt == 1) CLIENT_SOCKET_READY_DELAY_MS else CLIENT_SOCKET_RETRY_DELAY_MS + if (delayMs > 0) { + try { + Thread.sleep(delayMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw InterruptedIOException("Interrupted before Wi-Fi Aware socket connect") + } + } + + var sock: Socket? = null + try { + sock = network.socketFactory.createSocket() + sock.tcpNoDelay = true + sock.keepAlive = true + sock.connect(java.net.InetSocketAddress(scopedAddr, port), CLIENT_CONNECT_TIMEOUT_MS) + return sock + } catch (e: IOException) { + lastFailure = e + try { sock?.close() } catch (_: Exception) { } + if (attempt < CLIENT_SOCKET_ATTEMPTS) { + Log.d(TAG, "CLIENT: socket attempt $attempt/$CLIENT_SOCKET_ATTEMPTS failed for ${peerId.take(8)}: ${e.message}") + } + } + } + + throw lastFailure ?: IOException("Wi-Fi Aware socket connect failed without an exception") + } + + private fun buildServerReadyPayload(port: Int): ByteArray { + val peerIdBytes = myPeerID.toByteArray(Charsets.UTF_8) + return ByteBuffer.allocate(Int.SIZE_BYTES + peerIdBytes.size) + .order(ByteOrder.BIG_ENDIAN) + .putInt(port) + .put(peerIdBytes) + .array() + } + + private fun peerIdFromServerReadyPayload(payload: ByteArray): String? { + if (payload.size <= Int.SIZE_BYTES) return null + val peerId = try { + String(payload.copyOfRange(Int.SIZE_BYTES, payload.size), Charsets.UTF_8).trim() + } catch (_: Exception) { + return null + } + return peerId.takeIf { id -> + id.length == 16 && id.all { ch -> ch in '0'..'9' || ch in 'a'..'f' || ch in 'A'..'F' } + }?.lowercase() + } + + private fun resolveServerReadyPeerId(peerHandle: PeerHandle, payload: ByteArray): String? { + val advertisedPeerId = peerIdFromServerReadyPayload(payload) + val mappedPeerId = handleToPeerId[peerHandle]?.takeIf { it.isNotBlank() } + val peerId = advertisedPeerId ?: mappedPeerId + if (peerId == null) { + return null + } + + handleToPeerId[peerHandle] = peerId + subscribeHandles[peerId] = peerHandle + rememberDiscoveredPeer(peerId) + return peerId + } + + /** + * Handles a "server ready" message from a publishing peer and initiates a client connection. + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleServerReady( + peerHandle: PeerHandle, + payload: ByteArray + ) { + if (payload.size < Int.SIZE_BYTES) { + return + } + + val peerId = resolveServerReadyPeerId(peerHandle, payload) ?: return + if (peerId == myPeerID) return + if (amIServerFor(peerId)) return + if (connectionTracker.peerSockets.containsKey(peerId)) { + return + } + val cancelledServerOffers = connectionTracker.cancelPendingServerDataPaths(peerId) + if (cancelledServerOffers.isNotEmpty()) { + val cancelled = cancelledServerOffers.joinToString(", ") { it.take(8) } + Log.d(TAG, "CLIENT: preempted pending server offer(s) for $cancelled to connect ${peerId.take(8)}") + } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } + + val port = ByteBuffer.wrap(payload, 0, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int + + val subSession = subscribeSession ?: run { + Log.d(TAG, "CLIENT: subscribe session missing for server-ready from ${peerId.take(8)}") + connectionTracker.removePendingConnection(peerId) + return + } + val spec = WifiAwareNetworkSpecifier.Builder(subSession, peerHandle) + .setPskPassphrase(PSK) + .build() + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val connectStarted = AtomicBoolean(false) + + override fun onAvailable(network: Network) { + // Do not bind process for Aware; use per-socket binding instead + } + + override fun onUnavailable() { + Log.e(TAG, "CLIENT: failed to acquire Aware network for ${peerId.take(8)}") + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) + } + + override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { + if (connectionTracker.peerSockets.containsKey(peerId)) return + val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return + val addr = info.peerIpv6Addr as? Inet6Address ?: return + val connectPort = if (info.port > 0) info.port else port + // onCapabilitiesChanged can fire multiple times; only connect once + if (!connectStarted.compareAndSet(false, true)) return + + val lp = cm.getLinkProperties(network) + val iface = lp?.interfaceName + + // Offload the blocking connect() off the callback thread. + listenerExec.execute { + try { + // Use scoped IPv6 if interface name is available + val scopedAddr = if (iface != null && addr.scopeId == 0) { + try { + Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) + } catch (e: Exception) { + addr + } + } else { + addr + } + + val sock = connectAwareClientSocket(network, scopedAddr, connectPort, peerId) + Log.i(TAG, "Connected to ${peerId.take(8)} (client)") + + val synced = SyncedSocket(sock) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() + clientSocketFailures.remove(peerId) + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleServerKeepAlive(synced, peerId, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe) + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) + } + } + } + override fun onLost(network: Network) { + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "Disconnected from ${peerId.take(8)} (client: network lost)") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + try { + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "CLIENT: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } + } + + /** + * Sends periodic TCP and discovery keep-alive messages for server connections. + */ + private fun handleServerKeepAlive( + sock: SyncedSocket, + peerId: String, + peerHandle: PeerHandle + ) { + startProfiledKeepAlive(sock, peerId) { msgId -> + subscribeSession?.sendMessage(peerHandle, msgId, ByteArray(0)) + } + } + + /** + * One coroutine per peer schedules both TCP and discovery deadlines. This avoids two + * independently waking jobs while still adapting after every send to the latest profile. + */ + private fun startProfiledKeepAlive( + socket: SyncedSocket, + peerId: String, + sendDiscovery: (Int) -> Unit + ) { + serviceScope.launch { + var discoveryMessageId = 0 + var nextTcpAt = 0L + var nextDiscoveryAt = 0L + while (connectionTracker.isConnected(peerId)) { + val now = android.os.SystemClock.elapsedRealtime() + val schedule = powerManager.profile.value.wifiAware + + if (now >= nextTcpAt) { + try { + socket.write(ByteArray(0)) + } catch (_: IOException) { + handlePeerDisconnection(peerId, socket) + break + } + nextTcpAt = now + schedule.tcpKeepAliveMs + } + + if (now >= nextDiscoveryAt) { + try { + sendDiscovery(discoveryMessageId++) + } catch (_: Exception) { + // The TCP data path remains usable; discovery maintenance will recover its + // session independently without dropping this idle connection. + } + nextDiscoveryAt = now + schedule.discoveryKeepAliveMs + } + + val nextWakeAt = minOf(nextTcpAt, nextDiscoveryAt) + delay((nextWakeAt - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(250L)) + } + } + } + + /** + * Determines whether this device should act as the server in a given peer relationship. + */ + private fun amIServerFor(peerId: String): Boolean = when { + forcedClientPeers.contains(peerId) -> false + forcedServerPeers.contains(peerId) -> true + else -> myPeerID < peerId + } + + /** + * Records a validated, non-relayed ANNOUNCE as a direct route. The exact-link check keeps stale + * socket readers from rebinding a replacement connection, but Noise remains peer-scoped and is + * not restarted or coupled to this routing observation. + */ + private fun observeDirectIngressLink( + observation: DirectLinkAnnouncementPolicy.Observation + ) { + val link = IngressLinkPolicy.resolve( + ingressLinkID = observation.ingressLinkID, + relayAddress = observation.relayAddress, + links = ingressLinks, + currentTransportForRelay = connectionTracker::getSocketForPeer + ) ?: run { + Log.d( + TAG, + "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: ingress link is stale" + ) + return + } + + val provisionalPeerId = link.relayAddress + val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId) + if (existingCanonical == observation.peerID) { + try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { } + return + } + if (existingCanonical != provisionalPeerId) { + Log.w( + TAG, + "Refusing Wi-Fi route change ${existingCanonical.take(8)} -> ${observation.peerID.take(8)} on existing alias" + ) + return + } + + if (!connectionTracker.rebindPeerIdIfCurrent( + provisionalPeerId, + observation.peerID, + link.transport + ) + ) { + Log.d( + TAG, + "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: provisional socket changed" + ) + return + } + handleToPeerId.forEach { (handle, peerId) -> + if (peerId == provisionalPeerId) handleToPeerId[handle] = observation.peerID + } + subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[observation.peerID] = it } + publishHandles.remove(provisionalPeerId)?.let { publishHandles[observation.peerID] = it } + val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis() + discoveredTimestamps[observation.peerID] = discoveredAt + + try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { } + try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { } + try { + meshCore.addOrUpdatePeer( + observation.peerID, + meshCore.getPeerNickname(observation.peerID) ?: observation.peerID + ) + } catch (_: Exception) { } + try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { } + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000) + } catch (_: Exception) { } + publishControllerDebugSnapshot() + + Log.i( + TAG, + "Observed direct Wi-Fi route ${provisionalPeerId.take(8)} -> ${observation.peerID.take(8)}" + ) + } + + /** + * Listens for incoming packets from a connected peer and dispatches them through + * the packet processor. + * + * @param socket Socket connected to the peer + * @param initialLogicalPeerId Temporary identifier before peer ID resolution + */ + private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { + val logicalPeerId = initialLogicalPeerId + val ingressLinkID = UUID.randomUUID().toString() + val ingressLink = IngressLinkPolicy.Link(logicalPeerId, socket) + ingressLinks[ingressLinkID] = ingressLink + while (isActive) { + val raw = socket.read() ?: break + + if (raw.isEmpty()) { + // Keep-alive (0 length frame) + continue + } + + val pkt = BitchatPacket.fromBinaryData(raw) ?: continue + + val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + + if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { + // Rebinding happens only after MeshCore validates and accepts this ANNOUNCE. + Log.d( + TAG, + "RX: Wi-Fi peer observation ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending ANNOUNCE validation" + ) + } + + // Route the packet: + // - peerID = Originator (who signed it) + // - relayAddress = Neighbor (who sent it to us over this socket) + meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId, ingressLinkID) + } + + ingressLinks.remove(ingressLinkID, ingressLink) + + // Breaking out of the loop means the socket is dead or service is stopping. + Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)") + handlePeerDisconnection(logicalPeerId, socket) + socket.close() + } + + private fun handleNetworkFailure(peerId: String) { + serviceScope.launch { + if (!connectionTracker.isConnected(peerId)) { + val canonicalPeerId = connectionTracker.canonicalPeerId(peerId) + connectionTracker.disconnect(peerId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != peerId) { + meshCore.removePeer(peerId) + } + } + } + } + + private fun handlePeerDisconnection(initialId: String, socket: SyncedSocket? = null) { + serviceScope.launch { + // Check if this socket is the current active one before nuking the session + val currentSocket = connectionTracker.getSocketForPeer(initialId) + val canonicalPeerId = connectionTracker.canonicalPeerId(initialId) + if (currentSocket === socket) { + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } else if (socket == null && currentSocket == null) { + // Fallback: If we don't have a specific socket context but we are already disconnected, ensure cleanup + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } + // Else: socket replaced or inactive; do not remove peer/session, as a new socket has likely taken over + publishControllerDebugSnapshot() + } + } + + /** + * Sends a broadcast message to all peers. + * @param content Text content of the message + * @param mentions Optional list of mentioned peer IDs + * @param channel Optional channel name + */ + override fun sendMessage(content: String, mentions: List, channel: String?) { + meshCore.sendMessage(content, mentions, channel) + } + + /** + * Sends a private encrypted message to a specific peer. + * + * @param content The message text + * @param recipientPeerID Destination peer ID + * @param recipientNickname Recipient nickname + * @param messageID Optional message ID (UUID if null) + */ + override fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String?) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + + /** + * Sends a read receipt for a specific message to the given peer over an established + * Noise session. If no session exists, this will log an error. + * + * @param messageID The ID of the message that was read. + * @param recipientPeerID The peer to notify. + * @param readerNickname Nickname of the reader (may be shown by the receiver). + */ + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + meshCore.sendReadReceipt(messageID, recipientPeerID, readerNickname) + } + + override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + } + + override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + } + + /** + * Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support + * large payloads and generates a deterministic transferId (sha256 of payload) for UI/state. + * + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + override fun sendFileBroadcast(file: BitchatFilePacket) { + meshCore.sendFileBroadcast(file) + } + + /** + * Sends a file privately to a specific peer. If no Noise session is established, + * a handshake will be initiated and the send is deferred/aborted for now. + * + * @param recipientPeerID Target peer. + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + meshCore.sendFilePrivate(recipientPeerID, file) + } + + override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + meshCore.sendVoiceFrame(recipientPeerID, payload) + } + + override fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): com.bitchat.android.mesh.PrivateMediaPreparation = meshCore.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) + + /** + * Attempts to cancel an in-flight file transfer identified by its transferId. + * + * @param transferId Deterministic id (usually sha256 of the file TLV). + * @return true if a transfer with this id was found and cancellation was scheduled, false otherwise. + */ + override fun cancelFileTransfer(transferId: String): Boolean { + return meshCore.cancelFileTransfer(transferId) + } + + /** + * Broadcasts an ANNOUNCE packet to the entire mesh. + */ + override fun sendBroadcastAnnounce() { + meshCore.sendBroadcastAnnounce() + } + + /** + * Sends an ANNOUNCE packet to a specific peer. + */ + override fun sendAnnouncementToPeer(peerID: String) { + meshCore.sendAnnouncementToPeer(peerID) + } + + /** @return Mapping of peer IDs to nicknames. */ + override fun getPeerNicknames(): Map = meshCore.getPeerNicknames() + + /** @return Mapping of peer IDs to RSSI values. */ + override fun getPeerRSSI(): Map = meshCore.getPeerRSSI() + + /** @return current active peer count for status surfaces. */ + override fun getActivePeerCount(): Int = meshCore.getActivePeerCount() + + /** + * @return true if a Noise session with the peer is fully established. + */ + override fun hasEstablishedSession(peerID: String) = meshCore.hasEstablishedSession(peerID) + + /** + * @return a human-readable Noise session state for the given peer (implementation-defined). + */ + override fun getSessionState(peerID: String) = meshCore.getSessionState(peerID) + + /** + * Triggers a Noise handshake with the given peer. Safe to call repeatedly; no-op if already handshaking/established. + */ + override fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID) + + /** + * @return the stored public-key fingerprint (hex) for a peer, if known. + */ + override fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID) + + /** + * Retrieves the full profile for a peer, including keys and verification state, if available. + */ + override fun getPeerInfo(peerID: String): PeerInfo? = meshCore.getPeerInfo(peerID) + + /** + * Updates local metadata for a peer and returns whether the change was applied. + * + * @param peerID Target peer id. + * @param nickname Display name. + * @param noisePublicKey Peer’s Noise static public key. + * @param signingPublicKey Peer’s Ed25519 signing public key. + * @param isVerified Whether this identity is verified by the user. + * @return true if the record was updated or created, false otherwise. + */ + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = meshCore.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + /** + * @return the local device’s long-term identity fingerprint (hex). + */ + override fun getIdentityFingerprint(): String = meshCore.getIdentityFingerprint() + + override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey() + + /** + * @return true if the UI should show an “encrypted” indicator for this peer. + */ + override fun shouldShowEncryptionIcon(peerID: String) = meshCore.shouldShowEncryptionIcon(peerID) + + /** + * @return a snapshot list of peers with established Noise sessions. + */ + override fun getEncryptedPeers(): List = meshCore.getEncryptedPeers() + + /** + * @return the current IPv4/IPv6 address of a connected peer, if any. + * Prefers the scoped IPv6 address format. + */ + override fun getDeviceAddressForPeer(peerID: String): String? = + meshCore.getDeviceAddressForPeer(peerID) + + /** + * Helper to resolve a scoped IPv6 address from a socket for UI display. + */ + private fun resolveScopedAddress(sock: Socket): String? { + val addr = sock.inetAddress as? Inet6Address ?: return sock.inetAddress?.hostAddress + if (addr.scopeId != 0 || addr.isLoopbackAddress) return addr.hostAddress + + // If address has no scope but we are on Aware (Link-Local fe80), attempt interface resolution + val iface = try { + val lp = cm.getLinkProperties(cm.activeNetwork) + lp?.interfaceName ?: "aware0" + } catch (_: Exception) { "aware0" } + + return "${addr.hostAddress}%$iface" + } + + /** + * @return a mapping of peerID → connected device IP address for all active sockets. + * Results are formatted as scoped addresses if applicable. + */ + override fun getDeviceAddressToPeerMapping(): Map = + meshCore.getDeviceAddressToPeerMapping() + + /** + * @return map of peer ID to nickname, bridged for UI warning fix. + */ + fun getPeerNicknamesMap(): Map = meshCore.getPeerNicknames() + + /** Returns recently discovered peer IDs via Aware discovery (may not be connected). */ + fun getDiscoveredPeerIds(): Set = + (handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet() + + private fun publishControllerDebugSnapshot() { + try { + com.bitchat.android.wifiaware.WifiAwareController.publishDebugSnapshot( + connected = getDeviceAddressToPeerMapping(), + known = getPeerNicknames(), + discovered = getDiscoveredPeerIds() + ) + } catch (_: Exception) { } + } + + /** + * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. + */ + override fun printDeviceAddressesForPeers(): String = + getDeviceAddressToPeerMapping().entries.joinToString("\n") { "${it.key} -> ${it.value}" } + + /** + * @return A detailed string containing the debug status of all mesh components. + */ + override fun getDebugStatus(): String { + return meshCore.getDebugStatus( + transportInfo = connectionTracker.getDebugInfo(), + deviceMap = getDeviceAddressToPeerMapping(), + extraLines = listOf("Peers: ${connectionTracker.peerSockets.keys}"), + title = "Wi-Fi Aware Mesh Debug Status" + ) + } + + override fun clearAllInternalData() { + meshCore.clearAllInternalData() + } + + override fun clearAllEncryptionData() { + meshCore.clearAllEncryptionData() + } + + /** Utility extension to safely close server sockets. */ + private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} + + + private inner class WifiAwareTransport : MeshTransport { + override val id: String = "WIFI" + + override fun broadcastPacket(routed: RoutedPacket): Boolean = + this@WifiAwareMeshService.broadcastPacket(routed) + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + } + override fun sendPacketToLink( + relayAddress: String, + ingressLinkID: String, + packet: BitchatPacket + ): Boolean { + val link = IngressLinkPolicy.resolve( + ingressLinkID = ingressLinkID, + relayAddress = relayAddress, + links = ingressLinks, + currentTransportForRelay = connectionTracker::getSocketForPeer + ) ?: return false + val data = packet.toBinaryData() ?: return false + return try { + link.transport.write(data) + true + } catch (e: IOException) { + Log.e( + TAG, + "TX: exact-link write to ${relayAddress.take(8)} failed: ${e.message}" + ) + false + } + } + override fun cancelTransfer(transferId: String): Boolean { + return fragmentingSender.cancelTransfer(transferId) + } + override fun getDeviceAddressForPeer(peerID: String): String? { + return connectionTracker.getSocketForPeer(peerID)?.let { resolveScopedAddress(it.rawSocket) } + } + + override fun getDeviceAddressToPeerMapping(): Map { + val map = mutableMapOf() + connectionTracker.peerSockets.forEach { (pid, sock) -> + map[pid] = resolveScopedAddress(sock.rawSocket) ?: "unknown" + } + return map + } + override fun getTransportDebugInfo(): String { + return connectionTracker.getDebugInfo() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt new file mode 100644 index 00000000..3e7dbc58 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt @@ -0,0 +1,74 @@ +package com.bitchat.android.wifiaware + +import android.content.Context +import android.content.pm.PackageManager +import android.net.wifi.aware.WifiAwareManager +import android.os.Build + +/** + * Centralized Wi-Fi Aware capability checks. + * + * "Supported" is stable device/API capability. "Available" is runtime state and can change + * when Wi-Fi, location, airplane mode, or system radio state changes. + */ +object WifiAwareSupport { + data class Status( + val supported: Boolean, + val available: Boolean, + val reason: String? = null + ) + + fun evaluate(context: Context): Status { + val appContext = context.applicationContext + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return Status( + supported = false, + available = false, + reason = "requires Android 10+" + ) + } + + val hasFeature = try { + appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE) + } catch (_: Exception) { + false + } + if (!hasFeature) { + return Status( + supported = false, + available = false, + reason = "device does not advertise Wi-Fi Aware support" + ) + } + + val manager = getManager(appContext) + ?: return Status( + supported = false, + available = false, + reason = "WifiAwareManager unavailable" + ) + + val available = try { + manager.isAvailable + } catch (_: Exception) { + false + } + + return Status( + supported = true, + available = available, + reason = if (available) null else "Wi-Fi Aware temporarily unavailable" + ) + } + + fun isSupported(context: Context): Boolean = evaluate(context).supported + + fun getManager(context: Context): WifiAwareManager? { + return try { + context.applicationContext.getSystemService(WifiAwareManager::class.java) + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/info/guardianproject/arti/ArtiException.kt b/app/src/main/java/info/guardianproject/arti/ArtiException.kt new file mode 100644 index 00000000..bee87ff3 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiException.kt @@ -0,0 +1,9 @@ +package info.guardianproject.arti + +/** + * Exception thrown by Arti operations. + * + * This exception is thrown when the native Arti library encounters + * an error during initialization, startup, or other operations. + */ +class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt new file mode 100644 index 00000000..d21d3989 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt @@ -0,0 +1,17 @@ +package info.guardianproject.arti + +/** + * Listener interface for Arti log messages. + * + * This interface is called from the native Arti implementation whenever + * a log line is produced. It allows the application to monitor Tor's + * bootstrap progress and connection status. + */ +fun interface ArtiLogListener { + /** + * Called when Arti produces a log line. + * + * @param logLine The log message from Arti, or null if no message + */ + fun onLogLine(logLine: String?) +} diff --git a/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt new file mode 100644 index 00000000..a81350ad --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt @@ -0,0 +1,183 @@ +package info.guardianproject.arti + +import android.app.Application +import android.util.Log +import org.torproject.arti.ArtiNative +import java.io.File + +/** + * Arti Tor proxy implementation compatible with Guardian Project API. + * + * This class provides a wrapper around the custom-built Arti native library, + * maintaining API compatibility with Guardian Project's arti-mobile-ex library. + * + * Usage: + * ``` + * val proxy = ArtiProxy.Builder(application) + * .setSocksPort(9050) + * .setDnsPort(9051) + * .setLogListener { logLine -> Log.i("Arti", logLine ?: "") } + * .build() + * + * proxy.start() + * // ... use proxy ... + * proxy.stop() + * ``` + */ +class ArtiProxy private constructor( + private val application: Application, + private val socksPort: Int, + private val dnsPort: Int, + private val logListener: ArtiLogListener? +) { + companion object { + private const val TAG = "ArtiProxy" + } + + @Volatile + private var isRunning = false + + /** + * Start the Arti Tor proxy. + * + * This method: + * 1. Registers log callback + * 2. Initializes Arti runtime with data directory + * 3. Starts SOCKS proxy on configured port + * + * @throws ArtiException if initialization or startup fails + */ + fun start() { + if (isRunning) { + Log.w(TAG, "Arti already running") + return + } + + try { + logListener?.let { listener -> + Log.d(TAG, "Registering log callback") + ArtiNative.setLogCallback(listener) + } + + val dataDir = getDataDirectory() + Log.i(TAG, "Initializing Arti with data directory: $dataDir") + + val initResult = ArtiNative.initialize(dataDir.absolutePath) + if (initResult != 0) { + throw ArtiException("Failed to initialize Arti: error code $initResult") + } + + Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)") + val startResult = ArtiNative.startSocksProxy(socksPort) + when (startResult) { + 0 -> { + isRunning = true + Log.i(TAG, "Arti started successfully") + } + -1 -> throw ArtiException("Arti client not initialized") + -2 -> throw ArtiException("Tokio runtime not initialized") + -3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)") + else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult") + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to start Arti", e) + if (e is ArtiException) { + throw e + } else { + throw ArtiException("Failed to start Arti: ${e.message}", e) + } + } + } + + /** + * Stop the Arti Tor proxy. + * + * This method gracefully shuts down the Tor client and SOCKS proxy. + */ + fun stop() { + if (!isRunning) { + Log.w(TAG, "Arti not running") + return + } + + try { + Log.i(TAG, "Stopping Arti...") + val stopResult = ArtiNative.stop() + if (stopResult != 0) { + Log.w(TAG, "Stop returned error code: $stopResult") + } + + isRunning = false + Log.i(TAG, "Arti stopped successfully") + + } catch (e: Exception) { + Log.e(TAG, "Error stopping Arti", e) + } + } + + /** + * Get or create Arti data directory. + * + * Directory structure: + * - {app_data}/arti/cache - Tor directory cache + * - {app_data}/arti/state - Tor persistent state + */ + private fun getDataDirectory(): File { + val artiDir = File(application.filesDir, "arti") + if (!artiDir.exists()) { + artiDir.mkdirs() + } + + File(artiDir, "cache").apply { if (!exists()) mkdirs() } + File(artiDir, "state").apply { if (!exists()) mkdirs() } + + return artiDir + } + + /** + * Builder for ArtiProxy instances. + * + * Provides fluent API for configuring proxy settings before creation. + */ + class Builder(private val application: Application) { + private var socksPort: Int = 9050 + private var dnsPort: Int = 9051 + private var logListener: ArtiLogListener? = null + + /** + * Set SOCKS proxy port. + * @param port Port number (default: 9050) + * @return this Builder for chaining + */ + fun setSocksPort(port: Int) = apply { + this.socksPort = port + } + + /** + * Set DNS port. + * @param port Port number (default: 9051) + * @return this Builder for chaining + */ + fun setDnsPort(port: Int) = apply { + this.dnsPort = port + } + + /** + * Set log listener for Arti log messages. + * @param listener Callback for log lines + * @return this Builder for chaining + */ + fun setLogListener(listener: ArtiLogListener) = apply { + this.logListener = listener + } + + /** + * Build and return the configured ArtiProxy instance. + * @return Configured ArtiProxy (not yet started) + */ + fun build(): ArtiProxy { + return ArtiProxy(application, socksPort, dnsPort, logListener) + } + } +} diff --git a/app/src/main/java/org/torproject/arti/ArtiNative.kt b/app/src/main/java/org/torproject/arti/ArtiNative.kt new file mode 100644 index 00000000..13eca6db --- /dev/null +++ b/app/src/main/java/org/torproject/arti/ArtiNative.kt @@ -0,0 +1,54 @@ +package org.torproject.arti + +import info.guardianproject.arti.ArtiLogListener + +/** + * JNI wrapper for custom-built Arti (Tor implementation in Rust) + * + * This class provides native bindings to libarti_android.so compiled from + * the latest Arti source with rustls (no OpenSSL dependency). + * + * Features: + * - Latest Arti v1.7.0 code + * - 16KB page size support (Google Play Nov 2025 ready) + * - Onion service client support + * - Pure Rust TLS (rustls) + */ +object ArtiNative { + + init { + System.loadLibrary("arti_android") + } + + /** + * Get Arti version string + * @return Version string from native library + */ + external fun getVersion(): String + + /** + * Set log callback for Arti logs + * @param callback Callback object with onLogLine(String?) method + */ + external fun setLogCallback(callback: ArtiLogListener) + + /** + * Initialize Arti runtime + * @param dataDir Directory for Arti state/cache + * @return 0 on success, error code otherwise + */ + external fun initialize(dataDir: String): Int + + /** + * Start SOCKS proxy on specified port + * @param port Port number for SOCKS proxy (e.g., 9050) + * @return 0 on success, error code otherwise + */ + external fun startSocksProxy(port: Int): Int + + /** + * Stop Arti and cleanup + * @return 0 on success, error code otherwise + */ + external fun stop(): Int +} \ No newline at end of file diff --git a/app/src/main/jniLibs/arm64-v8a/libarti_android.so b/app/src/main/jniLibs/arm64-v8a/libarti_android.so new file mode 100755 index 00000000..77e066cd Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libarti_android.so b/app/src/main/jniLibs/armeabi-v7a/libarti_android.so new file mode 100755 index 00000000..c15c82fc Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libarti_android.so differ diff --git a/app/src/main/jniLibs/x86/libarti_android.so b/app/src/main/jniLibs/x86/libarti_android.so new file mode 100755 index 00000000..d88a13cd Binary files /dev/null and b/app/src/main/jniLibs/x86/libarti_android.so differ diff --git a/app/src/main/jniLibs/x86_64/libarti_android.so b/app/src/main/jniLibs/x86_64/libarti_android.so new file mode 100755 index 00000000..b092b073 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/app/src/main/res/drawable/ic_spec_bluetooth.xml b/app/src/main/res/drawable/ic_spec_bluetooth.xml new file mode 100644 index 00000000..8c1ed7ae --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bluetooth.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_filled.xml b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml new file mode 100644 index 00000000..2e73b458 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_outline.xml b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml new file mode 100644 index 00000000..988fe44c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_chat_bubbles.xml b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml new file mode 100644 index 00000000..76109058 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_check.xml b/app/src/main/res/drawable/ic_spec_check.xml new file mode 100644 index 00000000..16eb9e1c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_check.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_close.xml b/app/src/main/res/drawable/ic_spec_close.xml new file mode 100644 index 00000000..e0fbc4ea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_close.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_spec_command.xml b/app/src/main/res/drawable/ic_spec_command.xml new file mode 100644 index 00000000..366173cd --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_command.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_envelope.xml b/app/src/main/res/drawable/ic_spec_envelope.xml new file mode 100644 index 00000000..15552d37 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_envelope.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/ic_spec_eye_off.xml b/app/src/main/res/drawable/ic_spec_eye_off.xml new file mode 100644 index 00000000..2e852cf4 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_eye_off.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_globe.xml b/app/src/main/res/drawable/ic_spec_globe.xml new file mode 100644 index 00000000..b56bb3a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_globe.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock.xml b/app/src/main/res/drawable/ic_spec_lock.xml new file mode 100644 index 00000000..42713a11 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock_open.xml b/app/src/main/res/drawable/ic_spec_lock_open.xml new file mode 100644 index 00000000..11d69821 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock_open.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_mention.xml b/app/src/main/res/drawable/ic_spec_mention.xml new file mode 100644 index 00000000..a57bf9af --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_mention.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_on_location_person.xml b/app/src/main/res/drawable/ic_spec_on_location_person.xml new file mode 100644 index 00000000..507d47d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_on_location_person.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_panic.xml b/app/src/main/res/drawable/ic_spec_panic.xml new file mode 100644 index 00000000..7c876da3 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_panic.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_people.xml b/app/src/main/res/drawable/ic_spec_people.xml new file mode 100644 index 00000000..3b2d46f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_people.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_person.xml b/app/src/main/res/drawable/ic_spec_person.xml new file mode 100644 index 00000000..33c42dcb --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_person.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_range.xml b/app/src/main/res/drawable/ic_spec_range.xml new file mode 100644 index 00000000..5ba47d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_range.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_routed.xml b/app/src/main/res/drawable/ic_spec_routed.xml new file mode 100644 index 00000000..36e271d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_routed.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_shuffle.xml b/app/src/main/res/drawable/ic_spec_shuffle.xml new file mode 100644 index 00000000..9f431732 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_shuffle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star.xml b/app/src/main/res/drawable/ic_spec_star.xml new file mode 100644 index 00000000..c48025ab --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star_filled.xml b/app/src/main/res/drawable/ic_spec_star_filled.xml new file mode 100644 index 00000000..d29d62d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star_filled.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_sync.xml b/app/src/main/res/drawable/ic_spec_sync.xml new file mode 100644 index 00000000..1ab434e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_sync.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_teleport.xml b/app/src/main/res/drawable/ic_spec_teleport.xml new file mode 100644 index 00000000..6d215dea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_teleport.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_warning.xml b/app/src/main/res/drawable/ic_spec_warning.xml new file mode 100644 index 00000000..ffb64e3c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_warning.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_waveform.xml b/app/src/main/res/drawable/ic_spec_waveform.xml new file mode 100644 index 00000000..2e0070b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_waveform.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi.xml b/app/src/main/res/drawable/ic_spec_wifi.xml new file mode 100644 index 00000000..4757653f --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi_off.xml b/app/src/main/res/drawable/ic_spec_wifi_off.xml new file mode 100644 index 00000000..4a716e57 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi_off.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/font/geist_mono_bold.ttf b/app/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 00000000..90eb8a86 Binary files /dev/null and b/app/src/main/res/font/geist_mono_bold.ttf differ diff --git a/app/src/main/res/font/geist_mono_medium.ttf b/app/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 00000000..ff49ece5 Binary files /dev/null and b/app/src/main/res/font/geist_mono_medium.ttf differ diff --git a/app/src/main/res/font/geist_mono_regular.ttf b/app/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 00000000..50c9d5a6 Binary files /dev/null and b/app/src/main/res/font/geist_mono_regular.ttf differ diff --git a/app/src/main/res/font/geist_mono_semibold.ttf b/app/src/main/res/font/geist_mono_semibold.ttf new file mode 100644 index 00000000..1b21724d Binary files /dev/null and b/app/src/main/res/font/geist_mono_semibold.ttf differ diff --git a/app/src/main/res/raw/geist_mono_ofl.txt b/app/src/main/res/raw/geist_mono_ofl.txt new file mode 100644 index 00000000..34ffeb6f --- /dev/null +++ b/app/src/main/res/raw/geist_mono_ofl.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 31bcf058..772e48a5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,20 +1,396 @@ - بيتشات - يعد إذن البلوتوث ضروريًا للمراسلة عبر الإنترنت. - تحتاج إلى إذن بالترجمة لاكتشاف الأجهزة التالية عبر Bluetooth. - يلزم الحصول على إذن الإشعارات لتنبيه الرسائل الجديدة. - اسم العائلة - رقمي رسالة... + bitchat + + + إذن البلوتوث مطلوب للدردشة مباشرة مع الآخرين بدون إنترنت. + إذن الموقع مطلوب للعثور على الأجهزة القريبة عبر البلوتوث. + إذن الإشعارات مطلوب لتنبيهك بالرسائل الجديدة. + + + اسم مستعار + اكتب رسالة… كلمة المرور - انضمام إلى قناة + الانضمام إلى القناة مغادرة إرسال + عرض الأوامر رجوع أشخاص - قنوات + القنوات مستخدمون متصلون لا أحد متصل انقر ثلاث مرات لمسح جميع البيانات شبكتك + + + تم اكتشاف تحسين البطارية + تحسين البطارية معطّل + لا حاجة لتحسين البطارية + جارٍ التحقق من تحسين البطارية + لماذا تعطيل تحسين البطارية؟ + تعمل bitchat في الخلفية للحفاظ على اتصالات الشبكة المتشابكة مع الأجهزة القريبة. قد يقطع تحسين البطارية هذه الاتصالات مما يؤدي إلى تأخير الرسائل أو فقدانها.\n\nيساعد تعطيل تحسين البطارية في ضمان اتصال موثوق بين النظراء. + تعطيل تحسين البطارية + ملاحظة: يمكنك تغيير ذلك لاحقًا من إعدادات أندرويد > التطبيقات > bitchat > البطارية. + جهازك لا يحتاج إلى إعدادات تحسين البطارية. ستعمل bitchat بشكل طبيعي. + جهازك لا يحتاج إلى إعدادات تحسين البطارية. ستعمل bitchat بشكل طبيعي. + يمكن لـ bitchat العمل بشكل موثوق في الخلفية + • تسليم موثوق للرسائل\n• الحفاظ على اتصال الشبكة المتشابكة\n• تمرير الرسائل في الخلفية\n• منع انقطاع الاتصال + التحقق مرة أخرى + تخطي الآن + متابعة + إعادة المحاولة + تخطي + + + و%1$d إضافية + %1$d رسالة من %2$d أشخاص + و%1$d محادثات أخرى + مستخدمون قريبون! + شخص واحد قريب + %1$d أشخاص قريبون + رسائل جديدة + رسائل موقع جديدة + تم ذكرك في %1$s + تم ذكرك في %1$s (+%2$d أخرى) + %1$d من الإشارات في %2$s + نشاط جديد في %1$s + رسائل في %1$s + %1$s انضم إلى المحادثة + bitchat – %1$d إشارات + bitchat – محادثات المواقع + %1$d رسائل من %2$d مواقع + تم ذكرك في محادثة الشبكة + %1$d من الإشارات في محادثة الشبكة + و%1$d مواقع أخرى + و%1$d إضافية + + + إضافة إلى المفضلة + إزالة من المفضلة + إضافة إشارة مرجعية + + + يمكن الوصول عبر Nostr + رسائل خاصة غير مقروءة + تم الانتقال + فتح إعدادات الموقع والقنوات + الأقران المتصلون + مشاركو geohash + جاهز للمصافحة + المصافحة جارية + تشفير طرف إلى طرف + فشلت المصافحة + + + 📎 تم استلام ملف + 📄 %1$s + 📏 الحجم: %1$s + 🏷️ النوع: %1$s + 📂 فتح / حفظ + ❌ إغلاق + اختيار صورة + + + دردشة شبكية دون اتصال + تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق. + قنوات geohash عبر الإنترنت + تواصل مع أشخاص بالقرب منك عبر قنوات تعتمد على geohash. وسّع الشبكة عبر مرحلات الإنترنت العامة. + تشفير طرف إلى طرف + الرسائل الخاصة مشفّرة. رسائل القنوات عامة. + النظام + فاتح + داكن + برهان العمل + PoW معطّل + PoW مفعّل + أضف برهان العمل لرسائل geohash للحد من الرسائل المزعجة. + الصعوبة: %1$d بت (~%2$s) + الصعوبة %1$d تتطلب ~%2$s من محاولات الهاش + لا حاجة لبرهان العمل + منخفض جدًا – حماية دنيا من الرسائل المزعجة + منخفض – حماية أساسية + متوسط – حماية جيدة + عالٍ – حماية قوية + عالٍ جدًا – قد يسبب تأخيرًا + شديد – يتطلب قدرة حوسبية كبيرة + الشبكة + Tor معطّل + Tor مفعّل + توجيه حركة الإنترنت عبر Tor لمزيد من الخصوصية. + حالة Tor: %1$s، التمهيد %2$d%% + آخر: %1$s + مسح البيانات الطارئ + إعدادات التصحيح + مفتوح المصدر • الخصوصية أولاً • لامركزي + إغلاق + إلغاء + + + تحذير + خدمات الموقع + الخصوصية + خطأ + تحسين البطارية + الفوائد + جارٍ التحقق من تحسين البطارية + تحسين البطارية غير مدعوم + Bluetooth + رسالة غير مقروءة + فتح الخريطة + إزالة الإشارة المرجعية + انتقال + مغادرة القناة + يمكن الوصول عبر Nostr + مفضل (غير متصل) + خفض الدقة + رفع الدقة + اختر geohash + التمرير إلى الأسفل + ملف + صورة + إلغاء + رابط + اختيار وسائط + اختيار ملف + حفظ الصورة الحالية + إغلاق + صورة %1$d من %2$d + الخصوصية محمية + تسجيل مذكرة صوتية + دردشة شبكية دون اتصال + قنوات geohash عبر الإنترنت + تشفير طرف إلى طرف + + + صورة %1$d من %2$d + الصورة غير متاحة + تم حفظ الصورة في "التنزيلات" + تعذّر حفظ الصورة + تم حفظ الصورة في "التنزيلات" + تعذّر حفظ الصورة + اختيار ملف + [الملف غير متاح] + غير معروف + + + اختر إجراءً لهذه الرسالة أو هذا المستخدم + اختر إجراءً لهذا المستخدم + نسخ الرسالة + انسخ هذه الرسالة إلى الحافظة + صفع %1$s + إرسال صفعة ودّية + عناق %1$s + إرسال عناق ودي + حظر %1$s + حظر جميع رسائل هذا المستخدم + + + #قنوات الموقع + دردش مع أشخاص قريبين منك عبر قنوات geohash. تتم مشاركة geohash تقريبي فقط، وليس بيانات GPS الدقيقة. للحفاظ على خصوصيتك، لا تلتقط لقطات شاشة ولا تشارك هذه الشاشة. + منح إذن الموقع + تم رفض إذن الموقع. فعّله من الإعدادات لاستخدام قنوات الموقع. + فتح الإعدادات + \u2713 تم منح إذن الموقع + جارٍ التحقق من الأذونات… + البحث عن قنوات قريبة… + محفوظة + Geohash + geohash غير صالح + انتقال + تعطيل خدمات الموقع + تمكين خدمات الموقع + mesh + مربّع سكني + حيّ + مدينة + إقليم + منطقة + #bluetooth • %1$s + + + أدوات التصحيح + أدوات المطوّر للتشخيص والتحكم + سجل تفصيلي + تسجّل انضمام/مغادرة الأقران، اتجاه الاتصال، تمرير الحزم والمرحلين + أدوار البلوتوث + خادم GATT + الاتصالات: %1$d / %2$d + الحد الأقصى للخادم + عميل GATT + الحد الأقصى للعميل + الاتصالات: %1$d / %2$d + الحد الأقصى الكلي + تمرير الحزم + منذ البدء: %1$d + تبديل الأدوار وإغلاق جميع الاتصالات عند التعطيل + إعدادات المزامنة + الحد الأقصى للحزم لكل مزامنة: %1$d + الحد الأقصى لحجم مرشح GCS: %1$d بايت (128–1024) + نسبة الإيجابيات الكاذبة المستهدفة: %1$.2f%% + الأجهزة المتصلة + معرّف جهازنا: %1$s + لا شيء + قطع الاتصال + الأجهزة التي تم فحصها مؤخرًا + اتصال + وحدة تصحيح + مسح + آخر 10 ثوان: %1$d • 1 دقيقة: %2$d • 15 دقيقة: %3$d + قيمة P المشتقة: %1$s • الحد الأقصى للعناصر المقدّر: %2$s + • مباشر + كخادم (نحن نستضيف) + كعميل (نحن نتصل) + + + خدمات الموقع مطلوبة + الخصوصية أولاً + bitchat لا يتتبع موقعك.\n\nخدمات الموقع مطلوبة لمسح البلوتوث وميزة دردشة geohash. + bitchat يحتاج خدمات الموقع من أجل: + • مسح أجهزة البلوتوث\n• اكتشاف المستخدمين القريبين في شبكة mesh\n• ميزة دردشة geohash\n• بدون تتبع أو جمع للموقع + فتح إعدادات الموقع + التحقق مرة أخرى + خدمات الموقع غير متاحة + خدمات الموقع غير متاحة على هذا الجهاز. هذا غير معتاد لأن خدمات الموقع قياسية في أجهزة أندرويد.\n\nتتطلب bitchat خدمات الموقع لعمل مسح البلوتوث بشكل صحيح (متطلب أندرويد). بدون ذلك لا يمكن للتطبيق اكتشاف المستخدمين القريبين. + جارٍ التحقق من خدمات الموقع… + البلوتوث مطلوب + bitchat يحتاج البلوتوث من أجل: + • اكتشاف المستخدمين القريبين\n• إنشاء اتصالات شبكة mesh\n• إرسال واستقبال الرسائل\n• العمل بدون إنترنت أو خوادم + تفعيل البلوتوث + البلوتوث غير مدعوم + هذا الجهاز لا يدعم Bluetooth Low Energy (BLE) المطلوب لعمل bitchat.\n\nتحتاج bitchat إلى BLE لإنشاء شبكات mesh والتواصل مع الأجهزة القريبة بدون إنترنت. + جارٍ التحقق من حالة البلوتوث… + تم اكتشاف تحسين البطارية + تحسين البطارية مفعّل + تحتاج bitchat للعمل في الخلفية للحفاظ على اتصالات mesh. قد يقطع تحسين البطارية هذه الاتصالات. + فوائد التعطيل + • تسليم موثوق للرسائل\n• يحافظ على اتصال mesh\n• يمنع انقطاع الاتصال + تعطيل تحسين البطارية + تم تعطيل تحسين البطارية + متابعة + جارٍ تهيئة شبكة mesh + جارٍ إعداد شبكة Bluetooth mesh… + يجب أن يستغرق ذلك بضع ثوانٍ فقط + الإعداد غير مكتمل + حاول مرة أخرى + خصوصيتك محمية + • بدون تتبع أو جمع للبيانات\n• دردشات Bluetooth mesh تعمل دون اتصال بالكامل\n• دردشات geohash تستخدم الإنترنت + الأذونات + منح الأذونات + bitchat لا يتتبع موقعك + لا أحد هنا… + (أنت) + حرّك وقرّب لاختيار geohash + اختيار + اكتب رسالة… + إشارة + v%1$s + image/* + صورة + ملف + + + 📷 أرسل صورة + 🎤 أرسل مذكرة صوتية + 📎 أرسل ملفًا + + + تشغيل + إيقاف مؤقت + + + مطلوب لاكتشاف مستخدمي bitchat عبر البلوتوث + اسمح لـ bitchat بالاتصال بالأجهزة القريبة + مطلوب من أندرويد لاكتشاف مستخدمي bitchat القريبين عبر البلوتوث + bitchat يحتاج ذلك لمسح الأجهزة القريبة + تلقي إشعارات عند استلام رسائل خاصة + اسمح لـ bitchat بإرسال الإشعارات إليك + تعطيل تحسين البطارية لضمان أن bitchat يعمل بشكل موثوق في الخلفية ويحافظ على اتصالات الشبكة المتشابكة + اسمح لـ bitchat بالعمل بدون قيود البطارية + + + الأجهزة القريبة + موقع دقيق + ميكروفون + إشعارات + تحسين البطارية + أخرى + + + أدخل كلمة مرور القناة + القناة %1$s محمية بكلمة مرور. أدخل كلمة المرور للانضمام. + كلمة المرور + انضمام + إلغاء + + + + و%d إضافية + و%d إضافية + + + + شخص واحد + %d أشخاص + + + + #%1$s ± 1 • %2$d ملاحظة + #%1$s ± 1 • %2$d ملاحظات + + أضف ملاحظات قصيرة ودائمة إلى هذا المكان ليتمكن الزوار الآخرون من العثور عليها. + قنوات الترحيل الجغرافية غير متاحة؛ تم إيقاف الملاحظات + لا توجد قنوات ترحيل جغرافية قريبة + تعتمد الملاحظات على قنوات الترحيل الجغرافية. تحقق من الاتصال ثم أعد المحاولة. + جارٍ تحميل الملاحظات… + لا توجد ملاحظات بعد + كن أول من يضيف ملاحظة لهذا المكان. + إغلاق + أضف ملاحظة لهذا المكان + بلوتوث موصى به + الشبكة تعمل — %1$d أقران + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + فتح قسم حول + تحقّق من الملاحظات المتروكة هنا + تُركت ملاحظة واحدة هنا — انقر للقراءة + تُركت %d ملاحظات هنا — انقر للقراءة + اللغة + لغة التطبيق + إعداد النظام الافتراضي + اختر اللغة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml new file mode 100644 index 00000000..b6557fe2 --- /dev/null +++ b/app/src/main/res/values-bn/strings.xml @@ -0,0 +1,383 @@ + + + bitchat + + + ইন্টারনেট ছাড়াই অন্যদের সাথে সরাসরি চ্যাট করার জন্য ব্লুটুথ অনুমতি প্রয়োজন। + ব্লুটুথের মাধ্যমে কাছাকাছি ডিভাইস খুঁজে পেতে অবস্থান অনুমতি প্রয়োজন। + নতুন বার্তা সম্পর্কে আপনাকে জানাতে বিজ্ঞপ্তি অনুমতি প্রয়োজন। + + + ডাকনাম + বার্তা টাইপ করুন… + পাসওয়ার্ড + চ্যানেলে যোগ দিন + ছেড়ে দিন + পাঠান + কমান্ড দেখান + ফিরে যান + মানুষ + চ্যানেল + অনলাইন ব্যবহারকারী + কেউ সংযুক্ত নেই + সমস্ত ডেটা সাফ করতে তিনবার ট্যাপ করুন + নেটওয়ার্ক + + + ব্যাটারি অপ্টিমাইজেশন সনাক্ত + ব্যাটারি অপ্টিমাইজেশন নিষ্ক্রিয় + ব্যাটারি অপ্টিমাইজেশন প্রয়োজন নেই + ব্যাটারি অপ্টিমাইজেশন পরীক্ষা করা হচ্ছে + কেন ব্যাটারি অপ্টিমাইজেশন নিষ্ক্রিয় করবেন? + bitchat ব্যাকগ্রাউন্ডে চলে কাছাকাছি ডিভাইসগুলির সাথে মেশ সংযোগ বজায় রাখতে। ব্যাটারি অপ্টিমাইজেশন এই সংযোগগুলি বাধাগ্রস্ত করতে পারে, যার ফলে বার্তা বিলম্বিত বা হারিয়ে যেতে পারে।\n\nব্যাটারি অপ্টিমাইজেশন নিষ্ক্রিয় করা নির্ভরযোগ্য পিয়ার-টু-পিয়ার যোগাযোগ নিশ্চিত করে। + ব্যাটারি অপ্টিমাইজেশন নিষ্ক্রিয় করুন + নোট: আপনি পরে Android Settings > Apps > bitchat > Battery এ এটি পরিবর্তন করতে পারেন। + আপনার ডিভাইসে ব্যাটারি অপ্টিমাইজেশন সেটিংসের প্রয়োজন নেই। bitchat স্বাভাবিকভাবে চলবে। + আপনার ডিভাইসে ব্যাটারি অপ্টিমাইজেশন সেটিংসের প্রয়োজন নেই। bitchat স্বাভাবিকভাবে চলবে। + bitchat নির্ভরযোগ্যভাবে ব্যাকগ্রাউন্ডে চলতে পারে + • নির্ভরযোগ্য বার্তা ডেলিভারি\n• মেশ সংযোগ বজায় রাখা\n• ব্যাকগ্রাউন্ড বার্তা রিলে\n• সংযোগ বিচ্ছিন্নতা প্রতিরোধ + আবার পরীক্ষা করুন + এখন এড়িয়ে যান + চালিয়ে যান + আবার চেষ্টা করুন + এড়িয়ে যান + + + এবং %1$d আরো + %2$d জন থেকে %1$d বার্তা + এবং %1$d আরো কথোপকথন + কাছাকাছি bitchatter! + কাছাকাছি ১ জন + কাছাকাছি %1$d জন + নতুন বার্তা + নতুন অবস্থান বার্তা + %1$s এ উল্লিখিত + %1$s এ উল্লিখিত (+%2$d আরো) + %2$s এ %1$d উল্লেখ + %1$s এ নতুন কার্যকলাপ + %1$s এ বার্তা + %1$s কথোপকথনে যোগ দিয়েছে + bitchat – %1$d উল্লেখ + bitchat – অবস্থান চ্যাট + %2$d অবস্থান থেকে %1$d বার্তা + মেশ চ্যাটে উল্লিখিত + মেশ চ্যাটে %1$d উল্লেখ + এবং %1$d আরো অবস্থান + এবং %1$d আরো + + + প্রিয়তে যোগ করুন + প্রিয় থেকে সরান + বুকমার্ক যোগ করুন + + + Nostr এর মাধ্যমে পৌঁছানো যায় + অপঠিত ব্যক্তিগত বার্তা + টেলিপোর্ট করা হয়েছে + অবস্থান ও চ্যানেল সেটিংস খুলুন + সংযুক্ত পিয়ার + জিওহ্যাশ অংশগ্রহণকারী + হ্যান্ডশেকের জন্য প্রস্তুত + হ্যান্ডশেক চলছে + এন্ড-টু-এন্ড এনক্রিপ্ট করা + হ্যান্ডশেক ব্যর্থ + + + 📎 ফাইল প্রাপ্ত + 📄 %1$s + 📏 আকার: %1$s + 🏷️ ধরণ: %1$s + 📂 খুলুন / সংরক্ষণ করুন + ❌ বন্ধ করুন + ছবি নির্বাচন করুন + + + অফলাইন মেশ চ্যাট + ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়। + অনলাইন জিওহ্যাশ চ্যানেল + জিওহ্যাশ-ভিত্তিক চ্যানেলের মাধ্যমে আপনার এলাকার মানুষদের সাথে সংযুক্ত হন। পাবলিক ইন্টারনেট রিলেগুলির মাধ্যমে মেশ বিস্তৃত করুন। + এন্ড-টু-এন্ড এনক্রিপশন + ব্যক্তিগত বার্তাগুলি এনক্রিপ্ট করা। চ্যানেল বার্তাগুলি পাবলিক। + সিস্টেম + হালকা + অন্ধকার + প্রুফ অফ ওয়ার্ক + PoW বন্ধ + PoW চালু + স্প্যাম কঠিন করতে জিওহ্যাশ বার্তায় প্রুফ অফ ওয়ার্ক যোগ করুন। + কঠিনতা: %1$d বিট (~%2$s) + কঠিনতা %1$d প্রয়োজন ~%2$s হ্যাশ প্রচেষ্টা + কোন প্রুফ অফ ওয়ার্ক প্রয়োজন নেই + খুব কম – ন্যূনতম স্প্যাম সুরক্ষা + কম – মৌলিক স্প্যাম সুরক্ষা + মাঝারি – ভাল স্প্যাম সুরক্ষা + উচ্চ – শক্তিশালী স্প্যাম সুরক্ষা + খুব উচ্চ – বিলম্ব ঘটাতে পারে + চরম – উল্লেখযোগ্য কম্পিউটিং পাওয়ার প্রয়োজন + নেটওয়ার্ক + Tor বন্ধ + Tor চালু + আরো গোপনীয়তার জন্য Tor এর মাধ্যমে ইন্টারনেট ট্রাফিক রুট করুন। + Tor অবস্থা: %1$s, বুটস্ট্র্যাপ %2$d%% + শেষ: %1$s + জরুরী ডেটা ক্লিয়ার + ডিবাগ সেটিংস + ওপেন সোর্স • গোপনীয়তা প্রথম • বিকেন্দ্রীভূত + বন্ধ করুন + বাতিল + + + সতর্কতা + অবস্থান সেবা + গোপনীয়তা + ত্রুটি + ব্যাটারি অপ্টিমাইজেশন + সুবিধা + ব্যাটারি অপ্টিমাইজেশন পরীক্ষা করা হচ্ছে + ব্যাটারি অপ্টিমাইজেশন সমর্থিত নয় + ব্লুটুথ + অপঠিত বার্তা + মানচিত্র খুলুন + বুকমার্ক সরান + টেলিপোর্ট + চ্যানেল ছেড়ে দিন + Nostr এর মাধ্যমে পৌঁছানো যায় + প্রিয় (অফলাইন) + নির্ভুলতা কমান + নির্ভুলতা বাড়ান + জিওহ্যাশ নির্বাচন করুন + নিচে স্ক্রল করুন + ফাইল + ছবি + বাতিল + লিঙ্ক + মিডিয়া নির্বাচন করুন + ফাইল নির্বাচন করুন + বর্তমান ছবি সংরক্ষণ করুন + বন্ধ করুন + ছবি %1$d এর %2$d + গোপনীয়তা সুরক্ষিত + ভয়েস বার্তা রেকর্ড করুন + অফলাইন মেশ চ্যাট + অনলাইন জিওহ্যাশ চ্যানেল + এন্ড-টু-এন্ড এনক্রিপশন + + + ছবি %1$d এর %2$d + ছবি অনুপলব্ধ + ছবি \"Downloads\" এ সংরক্ষিত + ছবি সংরক্ষণ করতে ব্যর্থ + ছবি \"Downloads\" এ সংরক্ষিত + ছবি সংরক্ষণ করতে ব্যর্থ + ফাইল নির্বাচন করুন + [ফাইল অনুপলব্ধ] + অজানা + + + এই বার্তা বা ব্যবহারকারীর জন্য অ্যাকশন নির্বাচন করুন + এই ব্যবহারকারীর জন্য অ্যাকশন নির্বাচন করুন + বার্তা কপি করুন + এই বার্তাটি ক্লিপবোর্ডে কপি করুন + %1$s কে চড় দিন + একটি কৌতুকপূর্ণ চড় পাঠান + %1$s কে আলিঙ্গন করুন + একটি বন্ধুত্বপূর্ণ আলিঙ্গন পাঠান + %1$s কে ব্লক করুন + এই ব্যবহারকারীর সমস্ত বার্তা ব্লক করুন + + + #অবস্থান চ্যানেল + জিওহ্যাশ চ্যানেলের মাধ্যমে আপনার কাছাকাছি মানুষদের সাথে চ্যাট করুন। শুধুমাত্র রূঢ় জিওহ্যাশ শেয়ার করা হয়, সঠিক GPS ডেটা নয়। গোপনীয়তার জন্য দয়া করে এই ভিউ স্ক্রিনশট করবেন না বা শেয়ার করবেন না। + অবস্থান অনুমতি দিন + অবস্থান অনুমতি প্রত্যাখ্যাত। অবস্থান চ্যানেল ব্যবহার করতে সেটিংসে সক্ষম করুন। + সেটিংস খুলুন + \u2713 অবস্থান অনুমতি দেওয়া হয়েছে + অনুমতি পরীক্ষা করা হচ্ছে … + কাছাকাছি চ্যানেল খোঁজা হচ্ছে … + বুকমার্ক করা + জিওহ্যাশ + অবৈধ জিওহ্যাশ + টেলিপোর্ট + অবস্থান সেবা নিষ্ক্রিয় করুন + অবস্থান সেবা সক্রিয় করুন + মেশ + ব্লক + আশপাশ + শহর + প্রদেশ + অঞ্চল + #bluetooth • %1$s + + + ডিবাগ টুল + ডায়াগনস্টিক এবং নিয়ন্ত্রণের জন্য ডেভেলপার টুল + বিস্তারিত লগিং + পিয়ার জয়েন/লিভ, সংযোগ দিক, প্যাকেট ফরওয়ার্ডিং এবং রিলে লগ করে + ব্লুটুথ ভূমিকা + GATT সার্ভার + সংযোগ: %1$d / %2$d + সর্বোচ্চ সার্ভার + GATT ক্লায়েন্ট + সর্বোচ্চ ক্লায়েন্ট + সংযোগ: %1$d / %2$d + সর্বোচ্চ সামগ্রিক + প্যাকেট রিলে + শুরু থেকে: %1$d + ভূমিকা চালু/বন্ধ করুন এবং নিষ্ক্রিয় করার সময় সমস্ত সংযোগ বন্ধ করুন + সিঙ্ক সেটিংস + প্রতি সিঙ্কে সর্বোচ্চ প্যাকেট: %1$d + সর্বোচ্চ GCS ফিল্টার আকার: %1$d বাইট (128–1024) + লক্ষ্য FPR: %1$.2f%% + সংযুক্ত ডিভাইস + আমাদের ডিভাইস ID: %1$s + কোনটি নেই + সংযোগ বিচ্ছিন্ন করুন + সাম্প্রতিক স্ক্যান করা ডিভাইস + সংযোগ করুন + ডিবাগ কনসোল + সাফ করুন + শেষ 10s: %1$d • 1m: %2$d • 15m: %3$d + উৎপন্ন P: %1$s • আনুমানিক সর্বোচ্চ উপাদান: %2$s + • সরাসরি + সার্ভার হিসেবে (আমরা হোস্ট করি) + ক্লায়েন্ট হিসেবে (আমরা সংযোগ করি) + + + অবস্থান সেবা প্রয়োজন + গোপনীয়তা প্রথম + bitchat আপনার অবস্থান ট্র্যাক করে না।\n\nব্লুটুথ স্ক্যানিং এবং জিওহ্যাশ চ্যাট বৈশিষ্ট্যের জন্য অবস্থান সেবা প্রয়োজন। + bitchat এর জন্য অবস্থান সেবা প্রয়োজন: + • ব্লুটুথ ডিভাইস স্ক্যানিং\n• মেশ নেটওয়ার্কে কাছাকাছি ব্যবহারকারী আবিষ্কার\n• জিওহ্যাশ চ্যাট বৈশিষ্ট্য\n• কোন ট্র্যাকিং বা অবস্থান সংগ্রহ নেই + অবস্থান সেটিংস খুলুন + আবার পরীক্ষা করুন + অবস্থান সেবা অনুপলব্ধ + এই ডিভাইসে অবস্থান সেবা উপলব্ধ নেই। এটি অস্বাভাবিক কারণ অবস্থান সেবা Android ডিভাইসে স্ট্যান্ডার্ড।\n\nব্লুটুথ স্ক্যানিং সঠিকভাবে কাজ করার জন্য bitchat এর অবস্থান সেবা প্রয়োজন (Android প্রয়োজনীয়তা)। এটি ছাড়া অ্যাপ কাছাকাছি ব্যবহারকারী আবিষ্কার করতে পারে না। + অবস্থান সেবা পরীক্ষা করা হচ্ছে … + ব্লুটুথ প্রয়োজন + bitchat এর জন্য ব্লুটুথ প্রয়োজন: + • কাছাকাছি ব্যবহারকারী আবিষ্কার\n• মেশ নেটওয়ার্ক সংযোগ তৈরি\n• বার্তা পাঠানো এবং গ্রহণ\n• ইন্টারনেট বা সার্ভার ছাড়া কাজ + ব্লুটুথ সক্রিয় করুন + ব্লুটুথ সমর্থিত নয় + এই ডিভাইস Bluetooth Low Energy (BLE) সমর্থন করে না, যা bitchat কাজ করার জন্য প্রয়োজন।\n\nbitchat এর মেশ নেটওয়ার্ক তৈরি এবং ইন্টারনেট ছাড়াই কাছাকাছি ডিভাইসের সাথে যোগাযোগের জন্য BLE প্রয়োজন। + ব্লুটুথ অবস্থা পরীক্ষা করা হচ্ছে … + ব্যাটারি অপ্টিমাইজেশন সনাক্ত + ব্যাটারি অপ্টিমাইজেশন সক্রিয় + মেশ সংযোগ বজায় রাখতে bitchat অবশ্যই ব্যাকগ্রাউন্ডে চলতে হবে। ব্যাটারি অপ্টিমাইজেশন এই সংযোগগুলি বাধাগ্রস্ত করতে পারে। + নিষ্ক্রিয় করার সুবিধা + • নির্ভরযোগ্য বার্তা ডেলিভারি\n• মেশ সংযোগ বজায় রাখা\n• সংযোগ বিচ্ছিন্নতা প্রতিরোধ + ব্যাটারি অপ্টিমাইজেশন নিষ্ক্রিয় করুন + ব্যাটারি অপ্টিমাইজেশন নিষ্ক্রিয় + চালিয়ে যান + মেশ নেটওয়ার্ক শুরু করা হচ্ছে + ব্লুটুথ মেশ নেটওয়ার্ক সেটআপ করা হচ্ছে … + এটি মাত্র কয়েক সেকেন্ড সময় নেওয়া উচিত + সেটআপ সম্পূর্ণ নয় + আবার চেষ্টা করুন + আপনার গোপনীয়তা সুরক্ষিত + • কোন ট্র্যাকিং বা ডেটা সংগ্রহ নেই\n• ব্লুটুথ মেশ চ্যাট সম্পূর্ণ অফলাইন\n• জিওহ্যাশ চ্যাট ইন্টারনেট ব্যবহার করে + অনুমতি + অনুমতি দিন + bitchat আপনার অবস্থান ট্র্যাক করে না + কেউ আশেপাশে নেই … + (আপনি) + জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন + নির্বাচন করুন + বার্তা টাইপ করুন … + উল্লেখ + v%1$s + image/* + ছবি + ফাইল + + + 📷 একটি ছবি পাঠিয়েছে + 🎤 একটি ভয়েস বার্তা পাঠিয়েছে + 📎 একটি ফাইল পাঠিয়েছে + + + প্লে করুন + বিরাম + + + ব্লুটুথের মাধ্যমে bitchat ব্যবহারকারী আবিষ্কার করতে প্রয়োজন + bitchat কে কাছাকাছি ডিভাইসের সাথে সংযোগ করার অনুমতি দিন + ব্লুটুথের মাধ্যমে কাছাকাছি bitchat ব্যবহারকারী আবিষ্কার করার জন্য Android এর প্রয়োজন + bitchat এর কাছাকাছি ডিভাইস স্ক্যান করার জন্য এটি প্রয়োজন + ব্যক্তিগত বার্তা পেলে বিজ্ঞপ্তি পান + bitchat কে আপনাকে বিজ্ঞপ্তি পাঠানোর অনুমতি দিন + bitchat নির্ভরযোগ্যভাবে ব্যাকগ্রাউন্ডে চলে এবং মেশ নেটওয়ার্ক সংযোগ বজায় রাখে তা নিশ্চিত করতে ব্যাটারি অপ্টিমাইজেশন নিষ্ক্রিয় করুন + bitchat কে ব্যাটারি সীমাবদ্ধতা ছাড়াই চলার অনুমতি দিন + + + কাছাকাছি ডিভাইস + সঠিক অবস্থান + মাইক্রোফোন + বিজ্ঞপ্তি + ব্যাটারি অপ্টিমাইজেশন + অন্যান্য + + + চ্যানেল পাসওয়ার্ড লিখুন + চ্যানেল %1$s পাসওয়ার্ড দিয়ে সুরক্ষিত। যোগ দিতে পাসওয়ার্ড লিখুন। + পাসওয়ার্ড + যোগ দিন + বাতিল + + + + এবং %d আরো + এবং %d আরো + + + + ১ জন + %d জন + + + ব্লুটুথ প্রস্তাবিত + মেশ চলছে — %1$d পিয়ার + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + পরিচিতি খুলুন + এখানে রাখা নোট আছে কি না দেখুন + এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন + এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন + ভাষা + অ্যাপের ভাষা + সিস্টেম ডিফল্ট + ভাষা নির্বাচন করুন + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ae557cb6..75cc8927 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,20 +1,397 @@ bitchat - Für Peer-to-Peer-Messaging ohne Internetverbindung ist eine Bluetooth-Berechtigung erforderlich. - Um Geräte in der Nähe per Bluetooth zu erkennen, ist eine Standortberechtigung erforderlich. - Um dich über neue Nachrichten zu informieren, ist eine Benachrichtigungsberechtigung erforderlich. + + + Die Bluetooth‑Berechtigung ist erforderlich, um ohne Internet direkt mit anderen zu chatten. + Die Standort‑Berechtigung ist erforderlich, um in der Nähe befindliche Geräte per Bluetooth zu finden. + Die Benachrichtigungs‑Berechtigung ist erforderlich, um dich über neue Nachrichten zu informieren. + + Spitzname Nachricht eingeben… Passwort Kanal beitreten Verlassen Senden + Befehle anzeigen Zurück Personen Kanäle - Online-Benutzer - Keine Verbindung + Online‑Benutzer + Niemand verbunden Dreifach tippen, um alle Daten zu löschen - Ihr Netzwerk + Netzwerk + + + Akku‑Optimierung erkannt + Akku‑Optimierung deaktiviert + Akku‑Optimierung nicht erforderlich + Akku‑Optimierung wird geprüft + Warum Akku‑Optimierung deaktivieren? + bitchat läuft im Hintergrund, um Mesh‑Verbindungen zu nahegelegenen Geräten aufrechtzuerhalten. Die Akku‑Optimierung kann diese Verbindungen unterbrechen, wodurch Nachrichten verspätet zugestellt werden oder verloren gehen.\n\nDas Deaktivieren der Akku‑Optimierung sorgt für eine zuverlässige Peer‑to‑Peer‑Kommunikation. + Akku‑Optimierung deaktivieren + Hinweis: Du kannst dies später unter Android‑Einstellungen > Apps > bitchat > Akku ändern. + Dein Gerät benötigt keine Akku‑Optimierungs­einstellungen. bitchat läuft normal. + Dein Gerät benötigt keine Akku‑Optimierungs­einstellungen. bitchat läuft normal. + bitchat kann zuverlässig im Hintergrund laufen + • Zuverlässige Zustellung von Nachrichten\n• Aufrechterhaltung der Mesh‑Verbindung\n• Hintergrundweiterleitung von Nachrichten\n• Verhindert Verbindungsabbrüche + Erneut prüfen + Jetzt überspringen + Fortfahren + Erneut versuchen + Überspringen + + + und %1$d weitere + %1$d Nachrichten von %2$d Personen + und %1$d weitere Unterhaltungen + bitchatter in der Nähe! + 1 Person in der Nähe + %1$d Personen in der Nähe + Neue Nachrichten + Neue Standort‑Nachrichten + Erwähnt in %1$s + Erwähnt in %1$s (+%2$d weitere) + %1$d Erwähnungen in %2$s + Neue Aktivität in %1$s + Nachrichten in %1$s + %1$s ist der Unterhaltung beigetreten + bitchat – %1$d Erwähnungen + bitchat – Orts‑Chats + %1$d Nachrichten aus %2$d Orten + In Mesh‑Chat erwähnt + %1$d Erwähnungen im Mesh‑Chat + und %1$d weitere Orte + und %1$d weitere + + + Zu Favoriten hinzufügen + Aus Favoriten entfernen + Lesezeichen hinzufügen + + + Über Nostr erreichbar + Ungelesene private Nachrichten + Teleportiert + Standort- und Kanaleinstellungen öffnen + Verbundenen Peers + Geohash‑Teilnehmer + Bereit für Handshake + Handshake läuft + Ende‑zu‑Ende verschlüsselt + Handshake fehlgeschlagen + + + 📎 Datei empfangen + 📄 %1$s + 📏 Größe: %1$s + 🏷️ Typ: %1$s + 📂 Öffnen / Speichern + ❌ Schließen + Bild auswählen + + + Offline‑Mesh‑Chat + Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen. + Online‑Geohash‑Kanäle + Vernetze dich mit Menschen in deiner Umgebung über geohash‑basierte Kanäle. Erweitere das Mesh über öffentliche Internet‑Relays. + Ende‑zu‑Ende‑Verschlüsselung + Private Nachrichten sind verschlüsselt. Kanal‑Nachrichten sind öffentlich. + System + Hell + Dunkel + Proof of Work + PoW aus + PoW an + Füge Geohash‑Nachrichten einen Proof of Work hinzu, um Spam zu erschweren. + Schwierigkeit: %1$d Bit (~%2$s) + Schwierigkeit %1$d erfordert ~%2$s Hash‑Versuche + kein Proof of Work erforderlich + sehr niedrig – minimaler Spamschutz + niedrig – grundlegender Spamschutz + mittel – guter Spamschutz + hoch – starker Spamschutz + sehr hoch – kann Verzögerungen verursachen + extrem – erhebliche Rechenleistung erforderlich + Netzwerk + Tor aus + Tor an + Leite Internetverkehr über Tor für mehr Privatsphäre. + Tor‑Status: %1$s, Bootstrap %2$d%% + Zuletzt: %1$s + Notfall‑Datenlöschung + Debug‑Einstellungen + Open Source • Privacy First • Dezentral + Schließen + abbrechen + + + Warnung + Standortdienste + Privatsphäre + Fehler + Akku‑Optimierung + Vorteile + Akku‑Optimierung wird geprüft + Akku‑Optimierung nicht unterstützt + Bluetooth + Ungelesene Nachricht + Karte öffnen + Lesezeichen entfernen + Teleportieren + Kanal verlassen + Über Nostr erreichbar + Favorit (offline) + Genauigkeit verringern + Genauigkeit erhöhen + Geohash auswählen + Nach unten scrollen + Datei + Bild + Abbrechen + Link + Medien auswählen + Datei auswählen + Aktuelles Bild speichern + Schließen + Bild %1$d von %2$d + Privatsphäre geschützt + Sprachnotiz aufnehmen + Offline‑Mesh‑Chat + Online‑Geohash‑Kanäle + Ende‑zu‑Ende‑Verschlüsselung + + + Bild %1$d von %2$d + Bild nicht verfügbar + Bild in "Downloads" gespeichert + Bild konnte nicht gespeichert werden + Bild in "Downloads" gespeichert + Bild konnte nicht gespeichert werden + Datei auswählen + [Datei nicht verfügbar] + unbekannt + + + Aktion für diese Nachricht oder diesen Benutzer wählen + Aktion für diesen Benutzer wählen + Nachricht kopieren + Diese Nachricht in die Zwischenablage kopieren + %1$s ohrfeigen + Eine spielerische Ohrfeige senden + %1$s umarmen + Eine freundliche Umarmung senden + %1$s blockieren + Alle Nachrichten dieses Benutzers blockieren + + + #Standort‑Kanäle + Chatte mit Menschen in deiner Nähe über Geohash‑Kanäle. Es wird nur ein grober Geohash geteilt, niemals exakte GPS‑Daten. Mache zur Wahrung deiner Privatsphäre keine Screenshots und teile diesen Bildschirm nicht. + Standortberechtigung erteilen + Standortberechtigung verweigert. In den Einstellungen aktivieren, um Standort‑Kanäle zu nutzen. + Einstellungen öffnen + \u2713 Standortberechtigung erteilt + Berechtigungen werden geprüft … + Nahe Kanäle werden gesucht … + Lesezeichen + Geohash + Ungültiger Geohash + teleportieren + Standortdienste deaktivieren + Standortdienste aktivieren + mesh + Block + Nachbarschaft + Stadt + Provinz + Region + #bluetooth • %1$s + + + Debug‑Werkzeuge + Entwickler‑Werkzeuge für Diagnose und Steuerung + ausführliches Logging + Protokolliert Peer‑Beitritte/‑Austritte, Verbindungsrichtung, Paketweiterleitung und Relays + Bluetooth‑Rollen + GATT‑Server + Verbindungen: %1$d / %2$d + max. Server + GATT‑Client + max. Client + Verbindungen: %1$d / %2$d + max. gesamt + Paket‑Relay + seit Start: %1$d + Rollen ein/aus schalten und beim Deaktivieren alle Verbindungen schließen + Sync‑Einstellungen + max. Pakete pro Sync: %1$d + max. GCS‑Filtergröße: %1$d Bytes (128–1024) + Ziel‑FPR: %1$.2f%% + verbundene Geräte + unsere Geräte‑ID: %1$s + keine + trennen + zuletzt gescannte Geräte + verbinden + Debug‑Konsole + leeren + letzte 10s: %1$d • 1m: %2$d • 15m: %3$d + abgeleitetes P: %1$s • geschätzte max. Elemente: %2$s + • direkt + als Server (wir hosten) + als Client (wir verbinden) + + + Standortdienste erforderlich + Privatsphäre zuerst + bitchat verfolgt NICHT deinen Standort.\n\nStandortdienste sind für Bluetooth‑Scanning und das Geohash‑Chat‑Feature erforderlich. + bitchat benötigt Standortdienste für: + • Bluetooth‑Gerätescanning\n• Entdecken nahegelegener Benutzer im Mesh‑Netzwerk\n• Geohash‑Chat‑Feature\n• Keine Verfolgung oder Standortsammlung + Standorteinstellungen öffnen + Erneut prüfen + Standortdienste nicht verfügbar + Standortdienste sind auf diesem Gerät nicht verfügbar. Das ist ungewöhnlich, da Standortdienste bei Android‑Geräten Standard sind.\n\nbitchat benötigt Standortdienste für das ordnungsgemäße Funktionieren des Bluetooth‑Scannings (Android‑Anforderung). Ohne diese kann die App keine nahegelegenen Benutzer entdecken. + Standortdienste werden geprüft … + Bluetooth erforderlich + bitchat benötigt Bluetooth für: + • Entdecken nahegelegener Benutzer\n• Erstellen von Mesh‑Netzwerkverbindungen\n• Senden und Empfangen von Nachrichten\n• Funktionieren ohne Internet oder Server + Bluetooth aktivieren + Bluetooth nicht unterstützt + Dieses Gerät unterstützt kein Bluetooth Low Energy (BLE), das für das Funktionieren von bitchat erforderlich ist.\n\nbitchat benötigt BLE, um Mesh‑Netzwerke zu erstellen und ohne Internet mit nahegelegenen Geräten zu kommunizieren. + Bluetooth‑Status wird geprüft … + Akku‑Optimierung erkannt + Akku‑Optimierung aktiviert + bitchat muss im Hintergrund laufen, um Mesh‑Verbindungen aufrechtzuerhalten. Die Akku‑Optimierung kann diese Verbindungen unterbrechen. + Vorteile der Deaktivierung + • zuverlässige Nachrichtenzustellung\n• Aufrechterhaltung der Mesh‑Konnektivität\n• verhindert Verbindungsabbrüche + Akku‑Optimierung deaktivieren + Akku‑Optimierung deaktiviert + Fortfahren + Mesh‑Netzwerk wird initialisiert + Bluetooth‑Mesh‑Netzwerk wird eingerichtet … + Das sollte nur wenige Sekunden dauern + Setup nicht abgeschlossen + Erneut versuchen + Deine Privatsphäre ist geschützt + • keine Verfolgung oder Datensammlung\n• Bluetooth‑Mesh‑Chats sind vollständig offline\n• Geohash‑Chats nutzen das Internet + Berechtigungen + Berechtigungen erteilen + bitchat verfolgt NICHT deinen Standort + niemand da … + (du) + schwenken und zoomen, um einen Geohash auszuwählen + auswählen + Nachricht eingeben … + erwähnen + v%1$s + image/* + Bild + Datei + + + 📷 hat ein Bild gesendet + 🎤 hat eine Sprachnachricht gesendet + 📎 hat eine Datei gesendet + + + Abspielen + Pause + + + Erforderlich, um bitchat‑Benutzer über Bluetooth zu entdecken + Erlaube bitchat, sich mit nahegelegenen Geräten zu verbinden + Von Android erforderlich, um nahegelegene bitchat‑Benutzer über Bluetooth zu entdecken + bitchat benötigt dies, um nach nahegelegenen Geräten zu scannen + Benachrichtigungen erhalten, wenn du private Nachrichten erhältst + Erlaube bitchat, dir Benachrichtigungen zu senden + Akku‑Optimierung deaktivieren, um sicherzustellen, dass bitchat zuverlässig im Hintergrund läuft und Mesh‑Netzwerkverbindungen aufrechterhält + Erlaube bitchat, ohne Akku‑Beschränkungen zu laufen + + + Nahegelegene Geräte + Genauer Standort + Mikrofon + Benachrichtigungen + Akku‑Optimierung + Andere + + + Kanalpasswort eingeben + Kanal %1$s ist passwortgeschützt. Gib zum Beitreten das Passwort ein. + Passwort + Beitreten + Abbrechen + + + + und %d weitere + und %d weitere + + + + 1 Person + %d Personen + + + + + #%1$s ± 1 • %2$d Notiz + #%1$s ± 1 • %2$d Notizen + + füge kurze dauerhafte Notizen zu diesem Ort hinzu, damit andere Besucher sie finden. + Geo-Relays nicht verfügbar; Notizen pausiert + keine Geo-Relays in der Nähe + Notizen basieren auf Geo-Relays. Verbindung prüfen und erneut versuchen. + Notizen werden geladen… + noch keine Notizen + sei der Erste, der hier eine Notiz hinterlässt. + schließen + füge eine Notiz zu diesem Ort hinzu + Bluetooth empfohlen + Mesh läuft — %1$d Peers + + verifizieren + scannen zur verifizierung + anderen qr scannen + anderen qr scannen + mein qr zeigen + verifizierung entfernen + qr nicht verfügbar + kamera-berechtigung nötig + kamera aktivieren + verifizierungs-url einfügen + validieren + verifizierung angefordert + sicherheitsüberprüfung + ihr fingerabdruck + dein fingerabdruck + handshake ausstehend + privatchat öffnen + verschlüsselt & verifiziert + verschlüsselt + handshake + handshake fehlgeschlagen + unverschlüsselt + verifiziert + du hast diese person verifiziert. + nicht verifiziert + vergleiche fingerabdrücke mit %1$s. + als verifiziert markieren + handshake starten + kopieren + Gegenseitige Verifizierung + Du und %1$s habt euch verifiziert + gegenseitige verifizierung mit %1$s + Verifiziert + Du hast %1$s verifiziert + verifiziert %1$s + Info öffnen + nachsehen, ob hier notizen hinterlassen wurden + 1 notiz hier hinterlassen — tippen zum lesen + %d notizen hier hinterlassen — tippen zum lesen + Sprache + App-Sprache + Systemstandard + Sprache auswählen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b3a998e2..3f08ead0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,20 +1,396 @@ bitchat - Se requiere permiso de Bluetooth para la mensajería peer-to-peer sin internet. - Se requiere permiso de ubicación para descubrir dispositivos cercanos por Bluetooth. - Se requiere permiso de notificación para avisarte de nuevos mensajes. - apodo + + + El permiso de Bluetooth es necesario para chatear directamente con otros sin internet. + El permiso de ubicación es necesario para encontrar dispositivos cercanos a través de Bluetooth. + El permiso de notificaciones es necesario para alertarte sobre nuevos mensajes. + + + Apodo Escribe un mensaje… Contraseña Unirse al canal Salir Enviar + Mostrar comandos Atrás Personas Canales Usuarios en línea Nadie conectado - Tres toques para borrar todos los datos + Toca tres veces para borrar todos los datos Tu red + + + Optimización de batería detectada + Optimización de batería deshabilitada + Optimización de batería no requerida + Verificando optimización de batería + ¿Por qué deshabilitar la optimización de batería? + bitchat funciona en segundo plano para mantener conexiones de red mesh con dispositivos cercanos. La optimización de batería puede interrumpir estas conexiones, causando que los mensajes lleguen tarde o se pierdan.\n\nDeshabilitar la optimización de batería asegura una comunicación peer-to-peer confiable. + Deshabilitar optimización de batería + Nota: Puedes cambiar esto más tarde en Configuración de Android > Apps > bitchat > Batería. + Tu dispositivo no requiere configuraciones de optimización de batería. bitchat funcionará normalmente. + Tu dispositivo no requiere configuraciones de optimización de batería. bitchat funcionará normalmente. + bitchat puede funcionar de manera confiable en segundo plano + • Entrega confiable de mensajes\n• Mantiene conexión de red mesh\n• Retransmisión de mensajes en segundo plano\n• Previene desconexiones + Verificar de nuevo + Omitir por ahora + Continuar + Reintentar + Omitir + + + y %1$d más + %1$d mensajes de %2$d personas + y %1$d conversaciones más + ¡bitchatters cerca! + 1 persona cerca + %1$d personas cerca + Nuevos mensajes + Nuevos mensajes de ubicación + Mencionado en %1$s + Mencionado en %1$s (+%2$d más) + %1$d menciones en %2$s + Nueva actividad en %1$s + Mensajes en %1$s + %1$s se unió a la conversación + bitchat – %1$d menciones + bitchat – Chats de ubicación + %1$d mensajes desde %2$d ubicaciones + Mencionado en chat mesh + %1$d menciones en chat mesh + y %1$d ubicaciones más + y %1$d más + + + Agregar a favoritos + Quitar de favoritos + Agregar marcador + + + Accesible vía Nostr + Mensajes privados sin leer + Teletransportado + Abrir ajustes de ubicación y canales + Pares conectados + Participantes geohash + Listo para handshake + Handshake en progreso + Cifrado de extremo a extremo + Handshake fallido + + + 📎 Archivo recibido + 📄 %1$s + 📏 Tamaño: %1$s + 🏷️ Tipo: %1$s + 📂 Abrir / Guardar + ❌ Cerrar + Elegir imagen + + + Chat mesh sin conexión + Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance. + Canales geohash en línea + Conéctate con personas cerca de ti a través de canales basados en geohash. Extiende la red mesh a través de repetidores de internet público. + Cifrado de extremo a extremo + Los mensajes privados están cifrados. Los mensajes de canal son públicos. + Sistema + Claro + Oscuro + Prueba de trabajo + PoW desactivado + PoW activado + Agregar prueba de trabajo a mensajes geohash para hacer el spam más difícil. + Dificultad: %1$d bits (~%2$s) + Dificultad %1$d requiere ~%2$s intentos de hash + no se requiere prueba de trabajo + muy bajo – protección mínima contra spam + bajo – protección básica contra spam + medio – buena protección contra spam + alto – protección fuerte contra spam + muy alto – puede causar retrasos + extremo – requiere potencia computacional significativa + Red + Tor desactivado + Tor activado + Enrutar tráfico de internet a través de Tor para mayor privacidad. + Estado de Tor: %1$s, bootstrap %2$d%% + Último: %1$s + Borrado de datos de emergencia + Configuraciones de depuración + Código abierto • Privacidad primero • Descentralizado + Cerrar + cancelar + + + Advertencia + Servicios de ubicación + Privacidad + Error + Optimización de batería + Beneficios + Verificando optimización de batería + Optimización de batería no soportada + Bluetooth + Mensaje sin leer + Abrir mapa + Quitar marcador + Teletransportar + Salir del canal + Accesible vía Nostr + Favorito (sin conexión) + Disminuir precisión + Aumentar precisión + Seleccionar geohash + Desplazar al final + Archivo + Imagen + Cancelar + Enlace + Elegir medios + Elegir archivo + Guardar imagen actual + Cerrar + Imagen %1$d de %2$d + Privacidad protegida + Grabar nota de voz + Chat mesh sin conexión + Canales geohash en línea + Cifrado de extremo a extremo + + + Imagen %1$d de %2$d + Imagen no disponible + Imagen guardada en "Descargas" + No se pudo guardar la imagen + Imagen guardada en "Descargas" + No se pudo guardar la imagen + Elegir archivo + [Archivo no disponible] + desconocido + + + Elegir acción para este mensaje o usuario + Elegir acción para este usuario + Copiar mensaje + Copiar este mensaje al portapapeles + Abofetear a %1$s + Enviar una bofetada juguetona + Abrazar a %1$s + Enviar un abrazo amistoso + Bloquear a %1$s + Bloquear todos los mensajes de este usuario + + + #Canales de ubicación + Chatea con personas cerca de ti a través de canales geohash. Solo se comparte un geohash aproximado, nunca datos GPS exactos. Para preservar tu privacidad, no tomes capturas de pantalla ni compartas esta pantalla. + Otorgar permiso de ubicación + Permiso de ubicación denegado. Habilítalo en configuraciones para usar canales de ubicación. + Abrir configuraciones + \u2713 Permiso de ubicación otorgado + Verificando permisos… + Buscando canales cercanos… + Marcado + Geohash + Geohash inválido + teletransportar + Deshabilitar servicios de ubicación + Habilitar servicios de ubicación + mesh + Cuadra + Barrio + Ciudad + Provincia + Región + #bluetooth • %1$s + + + Herramientas de depuración + Herramientas de desarrollador para diagnóstico y control + registro detallado + Registra uniones/salidas de pares, dirección de conexión, retransmisión de paquetes y repetidores + Roles de Bluetooth + Servidor GATT + Conexiones: %1$d / %2$d + máx. servidor + Cliente GATT + máx. cliente + Conexiones: %1$d / %2$d + máx. general + Retransmisión de paquetes + desde inicio: %1$d + Alternar roles activado/desactivado y cerrar todas las conexiones al desactivar + Configuraciones de sincronización + máx. paquetes por sincronización: %1$d + máx. tamaño del filtro GCS: %1$d bytes (128–1024) + FPR objetivo: %1$.2f%% + dispositivos conectados + nuestro ID de dispositivo: %1$s + ninguno + desconectar + dispositivos escaneados recientemente + conectar + Consola de depuración + limpiar + últimos 10s: %1$d • 1m: %2$d • 15m: %3$d + P derivado: %1$s • máx. elementos estimados: %2$s + • directo + como servidor (nosotros hospedamos) + como cliente (nosotros conectamos) + + + Servicios de ubicación requeridos + Privacidad primero + bitchat NO rastrea tu ubicación.\n\nLos servicios de ubicación son requeridos para el escaneo Bluetooth y la función de chat geohash. + bitchat necesita servicios de ubicación para: + • Escaneo de dispositivos Bluetooth\n• Descubrir usuarios cercanos en la red mesh\n• Función de chat geohash\n• Sin rastreo o recolección de ubicación + Abrir configuraciones de ubicación + Verificar de nuevo + Servicios de ubicación no disponibles + Los servicios de ubicación no están disponibles en este dispositivo. Esto es inusual ya que los servicios de ubicación son estándar en dispositivos Android.\n\nbitchat requiere servicios de ubicación para el funcionamiento adecuado del escaneo Bluetooth (requisito de Android). Sin esto, la app no puede descubrir usuarios cercanos. + Verificando servicios de ubicación… + Bluetooth requerido + bitchat necesita Bluetooth para: + • Descubrir usuarios cercanos\n• Crear conexiones de red mesh\n• Enviar y recibir mensajes\n• Funcionar sin internet o servidores + Habilitar Bluetooth + Bluetooth no soportado + Este dispositivo no soporta Bluetooth Low Energy (BLE), que es requerido para que bitchat funcione.\n\nbitchat requiere BLE para crear redes mesh y comunicarse con dispositivos cercanos sin internet. + Verificando estado de Bluetooth… + Optimización de batería detectada + Optimización de batería habilitada + bitchat necesita funcionar en segundo plano para mantener conexiones mesh. La optimización de batería puede interrumpir estas conexiones. + Beneficios de deshabilitar + • entrega confiable de mensajes\n• mantiene conectividad mesh\n• previene desconexiones + Deshabilitar optimización de batería + Optimización de batería deshabilitada + Continuar + Inicializando red mesh + Configurando red mesh Bluetooth… + Esto debería tomar solo unos segundos + Configuración no completada + Intentar de nuevo + Tu privacidad está protegida + • sin rastreo o recolección de datos\n• chats mesh Bluetooth son completamente offline\n• chats geohash usan internet + Permisos + Otorgar permisos + bitchat NO rastrea tu ubicación + nadie por aquí… + (tú) + desplaza y hace zoom para seleccionar un geohash + seleccionar + Escribe un mensaje… + mencionar + v%1$s + image/* + Imagen + Archivo + + + 📷 envió una imagen + 🎤 envió una nota de voz + 📎 envió un archivo + + + Reproducir + Pausar + + + Requerido para descubrir usuarios bitchat a través de Bluetooth + Permitir que bitchat se conecte con dispositivos cercanos + Requerido por Android para descubrir usuarios bitchat cercanos a través de Bluetooth + bitchat necesita esto para escanear dispositivos cercanos + Recibir notificaciones cuando recibas mensajes privados + Permitir que bitchat te envíe notificaciones + Deshabilitar optimización de batería para asegurar que bitchat funcione de manera confiable en segundo plano y mantenga conexiones de red mesh + Permitir que bitchat funcione sin restricciones de batería + + + Dispositivos cercanos + Ubicación precisa + Micrófono + Notificaciones + Optimización de batería + Otro + + + Ingresar contraseña del canal + El canal %1$s está protegido con contraseña. Ingresa la contraseña para unirte. + Contraseña + Unirse + Cancelar + + + + y %d más + y %d más + + + + 1 persona + %d personas + + + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d notas + + agrega notas cortas y permanentes a este lugar para que otros visitantes las encuentren. + relés geográficos no disponibles; notas en pausa + no hay relés geográficos cercanos + las notas dependen de relés geográficos. verifica la conexión e inténtalo de nuevo. + cargando notas… + aún no hay notas + sé el primero en añadir una para este sitio. + cerrar + agrega una nota para este lugar + Bluetooth recomendado + Mesh en ejecución — %1$d pares + + verificar + escanear para verificarme + escanear qr de otro + escanear qr de otro + mostrar mi qr + eliminar verificación + qr no disponible + se necesita permiso de cámara + activar cámara + pegar url de verificación + validar + verificación solicitada + verificación de seguridad + su huella digital + tu huella digital + intercambio pendiente + abrir chat privado para ver + cifrado y verificado + cifrado + negociando + falló negociación + no cifrado + verificado + has verificado esta identidad. + no verificado + compara huellas con %1$s por canal seguro. + marcar verificado + iniciar handshake + copiar + Verificación mutua + Tú y %1$s se verificaron + verificación mutua con %1$s + Verificado + Verificaste a %1$s + verificado %1$s + Abrir Acerca de + buscar notas dejadas aquí + 1 nota dejada aquí — toca para leer + %d notas dejadas aquí — toca para leer + Idioma + Idioma de la app + Predeterminado del sistema + Seleccionar idioma diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml new file mode 100644 index 00000000..1e092b64 --- /dev/null +++ b/app/src/main/res/values-fa/strings.xml @@ -0,0 +1,383 @@ + + + bitchat + + + برای چت مستقیم با دیگران بدون اینترنت، به مجوز بلوتوث نیاز است. + برای یافتن دستگاه‌های نزدیک از طریق بلوتوث، به مجوز مکان نیاز است. + برای آگاه‌سازی از پیام‌های جدید، به مجوز اعلان نیاز است. + + + نام مستعار + نوشتن پیام… + گذرواژه + پیوستن به کانال + خروج + ارسال + نمایش دستورات + بازگشت + افراد + کانال‌ها + کاربران آنلاین + هیچ‌کس متصل نیست + برای پاک کردن همهٔ داده‌ها سه بار ضربه بزنید + شبکه + + + بهینه‌سازی باتری شناسایی شد + بهینه‌سازی باتری غیرفعال شد + بهینه‌سازی باتری لازم نیست + در حال بررسی بهینه‌سازی باتری + چرا بهینه‌سازی باتری را غیرفعال کنیم؟ + bitchat برای حفظ اتصال مِش با دستگاه‌های نزدیک در پس‌زمینه اجرا می‌شود. بهینه‌سازی باتری می‌تواند این اتصال‌ها را قطع کند و باعث تأخیر یا از دست رفتن پیام‌ها شود.\n\nغیرفعال‌کردن بهینه‌سازی باتری، ارتباط همتابه‌همتای قابل اعتماد را تضمین می‌کند. + غیرفعال‌کردن بهینه‌سازی باتری + توجه: می‌توانید بعداً در تنظیمات اندروید > برنامه‌ها > bitchat > باتری این مورد را تغییر دهید. + دستگاه شما به تنظیمات بهینه‌سازی باتری نیاز ندارد. bitchat به‌صورت عادی اجرا می‌شود. + دستگاه شما به تنظیمات بهینه‌سازی باتری نیاز ندارد. bitchat به‌صورت عادی اجرا می‌شود. + bitchat می‌تواند با اطمینان در پس‌زمینه اجرا شود + • تحویل مطمئن پیام‌ها\n• حفظ اتصال مِش\n• رلهٔ پیام در پس‌زمینه\n• جلوگیری از قطع اتصال + بررسی مجدد + فعلاً رد کردن + ادامه + تلاش دوباره + رد کردن + + + و %1$d مورد دیگر + %1$d پیام از %2$d نفر + و %1$d گفت‌وگوی دیگر + کاربران bitchat در نزدیکی! + ۱ نفر در نزدیکی + %1$d نفر در نزدیکی + پیام‌های جدید + پیام‌های مکانی جدید + ذکر شده در %1$s + ذکر شده در %1$s (+%2$d مورد دیگر) + %1$d مورد ذکر در %2$s + فعالیت جدید در %1$s + پیام‌ها در %1$s + %1$s به گفت‌وگو پیوست + bitchat – %1$d مورد ذکر + bitchat – گفت‌وگوی مکانی + %1$d پیام از %2$d موقعیت + ذکر در گفت‌وگوی مِش + %1$d مورد ذکر در گفت‌وگوی مِش + و %1$d موقعیت دیگر + و %1$d مورد دیگر + + + افزودن به موارد دلخواه + حذف از موارد دلخواه + افزودن نشانک + + + قابل دسترس از طریق Nostr + پیام‌های خصوصی خوانده‌نشده + انتقال مکانی + باز کردن تنظیمات مکان و کانال‌ها + همتایان متصل + شرکت‌کنندگان geohash + آمادهٔ دست‌دهی + دست‌دهی در حال انجام + رمزگذاری سرتاسری + دست‌دهی ناموفق بود + + + 📎 فایل دریافت شد + 📄 %1$s + 📏 اندازه: %1$s + 🏷️ نوع: %1$s + 📂 باز کردن / ذخیره + ❌ بستن + انتخاب تصویر + + + گفت‌وگوی مِش آفلاین + بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد. + کانال‌های geohash آنلاین + از طریق کانال‌های مبتنی بر geohash با افراد منطقهٔ خود ارتباط برقرار کنید. مِش را از طریق رله‌های عمومی اینترنت گسترش دهید. + رمزگذاری سرتاسری + پیام‌های خصوصی رمزگذاری می‌شوند. پیام‌های کانال عمومی هستند. + سامانه + روشن + تیره + گواه کار (Proof of Work) + PoW خاموش + PoW روشن + برای دشوارتر کردن هرزنامه، به پیام‌های geohash گواه کار بیفزایید. + سختی: %1$d بیت (~%2$s) + سختی %1$d به ~%2$s کوشش هش نیاز دارد + نیازی به گواه کار نیست + بسیار کم – کمینهٔ حفاظت در برابر هرزنامه + کم – حفاظت پایه در برابر هرزنامه + متوسط – حفاظت خوب + زیاد – حفاظت قوی + بسیار زیاد – ممکن است تأخیر ایجاد کند + فوق‌العاده – به توان محاسباتی قابل توجه نیاز دارد + شبکه + Tor خاموش + Tor روشن + برای حفظ بیشتر حریم خصوصی، ترافیک اینترنت را از طریق Tor مسیریابی کنید. + وضعیت Tor: %1$s، بوت‌استرپ %2$d%% + آخرین: %1$s + پاکسازی اضطراری داده + تنظیمات اشکال‌زدایی + متن‌باز • حریم خصوصی در اولویت • غیرمتمرکز + بستن + لغو + + + هشدار + خدمات مکان + حریم خصوصی + خطا + بهینه‌سازی باتری + مزایا + در حال بررسی بهینه‌سازی باتری + بهینه‌سازی باتری پشتیبانی نمی‌شود + بلوتوث + پیام خوانده‌نشده + باز کردن نقشه + حذف نشانک + انتقال + خروج از کانال + قابل دسترس از طریق Nostr + دلخواه (آفلاین) + کاهش دقت + افزایش دقت + انتخاب geohash + اسکرول به پایین + فایل + تصویر + لغو + پیوند + انتخاب رسانه + انتخاب فایل + ذخیرهٔ تصویر فعلی + بستن + تصویر %1$d از %2$d + حریم خصوصی محافظت شده است + ضبط پیام صوتی + گفت‌وگوی مِش آفلاین + کانال‌های geohash آنلاین + رمزگذاری سرتاسری + + + تصویر %1$d از %2$d + تصویر در دسترس نیست + تصویر در \"Downloads\" ذخیره شد + ذخیرهٔ تصویر ناموفق بود + تصویر در \"Downloads\" ذخیره شد + ذخیرهٔ تصویر ناموفق بود + انتخاب فایل + [فایل در دسترس نیست] + نامشخص + + + اقدامی برای این پیام یا کاربر انتخاب کنید + اقدامی برای این کاربر انتخاب کنید + کپی پیام + این پیام را در کلیپ‌بورد کپی کن + %1$s را سیلی بزن + یک سیلی شوخی ارسال کن + %1$s را بغل کن + یک بغل دوستانه ارسال کن + %1$s را مسدود کن + همهٔ پیام‌های این کاربر را مسدود کن + + + #کانال‌های مکانی + از طریق کانال‌های geohash با افراد نزدیک خود چت کنید. فقط geohash کلی به اشتراک گذاشته می‌شود، نه دادهٔ GPS دقیق. برای حفظ حریم خصوصی، لطفاً از این نما اسکرین‌شات نگیرید یا آن را به اشتراک نگذارید. + اعطای مجوز مکان + مجوز مکان رد شد. برای استفاده از کانال‌های مکانی، آن را در تنظیمات فعال کنید. + باز کردن تنظیمات + \u2713 مجوز مکان اعطا شد + در حال بررسی مجوزها … + در حال یافتن کانال‌های نزدیک … + نشانک شده + Geohash + geohash نامعتبر است + انتقال مکانی + غیرفعال‌کردن خدمات مکان + فعال‌کردن خدمات مکان + mesh + بلوک + محله + شهر + استان + منطقه + #bluetooth • %1$s + + + ابزارهای اشکال‌زدایی + ابزارهای توسعه‌دهنده برای عیب‌یابی و کنترل + گزارش‌گیری مفصل + پیوستن/ترک همتا، جهت اتصال، رلهٔ بسته‌ها و مسیرها را ثبت می‌کند + نقش‌های بلوتوث + سرور GATT + اتصال‌ها: %1$d / %2$d + حداکثر سرور + کلاینت GATT + حداکثر کلاینت + اتصال‌ها: %1$d / %2$d + حداکثر کل + رلهٔ بسته + از شروع: %1$d + روشن/خاموش‌کردن نقش‌ها و بستن همهٔ اتصال‌ها هنگام غیرفعالسازی + تنظیمات همگام‌سازی + حداکثر بسته در هر همگام‌سازی: %1$d + حداکثر اندازهٔ فیلتر GCS: %1$d بایت (128–1024) + FPR هدف: %1$.2f%% + دستگاه‌های متصل + شناسهٔ دستگاه ما: %1$s + هیچ + قطع اتصال + دستگاه‌های اخیراً اسکن‌شده + اتصال + کنسول اشکال‌زدایی + پاک‌کردن + ۱۰ث اخیر: %1$d • ۱د: %2$d • ۱۵د: %3$d + P مشتق: %1$s • عناصر حداکثر تخمینی: %2$s + • مستقیم + به عنوان سرور (ما میزبانیم) + به عنوان کلاینت (ما متصل می‌شویم) + + + خدمات مکان لازم است + حریم خصوصی در اولویت + bitchat موقعیت شما را پیگیری نمی‌کند.\n\nبرای اسکن بلوتوث و قابلیت گفت‌وگوی geohash، خدمات مکان لازم است. + bitchat به خدمات مکان برای موارد زیر نیاز دارد: + • اسکن دستگاه‌های بلوتوث\n• یافتن کاربران نزدیک در شبکهٔ مِش\n• قابلیت گفت‌وگوی geohash\n• بدون پیگیری یا جمع‌آوری موقعیت + باز کردن تنظیمات مکان + بررسی دوباره + خدمات مکان در دسترس نیست + خدمات مکان در این دستگاه در دسترس نیست. این غیرمعمول است زیرا خدمات مکان در دستگاه‌های اندرویدی استاندارد است.\n\nبرای عملکرد صحیح اسکن بلوتوث (الزام اندروید) bitchat به خدمات مکان نیاز دارد. بدون آن، برنامه نمی‌تواند کاربران نزدیک را بیابد. + در حال بررسی خدمات مکان … + بلوتوث لازم است + bitchat به بلوتوث برای موارد زیر نیاز دارد: + • یافتن کاربران نزدیک\n• ایجاد اتصال‌های شبکهٔ مِش\n• ارسال و دریافت پیام‌ها\n• کار بدون اینترنت یا سرور + فعال‌کردن بلوتوث + بلوتوث پشتیبانی نمی‌شود + این دستگاه از Bluetooth Low Energy (BLE) که برای کارکرد bitchat لازم است پشتیبانی نمی‌کند.\n\nbitchat برای ایجاد شبکهٔ مِش و ارتباط با دستگاه‌های نزدیک بدون اینترنت به BLE نیاز دارد. + در حال بررسی وضعیت بلوتوث … + بهینه‌سازی باتری شناسایی شد + بهینه‌سازی باتری فعال است + برای حفظ اتصال مِش، bitchat باید در پس‌زمینه اجرا شود. بهینه‌سازی باتری می‌تواند این اتصال‌ها را مختل کند. + مزایای غیرفعالسازی + • تحویل مطمئن پیام‌ها\n• حفظ اتصال مِش\n• جلوگیری از قطع اتصال + غیرفعال‌کردن بهینه‌سازی باتری + بهینه‌سازی باتری غیرفعال شد + ادامه + در حال راه‌اندازی شبکهٔ مِش + در حال تنظیم شبکهٔ مِش بلوتوث … + این کار فقط چند ثانیه طول می‌کشد + راه‌اندازی کامل نشد + تلاش دوباره + حریم خصوصی شما محافظت می‌شود + • بدون پیگیری یا جمع‌آوری داده\n• گفت‌وگوی مِش بلوتوث کاملاً آفلاین است\n• گفت‌وگوی geohash از اینترنت استفاده می‌کند + مجوزها + اعطای مجوزها + bitchat موقعیت شما را پیگیری نمی‌کند + کسی در اطراف نیست … + (شما) + برای انتخاب geohash، جابه‌جا و زوم کنید + انتخاب + نوشتن پیام … + ذکر + v%1$s + image/* + تصویر + فایل + + + 📷 یک تصویر ارسال کرد + 🎤 یک پیام صوتی ارسال کرد + 📎 یک فایل ارسال کرد + + + پخش + توقف + + + برای یافتن کاربران bitchat از طریق بلوتوث لازم است + به bitchat اجازه دهید با دستگاه‌های نزدیک متصل شود + برای یافتن کاربران نزدیک bitchat از طریق بلوتوث به‌وسیلهٔ اندروید لازم است + bitchat برای اسکن دستگاه‌های نزدیک به این مورد نیاز دارد + هنگام دریافت پیام خصوصی اعلان دریافت کنید + به bitchat اجازه دهید برای شما اعلان ارسال کند + بهینه‌سازی باتری را غیرفعال کنید تا اطمینان حاصل شود bitchat به‌طور قابل اعتماد در پس‌زمینه اجرا شده و اتصال‌های شبکهٔ مِش را حفظ می‌کند + به bitchat اجازه دهید بدون محدودیت باتری اجرا شود + + + دستگاه‌های نزدیک + مکان دقیق + میکروفون + اعلان‌ها + بهینه‌سازی باتری + سایر + + + گذرواژهٔ کانال را وارد کنید + کانال %1$s با گذرواژه محافظت می‌شود. برای پیوستن، گذرواژه را وارد کنید. + گذرواژه + پیوستن + لغو + + + + و %d مورد دیگر + و %d مورد دیگر + + + + ۱ نفر + %d نفر + + + بلوتوث توصیه می شود + مش در حال اجرا — %1$d همتا + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + باز کردن درباره + یادداشت‌های باقی‌مانده در اینجا را بررسی کنید + ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید + %d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید + زبان + زبان برنامه + پیش‌فرض سیستم + انتخاب زبان + diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml new file mode 100644 index 00000000..fa4aea0c --- /dev/null +++ b/app/src/main/res/values-fil/strings.xml @@ -0,0 +1,394 @@ + + + bitchat + + + Kailangan ang permiso sa Bluetooth para sa direktang pag‑usap kahit walang internet. + Kailangan ang permiso sa Lokasyon para mahanap ang mga kalapit na device gamit ang Bluetooth. + Kailangan ang permiso sa Abiso para ipaalam ang mga bagong mensahe. + + + palayaw + magtype ng mensahe… + Password + Sumali sa Channel + Umalis + Ipadala + Ipakita ang mga utos + Bumalik + Tao + Mga Channel + Mga Online na User + Walang nakakonekta + I‑tap nang tatlong beses para burahin ang lahat ng data + Network + + + Natukoy ang pag‑optimize ng baterya + Naka‑disable ang pag‑optimize ng baterya + Hindi kailangan ang pag‑optimize ng baterya + Sinusuri ang pag‑optimize ng baterya + Bakit i‑disable ang pag‑optimize? + Tumatakbo ang bitchat sa background upang mapanatili ang mesh na koneksyon sa mga kalapit na device. Maaaring maputol ito ng pag‑optimize ng baterya na nagdudulot ng pagkaantala o pagkawala ng mensahe.\n\nAng pag‑disable nito ay tinitiyak ang maaasahang peer‑to‑peer na pagmemensahe. + I‑disable ang pag‑optimize + Tandaan: Maaari itong baguhin sa Android Settings > Apps > bitchat > Battery + Hindi kailangan ng iyong device ng mga setting ng pag‑optimize ng baterya. Normal na gagana ang bitchat. + Hindi kailangan ng iyong device ng mga setting ng pag‑optimize ng baterya. Normal na gagana ang bitchat. + Maaasahang tatakbo ang bitchat sa background + • Maaasahang paghatid ng mensahe\n• Pinananatili ang mesh na koneksyon\n• Background relay ng mensahe\n• Iwas putol ng koneksyon + Suriin muli + Laktawan muna + Magpatuloy + Subukang muli + Laktawan + + + at %1$d pa + %1$d mensahe mula sa %2$d tao + at %1$d pang usapan + mga bitchatter na malapit! + 1 tao sa paligid + %1$d tao sa paligid + Mga bagong mensahe + Mga mensahe sa lokasyon + Nabanggit sa %1$s + Nabanggit sa %1$s (+%2$d pa) + %1$d banggit sa %2$s + Bagong aktibidad sa %1$s + Mga mensahe sa %1$s + Sumali si %1$s sa usapan + bitchat – %1$d banggit + bitchat – mga chat sa lokasyon + %1$d mensahe mula sa %2$d lokasyon + Nabanggit sa Mesh Chat + %1$d banggit sa Mesh Chat + at %1$d pang lokasyon + at %1$d pa + + + Idagdag sa paborito + Alisin sa paborito + Mag‑bookmark + + + Maabot sa Nostr + Hindi nabasang pribadong mensahe + Naiteleport + Buksan ang mga setting ng lokasyon at channel + Nakakonektang peers + Mga kalahok sa geohash + Handa para sa handshake + Isinasagawa ang handshake + End‑to‑end naka‑encrypt + Bigo ang handshake + + + 📎 Nakakuha ng file + 📄 %1$s + 📏 Laki: %1$s + 🏷️ Uri: %1$s + 📂 Buksan / I‑save + ❌ Isara + Pumili ng larawan + + + Offline Mesh Chat + Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device. + Online na Geohash na mga Channel + Makipag‑ugnayan sa mga tao sa paligid gamit ang geohash‑based na mga channel. Palawakin ang mesh sa pampublikong internet relays. + End‑to‑End Encryption + Ang mga pribadong mensahe ay naka‑encrypt. Ang mga mensahe sa channel ay publiko. + system + light + dark + proof of work + pow off + pow on + magdagdag ng proof of work sa mga geohash na mensahe para iwas spam. + hirap: %1$d bits (~%2$s) + ang hirap na %1$d ay ~%2$s hash attempts + walang proof of work na kailangan + napakababa – minimal na proteksyon + mababa – batayang proteksyon + katamtaman – magandang proteksyon + mataas – matibay na proteksyon + napakataas – posibleng may pagkaantala + matindi – malaking komputasyon ang kailangan + network + tor off + tor on + iruta ang internet sa Tor para sa mas mataas na privacy. + katayuan ng Tor: %1$s, bootstrap %2$d%% + Huli: %1$s + Emergency Data Deletion + Debug Settings + Open Source • Privacy First • Desentralisado + Isara + kanselahin + + + Babala + Mga serbisyo sa lokasyon + Privacy + Error + Pag‑optimize ng baterya + Mga benepisyo + Sinusuri ang pag‑optimize + Hindi suportado + Bluetooth + Hindi nabasang mensahe + Buksan ang mapa + Alisin ang bookmark + Teleport + Umalis sa channel + Maabot sa Nostr + Paborito (offline) + Bawasan ang precision + Dagdagan ang precision + Piliin ang geohash + Mag‑scroll pababa + File + Larawan + Kanselahin + Link + Pumili ng media + Mag‑record ng voice note + Offline Mesh Chat + Online na Geohash Channels + End‑to‑End Encryption + + + Larawan %1$d ng %2$d + Hindi available ang larawan + Na‑save ang larawan sa "Downloads" + Nabigo ang pag‑save ng larawan + Pumili ng file + [hindi available ang file] + hindi kilala + + + pumili ng aksyon para sa mensaheng ito o user + pumili ng aksyon para sa user na ito + kopyahin ang mensahe + kopyahin ang mensaheng ito sa clipboard + sapakin si %1$s + magpadala ng birong sampal + yakapin si %1$s + magpadala ng magiliw na yakap + harangan si %1$s + harangan ang lahat ng mensahe mula sa user na ito + + + #mga channel sa lokasyon + makipag‑usap sa mga tao sa paligid gamit ang mga geohash na channel. tanging malabong geohash ang ibinabahagi, hindi eksaktong gps. huwag mag‑screenshot o ibahagi ang screen na ito para sa privacy. + payagan ang lokasyon + tinanggihan ang permiso sa lokasyon. i‑enable sa settings para magamit ang mga channel sa lokasyon. + Buksan ang Settings + \u2713 pinayagan ang lokasyon + sini‑siyasat ang mga permiso… + naghahanap ng kalapit na channel… + na‑bookmark + geohash + maling geohash + teleport + i‑disable ang location services + i‑enable ang location services + #bluetooth • %1$s + mesh + bloke + barangay + lungsod + probinsya + rehiyon + + + mga kasangkapan sa debug + masalitang pag‑log + mga tungkulin sa Bluetooth + GATT server + mga koneksyon: %1$d / %2$d + max server + GATT client + max client + mga koneksyon: %1$d / %2$d + max kabuuan + packet relay + mula umpisa: %1$d + mga setting ng sync + max packet bawat sync: %1$d + max laki ng GCS filter: %1$d bytes (128–1024) + target FPR: %1$.2f%% + mga nakakonektang device + ID ng aming device: %1$s + wala + i‑disconnect + mga huling resulta ng scan + kumonekta + debug console + limasin + huling 10s: %1$d • 1m: %2$d • 15m: %3$d + hinangong P: %1$s • tantyang max na elemento: %2$s + • direkta + ? + bilang server (kami ang nagho‑host) + bilang client (kami ang kumokonekta) + + + Kailangang‑kailangan ang Location Services + Privacy muna + HINDI sinusubaybayan ng bitchat ang iyong lokasyon.\n\nKailangan ang location services para sa Bluetooth scanning at Geohash chat. + Kailangan ng bitchat ang location services para sa: + • Bluetooth device scanning\n• Pagtuklas ng kalapit na user sa mesh\n• Geohash chat\n• Walang tracking o koleksiyon ng lokasyon + Buksan ang Location Settings + Suriin muli + Hindi available ang Location Services + Hindi available ang location services sa device na ito. Hindi ito karaniwan dahil standard ito sa Android.\n\nKailangan ito para maayos ang Bluetooth scanning. Kung wala ito, hindi matutuklasan ang mga kalapit na user. + Sinusuri ang location services… + Kailangan ang Bluetooth + Kailangan ng bitchat ang Bluetooth para sa: + • Tuklasin ang mga kalapit na user\n• Gumawa ng mesh na koneksyon\n• Magpadala at tumanggap ng mensahe\n• Gumana kahit walang internet o server + I‑enable ang Bluetooth + Hindi suportado ang Bluetooth + Hindi sinusuportahan ng device na ito ang Bluetooth Low Energy (BLE) na kailangan para gumana ang bitchat.\n\nKailangan ang BLE para bumuo ng mesh at makipag‑ugnayan sa kalapit na device kahit walang internet. + Sinusuri ang status ng Bluetooth… + natukoy ang pag‑optimize ng baterya + Naka‑enable ang pag‑optimize + kailangang tumakbo ang bitchat sa background para sa mesh; maaaring putulin ito ng pag‑optimize. + Mga benepisyo ng pag‑disable + • maaasahang paghatid\n• panatilihin ang koneksyon\n• iwas putol + I‑disable ang pag‑optimize + na‑disable ang pag‑optimize + Magpatuloy + Ine‑initialize ang mesh network + . + Ise‑set up ang Bluetooth mesh… + Ilang segundo lang ito + ⚠️ + Hindi kumpleto ang setup + Subukang muli + Protektado ang iyong privacy + • walang tracking o koleksiyon\n• offline ang mesh chat sa Bluetooth\n• gumagamit ng internet ang geohash chat + mga permiso + Payagan + HINDI sinusubaybayan ng bitchat ang lokasyon mo + @ + · ⧉ + walang tao sa paligid… + (ikaw) + i‑pan at i‑zoom para pumili ng geohash + piliin + magtype ng mensahe… + @%1$s + banggit + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Larawan + File + + + + + 📷 nagpadala ng larawan + 🎤 nagpadala ng voice message + 📎 nagpadala ng file + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + I‑play + I‑pause + + + Kailangan upang makakita ng mga bitchat user sa Bluetooth + Payagan ang bitchat na kumonekta sa kalapit na device + Kailangan ng Android para sa pagtuklas ng user sa Bluetooth + Kailangan ito ng bitchat para mag‑scan + Tumanggap ng abiso sa mga pribadong mensahe + Payagan ang bitchat na magpadala ng abiso + I‑disable ang pag‑optimize upang tumakbo nang maaasahan at mapanatili ang mesh na koneksyon + Payagan ang bitchat na tumakbo nang walang limitasyong baterya + + + Kalapit na device + Eksaktong lokasyon + Mikropono + Mga abiso + Pag‑optimize ng baterya + Iba pa + + + Ilagay ang password ng channel + Ang channel na %1$s ay protektado. Ilagay ang password para sumali. + Password + Sumali + Kanselahin + + + + at %d pa + at %d pa + + + + 1 tao + %d tao + + + Bluetooth Recommended + Tumatakbo ang Mesh — %1$d na peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + Buksan ang Tungkol + tingnan kung may mga note na naiwan dito + 1 note ang naiwan dito — i-tap para basahin + %d note ang naiwan dito — i-tap para basahin + Wika + Wika ng app + Default ng system + Pumili ng wika + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 10d6d429..a884d35e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,20 +1,408 @@ bitchat - L'autorisation Bluetooth est requise pour la messagerie peer-à-peer sans Internet. - L'autorisation de localisation est requise pour détecter les appareils à proximité via Bluetooth. - L'autorisation de notification est requise pour vous avertir de l'arrivée de nouveaux messages. + + + L’autorisation Bluetooth est requise pour discuter en pair à pair sans internet. + L’autorisation de localisation est requise pour découvrir les appareils à proximité via Bluetooth. + L’autorisation de notifications est requise pour t’alerter des nouveaux messages. + + pseudo - Saisir un message… + écrire un message… Mot de passe Rejoindre le canal Quitter Envoyer + Afficher les commandes Retour Personnes Canaux Utilisateurs en ligne Personne connecté - Appuyer trois fois pour effacer toutes les données - Votre réseau + Tapoter 3 fois pour effacer toutes les données + Réseau + + + Optimisation de la batterie détectée + Optimisation de la batterie désactivée + Optimisation de la batterie non requise + Vérification de l’optimisation de la batterie + Pourquoi désactiver l’optimisation de la batterie ? + bitchat s’exécute en arrière-plan pour maintenir des connexions mesh avec les appareils à proximité. L’optimisation de la batterie peut interrompre ces connexions, entraînant des retards ou des pertes de messages.\n\nLa désactiver garantit une messagerie pair à pair fiable. + Désactiver l’optimisation + Remarque : tu peux modifier ce réglage plus tard dans Paramètres Android > Apps > bitchat > Batterie + Ton appareil n’a pas besoin de réglages d’optimisation de batterie. bitchat fonctionnera normalement. + Ton appareil n’a pas besoin de réglages d’optimisation de batterie. bitchat fonctionnera normalement. + bitchat peut fonctionner de manière fiable en arrière‑plan + • Livraison fiable des messages\n• Maintien de la connectivité mesh\n• Relais des messages en arrière‑plan\n• Évite les coupures + Vérifier à nouveau + Ignorer pour l’instant + Continuer + Réessayer + Ignorer + + + et %1$d de plus + %1$d messages de %2$d personnes + et %1$d conversations de plus + bitchatters à proximité ! + 1 personne à proximité + %1$d personnes à proximité + Nouveaux messages + Nouveaux messages de lieu + Mentionné dans %1$s + Mentionné dans %1$s (+%2$d de plus) + %1$d mentions dans %2$s + Nouvelle activité dans %1$s + Messages dans %1$s + %1$s a rejoint la conversation + bitchat – %1$d mentions + bitchat – discussions par lieu + %1$d messages depuis %2$d lieux + Mentionné dans le chat Mesh + %1$d mentions dans le chat Mesh + et %1$d lieux de plus + et %1$d de plus + + + Ajouter aux favoris + Retirer des favoris + Ajouter un signet + + + Joignable via Nostr + Messages privés non lus + Téléporté + Ouvrir les réglages de localisation et des canaux + Pairs connectés + Participants geohash + Prêt pour l’échange de clés + Échange de clés en cours + Chiffré de bout en bout + Échec de l’échange + + + 📎 Fichier reçu + 📄 %1$s + 📏 Taille : %1$s + 🏷️ Type : %1$s + 📂 Ouvrir / Enregistrer + ❌ Fermer + Choisir une image + + + Chat Mesh hors‑ligne + Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée. + Canaux Geohash en ligne + Discute avec des personnes proches via des canaux basés sur geohash. Étends le mesh grâce à des relais internet publics. + Chiffrement de bout en bout + Les messages privés sont chiffrés. Les messages de canal sont publics. + système + clair + sombre + proof of work + pow désactivé + pow activé + ajoute un proof of work aux messages geohash pour dissuader le spam. + difficulté : %1$d bits (~%2$s) + la difficulté %1$d nécessite ~%2$s tentatives de hachage + aucun proof of work requis + très faible – protection minimale + faible – protection basique + moyenne – bonne protection + élevée – forte protection + très élevée – peut causer des délais + extrême – calcul important requis + réseau + tor désactivé + tor activé + router internet via Tor pour plus de confidentialité. + statut Tor : %1$s, bootstrap %2$d%% + Dernier : %1$s + Suppression d’urgence des données + Paramètres de debug + Open Source • Privacy First • Décentralisé + Fermer + annuler + + + Avertissement + Services de localisation + Confidentialité + Erreur + Optimisation de la batterie + Avantages + Vérification de l’optimisation de la batterie + Optimisation non prise en charge + Bluetooth + Message non lu + Ouvrir la carte + Retirer le signet + Téléporter + Quitter le canal + Joignable via Nostr + Favori (hors‑ligne) + Diminuer la précision + Augmenter la précision + Sélectionner le geohash + Aller en bas + Fichier + Image + Annuler + Lien + Choisir un média + Enregistrer un message vocal + Chat Mesh hors‑ligne + Canaux Geohash en ligne + Chiffrement de bout en bout + + + Image %1$d sur %2$d + Image indisponible + Image enregistrée dans "Téléchargements" + Échec de l’enregistrement de l’image + Choisir un fichier + [fichier indisponible] + inconnu + + + choisis une action pour ce message ou cet utilisateur + choisis une action pour cet utilisateur + copier le message + copier ce message dans le presse‑papiers + gifler %1$s + envoyer une gifle amicale + prendre %1$s dans les bras + envoyer une accolade amicale + bloquer %1$s + bloquer tous les messages de cet utilisateur + + + #canaux de lieu + discute avec des personnes proches via des canaux geohash. seul un geohash approximatif est partagé, jamais un GPS exact. ne prends pas de capture d’écran et ne partage pas cet écran pour protéger ta vie privée. + accorder la permission de localisation + permission de localisation refusée. active‑la dans les paramètres pour utiliser les canaux de lieu. + Ouvrir les paramètres + \u2713 permission de localisation accordée + vérification des permissions… + recherche des canaux à proximité… + ajouté aux favoris + geohash + geohash invalide + téléporter + désactiver les services de localisation + activer les services de localisation + #bluetooth • %1$s + mesh + bloc + quartier + ville + province + région + + + outils de debug + outils développeur pour diagnostic et contrôle + journalisation verbeuse + rôles Bluetooth + serveur GATT + connexions : %1$d / %2$d + max serveur + client GATT + max client + connexions : %1$d / %2$d + max global + relais de paquets + depuis le début : %1$d + paramètres de synchro + paquets max par synchro : %1$d + taille max du filtre GCS : %1$d octets (128–1024) + FPR cible : %1$.2f%% + appareils connectés + ID de notre appareil : %1$s + aucun + déconnecter + résultats de scan récents + connecter + console de debug + effacer + dernières 10s : %1$d • 1m : %2$d • 15m : %3$d + P dérivé : %1$s • nb max estimé : %2$s + • direct + ? + en tant que serveur (nous hébergeons) + en tant que client (nous nous connectons) + + + Services de localisation requis + Confidentialité d’abord + bitchat ne suit PAS ta position.\n\nLes services de localisation sont requis pour le scan Bluetooth et pour la fonction de chat Geohash. + bitchat a besoin des services de localisation pour : + • Scan des appareils Bluetooth\n• Découvrir les utilisateurs à proximité sur le réseau mesh\n• Fonction de chat Geohash\n• Pas de suivi ni de collecte de position + Ouvrir les paramètres de localisation + Vérifier à nouveau + Services de localisation indisponibles + Les services de localisation ne sont pas disponibles sur cet appareil. C’est inhabituel car ils sont standard sur Android.\n\nL’app en a besoin pour que le scan Bluetooth fonctionne correctement (exigence Android). Sans cela, l’app ne peut pas découvrir les utilisateurs à proximité. + Vérification des services de localisation… + Bluetooth requis + bitchat a besoin de Bluetooth pour : + • Découvrir les utilisateurs à proximité\n• Créer des connexions de réseau mesh\n• Envoyer et recevoir des messages\n• Fonctionner sans internet ni serveurs + Activer Bluetooth + Bluetooth non pris en charge + Cet appareil ne prend pas en charge Bluetooth Low Energy (BLE), requis pour le fonctionnement de bitchat.\n\nbitchat a besoin de BLE pour créer des réseaux mesh et communiquer sans internet avec les appareils à proximité. + Vérification du statut Bluetooth… + optimisation de la batterie détectée + Optimisation de la batterie activée + bitchat doit fonctionner en arrière‑plan pour maintenir les connexions mesh. l’optimisation de la batterie peut les interrompre. + Avantages de la désactivation + • livraison fiable des messages\n• maintient la connectivité mesh\n• évite les coupures + Désactiver l’optimisation + optimisation de la batterie désactivée + Continuer + Initialisation du réseau mesh + . + Configuration du mesh Bluetooth… + Cela ne devrait prendre que quelques secondes + ⚠️ + Configuration incomplète + Réessayer + Ta confidentialité est protégée + • pas de suivi ni collecte de données\n• chats mesh Bluetooth entièrement hors‑ligne\n• les chats geohash utilisent internet + autorisations + Accorder les autorisations + bitchat ne suit PAS ta position + @ + · ⧉ + personne aux alentours… + (toi) + déplacer et zoomer pour choisir un geohash + sélectionner + écrire un message… + @%1$s + mention + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Image + Fichier + + + + + 📷 a envoyé une image + 🎤 a envoyé un message vocal + 📎 a envoyé un fichier + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + Lire + Pause + + + Nécessaire pour découvrir des utilisateurs bitchat via Bluetooth + Autoriser bitchat à se connecter aux appareils à proximité + Requis par Android pour découvrir des utilisateurs bitchat via Bluetooth + bitchat en a besoin pour rechercher des appareils à proximité + Recevoir des notifications de messages privés + Autoriser bitchat à t’envoyer des notifications + Désactiver l’optimisation de la batterie pour assurer un fonctionnement fiable en arrière‑plan et maintenir les connexions mesh + Autoriser bitchat à fonctionner sans restrictions de batterie + + + Appareils à proximité + Localisation précise + Microphone + Notifications + Optimisation de la batterie + Autre + + + Entrer le mot de passe du canal + Le canal %1$s est protégé par mot de passe. Entre le mot de passe pour rejoindre. + Mot de passe + Rejoindre + Annuler + + + + et %d de plus + et %d de plus + + + + 1 personne + %d personnes + + + + #%1$s ± 1 • %2$d note + #%1$s ± 1 • %2$d notes + + ajoutez de courtes notes permanentes à cet endroit pour que d\'autres visiteurs les trouvent. + relais géo indisponibles ; notes en pause + aucun relais géo à proximité + les notes dépendent des relais géo. vérifiez la connexion et réessayez. + chargement des notes… + aucune note pour le moment + soyez le premier à en ajouter pour cet endroit. + fermer + ajoutez une note pour cet endroit + Bluetooth recommandé + Mesh actif — %1$d pairs + + vérifier + scanner pour me vérifier + scanner un autre qr + scanner un autre qr + afficher mon qr + supprimer la vérification + qr indisponible + permission caméra requise + activer caméra + coller url de vérification + valider + vérification demandée + vérification de sécurité + leur empreinte + votre empreinte + négociation en cours + ouvrir un chat privé + chiffré & vérifié + chiffré + négociation + échec négociation + non chiffré + vérifié + vous avez vérifié cette personne. + non vérifié + comparez avec %1$s via canal sûr. + marquer vérifié + lancer handshake + copier + Vérification mutuelle + Vous et %1$s êtes vérifiés + vérification mutuelle avec %1$s + Vérifié + Vous avez vérifié %1$s + vérifié %1$s + Ouvrir À propos + vérifier s\'il y a des notes laissées ici + 1 note laissée ici — appuyez pour lire + %d notes laissées ici — appuyez pour lire + Langue + Langue de l’appli + Langue du système + Choisir la langue diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml new file mode 100644 index 00000000..f742042e --- /dev/null +++ b/app/src/main/res/values-he/strings.xml @@ -0,0 +1,395 @@ + + + + + + #%1$s ± 1 • %2$d הערה + #%1$s ± 1 • %2$d הערות + + הוסף הערות קצרות וקבועות למיקום זה כדי שמבקרים אחרים ימצאו אותן. + ממסרי מיקום אינם זמינים; ההערות הושהו + אין ממסרי מיקום בקרבת מקום + ההערות תלויות בממסרי מיקום. בדוק חיבור ונסה שוב. + טוען הערות… + אין עדיין הערות + היה הראשון להוסיף הערה למקום זה. + סגור + הוסף הערה למקום זה + דלג + מומלץ Bluetooth + רשת Mesh פועלת — %1$d עמיתים + + אימות + סרוק כדי לאמת אותי + סרוק קוד QR של מישהו אחר + סרוק קוד QR של מישהו אחר + הצג את קוד ה-QR שלי + הסר אימות + קוד QR אינו זמין + נדרשת הרשאת מצלמה כדי לסרוק קודי QR + אפשר מצלמה + הדבק כתובת אימות + אמת + בקשת אימות נשלחה + אימות אבטחה + טביעת האצבע שלהם + טביעת האצבע שלך + לחיצת יד ממתינה + פתח שיחה פרטית כדי לצפות בטביעות אצבע + מוצפן ומאומת + מוצפן + מבצע לחיצת יד + לחיצת היד נכשלה + לא מוצפן + מאומת + אימתת את זהותו של אדם זה. + לא מאומת + השווה טביעות אצבע אלה עם %1$s באמצעות ערוץ מאובטח. + סמן כמאומת + התחל לחיצת יד + העתק + אימות הדדי + אתה ו-%1$s אימתתם זה את זה + אימות הדדי עם %1$s + מאומת + אימתת את %1$s + אימת את %1$s + + bitchat + נדרשת הרשאת Bluetooth להעברת הודעות בין עמיתים ללא אינטרנט. + נדרשת הרשאת מיקום כדי לגלות מכשירים קרובים באמצעות Bluetooth. + נדרשת הרשאת התראות כדי להתריע לך על הודעות חדשות. + כינוי + הקלד הודעה… + סיסמה + הצטרף לערוץ + עזוב + שלח + הצג פקודות + חזרה + אנשים + ערוצים + משתמשים מחוברים + אף אחד לא מחובר + הקש שלוש פעמים כדי למחוק את כל הנתונים + רשת + זוהה ייעול צריכת סוללה + ייעול צריכת סוללה מבוטל + ייעול צריכת סוללה אינו נדרש + בודק ייעול צריכת סוללה + מדוע לבטל ייעול צריכת סוללה? + bitchat פועל ברקע כדי לשמור על חיבורי רשת Mesh עם מכשירים קרובים. ייעול צריכת סוללה עלול לשבש חיבורים אלה, ולגרום לעיכוב או לאובדן הודעות.\n\nביטול ייעול צריכת הסוללה מבטיח העברת הודעות אמינה בין עמיתים. + בטל ייעול צריכת סוללה + הערה: תוכל לשנות הגדרה זו מאוחר יותר בהגדרות אנדרואיד > אפליקציות > bitchat > סוללה + המכשיר שלך אינו דורש הגדרות ייעול צריכת סוללה. bitchat יפעל כרגיל. + המכשיר שלך אינו דורש הגדרות ייעול צריכת סוללה. bitchat יפעל כרגיל. + bitchat יכול לפעול באופן אמין ברקע + • מבטיח מסירת הודעות אמינה\n• שומר על קישוריות רשת Mesh\n• מאפשר העברת הודעות ברקע\n• מונע ניתוקי חיבור + בדוק שוב + דלג לעת עתה + המשך + נסה שוב + ועוד %1$d + %1$d הודעות מ-%2$d אנשים + ועוד %1$d שיחות + 👥 יש בצ׳אטרים בקרבת מקום! + אדם אחד בסביבה + %1$d אנשים בסביבה + הודעות חדשות + הודעות מיקום חדשות + אוזכרת ב-%1$s + אוזכרת ב-%1$s (+%2$d נוספים) + %1$d אזכורים ב-%2$s + פעילות חדשה ב-%1$s + הודעות ב-%1$s + %1$s הצטרף/ה לשיחה + bitchat - %1$d אזכורים + bitchat - צ׳אטים לפי מיקום + %1$d הודעות מ-%2$d מיקומים + אוזכרת בצ׳אט Mesh + %1$d אזכורים בצ׳אט Mesh + ועוד %1$d מיקומים + + ועוד %1$d + ועוד %1$d + + שירות רקע של רשת Mesh + שומר על פעילות רשת ה-Mesh ברקע + הוסף למועדפים + הסר מהמועדפים + הוסף סימנייה + נגיש דרך Nostr + הודעות פרטיות שלא נקראו + הערות מיקום + טלפורט + פתיחת הגדרות מיקום וערוצים + עמיתים מחוברים + משתתפי geohash + מוכן ללחיצת יד + לחיצת יד בתהליך + מוצפן מקצה לקצה + לחיצת היד נכשלה + 📎 התקבל קובץ + 📄 %1$s + 📏 גודל: %1$s + 🏷️ סוג: %1$s + 📂 פתח / שמור + ❌ סגור + בחר תמונה + שמור את התמונה הנוכחית + סגור + התמונה נשמרה בהורדות + שמירת התמונה נכשלה + תמונה %1$d מתוך %2$d + בחר קובץ + העברת הודעות מבוזרת ברשת Mesh עם הצפנה מקצה לקצה + צ׳אט Mesh לא מקוון + תקשר ישירות באמצעות Bluetooth LE ללא אינטרנט או שרתים. הודעות מועברות דרך מכשירים קרובים כדי להאריך את הטווח. + ערוצי geohash מקוונים + התחבר לאנשים באזורך באמצעות ערוצים מבוססי geohash. הרחב את רשת ה-Mesh באמצעות ממסרי אינטרנט ציבוריים. + הצפנה מקצה לקצה + הודעות פרטיות מוצפנות. הודעות בערוצים הן ציבוריות. + מערכת + בהיר + כהה + הוכחת עבודה + הוכחת עבודה כבויה + הוכחת עבודה דלוקה + הוסף הוכחת עבודה להודעות geohash כדי להרתיע ספאם. + קושי: %1$d ביטים (~%2$s) + קושי %1$d דורש כ-%2$s ניסיונות גיבוב + לא נדרשת הוכחת עבודה + נמוך מאוד - הגנה מינימלית מפני ספאם + נמוך - הגנה בסיסית מפני ספאם + בינוני - הגנה טובה מפני ספאם + גבוה - הגנה חזקה מפני ספאם + גבוה מאוד - עלול לגרום לעיכובים + קיצוני - נדרש חישוב משמעותי + רשת + Tor כבוי + Tor דלוק + נתב תעבורת אינטרנט דרך Tor לפרטיות משופרת. + סטטוס Tor: %1$s, אתחול %2$d%% + אחרון: %1$s + מחיקת נתונים חירומית + הגדרות דיבוג + קוד פתוח • פרטיות תחילה • מבוזר + סגור + פרטיות מוגנת + בטל + אזהרה + שירותי מיקום + פרטיות + שגיאה + ייעול צריכת סוללה + יתרונות + בודק ייעול צריכת סוללה + ייעול צריכת סוללה אינו נתמך + Bluetooth + הודעה שלא נקראה + פתח מפה + הסר סימנייה + טלפורט + צא מ-bitchat + הפעל ברקע + שמור על רשת ה-Mesh פעילה כאשר האפליקציה סגורה (שירות חזית) + עזוב ערוץ + נגיש דרך Nostr + מועדף לא מקוון + הפחת דיוק + הגבר דיוק + בחר geohash + גלול לתחתית + קובץ + תמונה + בטל + קישור + הקלט הודעה קולית + בחר מדיה + צ׳אט Mesh לא מקוון + ערוצי geohash מקוונים + הצפנה מקצה לקצה + #bluetooth • %1$s + תמונה %1$d מתוך %2$d + התמונה אינה זמינה + התמונה נשמרה בהורדות + שמירת התמונה נכשלה + בחר קובץ + [הקובץ אינו זמין] + לא ידוע + בחר פעולה עבור הודעה או משתמש זה + בחר פעולה עבור משתמש זה + העתק הודעה + העתק הודעה זו ללוח + סטור ל-%1$s + שלח הודעת סטירה שובבה + חבק את %1$s + שלח הודעת חיבוק ידידותית + חסום את %1$s + חסום את כל ההודעות ממשתמש זה + הודעה ל-%1$s + שלח הודעה פרטית + #ערוצי מיקום + שוחח עם אנשים בקרבתך באמצעות ערוצי geohash. משותף רק geohash גס, לעולם לא מיקום GPS מדויק. אל תצלם מסך או תשתף מסך זה כדי להגן על פרטיותך. + הענק הרשאת מיקום + הרשאת המיקום נדחתה. אפשר בהגדרות כדי להשתמש בערוצי מיקום. + ✓ הרשאת מיקום ניתנה + בודק הרשאות... + מחפש ערוצים בקרבת מקום… + סומן + geohash + geohash לא תקין + טלפורט + בטל שירותי מיקום + אפשר שירותי מיקום + mesh + רחוב + שכונה + עיר + מחוז + אזור + כלי דיבוג + כלי מפתחים לאבחון ובקרה + רישום מפורט + רושם הצטרפות/עזיבת עמיתים, כיוון חיבור, ניתוב חבילות וממסרים + תפקידי Bluetooth + שרת GATT + חיבורים: %1$d / %2$d + מקסימום שרת + לקוח GATT + מקסימום לקוח + חיבורים: %1$d / %2$d + מקסימום כולל + ממסר חבילות + מאז ההתחלה: %1$d + הפעל/כבה תפקידים וסגור את כל החיבורים כשמושבת + הגדרות סנכרון + מקסימום חבילות לסנכרון: %1$d + גודל מסנן GCS מקסימלי: %1$d בייטים (128–1024) + FPR יעד: %1$.2f%% + מכשירים מחוברים + מזהה המכשיר שלנו: %1$s + ללא + נתק + תוצאות סריקה אחרונות + התחבר + מסוף דיבוג + נקה + 10 שנ׳ אחרונות: %1$d • 1 דקה: %2$d • 15 דקות: %3$d + P נגזר: %1$s • הערכת מקסימום אלמנטים: %2$s + • ישיר + RSSI: %1$s + ? + כשרת (אנחנו מארחים) + כלקוח (אנחנו מתחברים) + נדרשים שירותי מיקום + פרטיות תחילה + bitchat לא עוקב אחר המיקום שלך.\n\nשירותי מיקום נדרשים לסריקת Bluetooth ולתכונת צ׳אט ה-Geohash. + bitchat זקוק לשירותי מיקום עבור: + • סריקת מכשירי Bluetooth\n• איתור משתמשים קרובים ברשת ה-Mesh\n• תכונת צ׳אט Geohash\n• ללא מעקב או איסוף מיקום + מומלץ מיקום ברקע + אופציונלי, משפר את אמינות ה-Mesh + אנדרואיד ממליץ על מיקום ברקע כדי ש-bitchat יוכל לסרוק מכשירים קרובים כשהאפליקציה אינה פתוחה. זה שומר על פעילות ה-Mesh לאחר איתחול מחדש. + כשההגדרות נפתחות, בחר \"אפשר תמיד\". + bitchat משתמש במיקום ברקע עבור: + - סריקת מכשירים קרובים כשהאפליקציה סגורה\n- חיבור מחדש לאחר איתחול\n- שמירה על פעילות ה-Mesh ברקע + אנחנו לעולם לא אוספים או שומרים את המיקום שלך. הפרטיות שלך בטוחה. + אפשר מיקום ברקע + המשך ללא מיקום ברקע + פתח הגדרות מיקום + בדוק שוב + שירותי מיקום אינם זמינים + שירותי מיקום אינם זמינים במכשיר זה. זה נדיר, מכיוון ששירותי מיקום הם סטנדרטיים במכשירי אנדרואיד.\n\nbitchat זקוק לשירותי מיקום כדי שסריקת Bluetooth תפעל כראוי (דרישת אנדרואיד). ללא זה, האפליקציה אינה יכולה לאתר משתמשים קרובים. + בודק שירותי מיקום... + נדרש Bluetooth + bitchat זקוק ל-Bluetooth כדי: + • לאתר משתמשים קרובים\n• ליצור חיבורי רשת Mesh\n• לשלוח ולקבל הודעות\n• לפעול ללא אינטרנט או שרתים + אפשר Bluetooth + Bluetooth אינו נתמך + מכשיר זה אינו תומך ב-Bluetooth Low Energy‏ (BLE), הנדרש כדי ש-bitchat יפעל.\n\nbitchat זקוק ל-BLE כדי ליצור רשתות Mesh ולתקשר עם מכשירים קרובים ללא אינטרנט. + בודק סטטוס Bluetooth... + זוהה ייעול צריכת סוללה + ייעול צריכת סוללה מופעל + bitchat צריך לפעול ברקע כדי לשמור על חיבורי Mesh. ייעול צריכת סוללה עלול לשבש חיבורים אלה. + יתרונות הביטול + • מסירת הודעות אמינה\n• שמירה על קישוריות Mesh\n• מניעת ניתוקי חיבור + בטל ייעול צריכת סוללה + ייעול צריכת סוללה בוטל + המשך + מאתחל רשת Mesh + . + מגדיר רשת Mesh דרך Bluetooth... + זה אמור לקחת רק כמה שניות + ⚠️ + ההגדרה לא הושלמה + נסה שוב + פתח הגדרות + הפרטיות שלך מוגנת + • ללא מעקב או איסוף נתונים\n• צ׳אטים ברשת Mesh דרך Bluetooth הם לחלוטין לא מקוונים\n• צ׳אטים לפי מיקום (Geohash) משתמשים באינטרנט + הרשאות + הענק הרשאות + bitchat לא עוקב אחר המיקום שלך + @ + · ⧉ + אין אף אחד בסביבה... + (אתה) + הזז ושנה תצוגה כדי לבחור geohash + בחר + הקלד הודעה... + @%1$s + אזכור + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + תמונה + קובץ + 📷 שלח/ה תמונה + 🎤 שלח/ה הודעה קולית + 📎 שלח/ה קובץ + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + נגן + השהה + נדרש כדי לאתר משתמשי bitchat באמצעות Bluetooth + אפשר ל-bitchat להתחבר למכשירים קרובים + נדרש על ידי אנדרואיד כדי לאתר משתמשי bitchat קרובים באמצעות Bluetooth + bitchat זקוק לזה כדי לסרוק מכשירים קרובים + מומלץ כדי לסרוק מכשירים קרובים כשהאפליקציה ברקע + אפשר גישה למיקום ברקע + קבל התראות כשאתה מקבל הודעות פרטיות + אפשר ל-bitchat לשלוח לך התראות + בטל ייעול צריכת סוללה כדי להבטיח ש-bitchat יפעל באופן אמין ברקע וישמור על חיבורי רשת Mesh + אפשר ל-bitchat לפעול ללא הגבלות סוללה + מכשירים קרובים + מיקום מדויק + מיקום ברקע + מיקרופון + התראות + ייעול צריכת סוללה + אחר + הזן סיסמת ערוץ + הערוץ %1$s מוגן בסיסמה. הזן את הסיסמה כדי להצטרף. + סיסמה + הצטרף + בטל + Tor אינו זמין בגרסה זו + + + איש אחד + %1$d אנשים + + שפה + שפת האפליקציה + ברירת המחדל של המערכת + בחירת שפה + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml new file mode 100644 index 00000000..4cb18e38 --- /dev/null +++ b/app/src/main/res/values-hi/strings.xml @@ -0,0 +1,396 @@ + + + bitchat + + + ब्लूटूथ अनुमति इंटरनेट के बिना सीधे दूसरों से चैट करने के लिए आवश्यक है। + स्थान अनुमति ब्लूटूथ के माध्यम से आस-पास के डिवाइस खोजने के लिए आवश्यक है। + सूचनाओं की अनुमति नए संदेशों के बारे में बताने के लिए आवश्यक है। + + + उपनाम + संदेश लिखें… + पासवर्ड + चैनल में शामिल हों + छोड़ें + भेजें + कमांड दिखाएँ + वापस + लोग + चैनल + ऑनलाइन उपयोगकर्ता + कोई कनेक्टेड नहीं + सभी डेटा मिटाने के लिए तीन बार टैप करें + आपका नेटवर्क + + + बैटरी अनुकूलन मिला + बैटरी अनुकूलन बंद है + बैटरी अनुकूलन आवश्यक नहीं + बैटरी अनुकूलन जाँच रहे हैं + बैटरी अनुकूलन क्यों बंद करें? + bitchat पृष्ठभूमि में चलती है ताकि पास के उपकरणों के साथ मेष कनेक्शन बनाए रखे। बैटरी अनुकूलन इन कनेक्शनों को बाधित कर सकता है, जिससे संदेश देर से पहुँचें या खो जाएँ।\n\nबैटरी अनुकूलन बंद करने से विश्वसनीय पीयर‑टू‑पीयर संचार सुनिश्चित होता है। + बैटरी अनुकूलन बंद करें + नोट: आप इसे बाद में Android सेटिंग्स > ऐप्स > bitchat > बैटरी में बदल सकते हैं। + आपके डिवाइस को बैटरी अनुकूलन सेटिंग्स की आवश्यकता नहीं है। bitchat सामान्य रूप से चलेगी। + आपके डिवाइस को बैटरी अनुकूलन सेटिंग्स की आवश्यकता नहीं है। bitchat सामान्य रूप से चलेगी। + bitchat पृष्ठभूमि में विश्वसनीय रूप से चल सकती है + • संदेशों की विश्वसनीय डिलीवरी\n• मेष कनेक्शन बनाए रखना\n• पृष्ठभूमि में संदेश फॉरवर्डिंग\n• डिसकनेक्शन से बचाव + फिर से जाँचें + अभी छोड़ें + जारी रखें + दोबारा प्रयास करें + छोड़ें + + + और %1$d + %2$d लोगों से %1$d संदेश + और %1$d वार्तालाप + पास में उपयोगकर्ता! + पास में 1 व्यक्ति + पास में %1$d लोग + नए संदेश + नई स्थान संदेश + %1$s में उल्लेख + %1$s में उल्लेख (+%2$d) + %2$s में %1$d उल्लेख + %1$s में नई गतिविधि + %1$s में संदेश + %1$s बातचीत में शामिल हुआ/हुई + bitchat – %1$d उल्लेख + bitchat – स्थान चैट + %2$d स्थानों से %1$d संदेश + मेष चैट में उल्लेख + मेष चैट में %1$d उल्लेख + और %1$d स्थान + और %1$d + + + पसंदीदा में जोड़ें + पसंदीदा से हटाएँ + बुकमार्क जोड़ें + + + Nostr के माध्यम से उपलब्ध + अपठित निजी संदेश + टेलीपोर्ट हुआ + स्थान और चैनल सेटिंग खोलें + कनेक्टेड पीयर्स + geohash प्रतिभागी + हैंडशेक के लिए तैयार + हैंडशेक जारी है + एंड‑टू‑एंड एन्क्रिप्टेड + हैंडशेक विफल + + + 📎 फ़ाइल प्राप्त + 📄 %1$s + 📏 आकार: %1$s + 🏷️ प्रकार: %1$s + 📂 खोलें / सहेजें + ❌ बंद करें + छवि चुनें + + + ऑफ़लाइन मेष चैट + इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े। + ऑनलाइन geohash चैनल + geohash आधारित चैनलों के माध्यम से अपने आस‑पास के लोगों से जुड़ें। सार्वजनिक इंटरनेट रिले के माध्यम से मेष का विस्तार करें। + एंड‑टू‑एंड एन्क्रिप्शन + निजी संदेश एन्क्रिप्टेड हैं। चैनल संदेश सार्वजनिक हैं। + सिस्टम + लाइट + डार्क + प्रूफ़ ऑफ़ वर्क + PoW बंद + PoW चालू + स्पैम को कठिन बनाने के लिए geohash संदेशों में Proof of Work जोड़ें। + कठिनाई: %1$d बिट (~%2$s) + कठिनाई %1$d के लिए ~%2$s हैश प्रयास + Proof of Work आवश्यक नहीं + बहुत कम – न्यूनतम स्पैम सुरक्षा + कम – बुनियादी स्पैम सुरक्षा + मध्यम – अच्छी स्पैम सुरक्षा + उच्च – मजबूत स्पैम सुरक्षा + बहुत उच्च – विलंब हो सकता है + अत्यधिक – काफ़ी गणनात्मक संसाधन चाहिए + नेटवर्क + Tor बंद + Tor चालू + अधिक गोपनीयता के लिए इंटरनेट ट्रैफ़िक को Tor के माध्यम से रूट करें। + Tor स्थिति: %1$s, बूटस्ट्रैप %2$d%% + अंतिम: %1$s + आपातकालीन डेटा मिटाना + डिबग सेटिंग्स + ओपन सोर्स • गोपनीयता पहले • विकेन्द्रीकृत + बंद करें + रद्द करें + + + चेतावनी + स्थान सेवाएँ + गोपनीयता + त्रुटि + बैटरी अनुकूलन + लाभ + बैटरी अनुकूलन जाँच रहे हैं + बैटरी अनुकूलन समर्थित नहीं + Bluetooth + अपठित संदेश + मानचित्र खोलें + बुकमार्क हटाएँ + टेलीपोर्ट + चैनल छोड़ें + Nostr के माध्यम से उपलब्ध + पसंदीदा (ऑफ़लाइन) + सटीकता घटाएँ + सटीकता बढ़ाएँ + geohash चुनें + नीचे स्क्रॉल करें + फ़ाइल + छवि + रद्द करें + लिंक + मीडिया चुनें + फ़ाइल चुनें + वर्तमान छवि सहेजें + बंद करें + छवि %1$d / %2$d + गोपनीयता सुरक्षित + वॉइस नोट रिकॉर्ड करें + ऑफ़लाइन मेष चैट + ऑनलाइन geohash चैनल + एंड‑टू‑एंड एन्क्रिप्शन + + + छवि %1$d / %2$d + छवि उपलब्ध नहीं + छवि "Downloads" में सहेजी गई + छवि सहेजी नहीं जा सकी + छवि "Downloads" में सहेजी गई + छवि सहेजी नहीं जा सकी + फ़ाइल चुनें + [फ़ाइल उपलब्ध नहीं] + अज्ञात + + + इस संदेश या उपयोगकर्ता के लिए क्रिया चुनें + इस उपयोगकर्ता के लिए क्रिया चुनें + संदेश कॉपी करें + इस संदेश को क्लिपबोर्ड में कॉपी करें + %1$s को थप्पड़ + मज़ाकिया थप्पड़ भेजें + %1$s को गले लगाएँ + दोस्ताना आलिंगन भेजें + %1$s को ब्लॉक करें + इस उपयोगकर्ता के सभी संदेश ब्लॉक करें + + + #स्थान चैनल + geohash चैनलों के माध्यम से अपने पास के लोगों के साथ चैट करें। केवल एक मोटा geohash साझा किया जाता है, कभी भी सटीक GPS डेटा नहीं। अपनी गोपनीयता के लिए, स्क्रीनशॉट न लें और इस स्क्रीन को साझा न करें। + स्थान अनुमति दें + स्थान अनुमति अस्वीकार कर दी गई। स्थान चैनल उपयोग करने के लिए सेटिंग्स में सक्षम करें। + सेटिंग्स खोलें + \u2713 स्थान अनुमति दी गई + अनुमतियाँ जाँच रहे हैं… + पास के चैनल ढूँढ़ रहे हैं… + बुकमार्क + Geohash + अमान्य geohash + टेलीपोर्ट + स्थान सेवाएँ बंद करें + स्थान सेवाएँ चालू करें + mesh + ब्लॉक + पड़ोस + शहर + प्रांत + क्षेत्र + #bluetooth • %1$s + + + डिबग उपकरण + निदान और नियंत्रण के लिए डेवलपर टूल + विस्तृत लॉगिंग + पीयर जुड़ना/छोड़ना, कनेक्शन दिशा, पैकेट रिले और रिले को लॉग करता है + Bluetooth भूमिकाएँ + GATT सर्वर + कनेक्शन: %1$d / %2$d + अधिकतम सर्वर + GATT क्लाइंट + अधिकतम क्लाइंट + कनेक्शन: %1$d / %2$d + कुल अधिकतम + पैकेट रिले + आरंभ से: %1$d + भूमिकाएँ ऑन/ऑफ करें और बंद करने पर सभी कनेक्शन बंद करें + सिंक सेटिंग्स + प्रति सिंक अधिकतम पैकेट: %1$d + अधिकतम GCS फ़िल्टर आकार: %1$d बाइट (128–1024) + लक्ष्य FPR: %1$.2f%% + कनेक्टेड डिवाइस + हमारा डिवाइस ID: %1$s + कोई नहीं + डिसकनेक्ट + हाल में स्कैन किए डिवाइस + कनेक्ट + डिबग कंसोल + साफ़ करें + पिछले 10 से.: %1$d • 1 मि.: %2$d • 15 मि.: %3$d + व्युत्पन्न P: %1$s • अनुमानित अधिकतम आइटम: %2$s + • डायरेक्ट + सर्वर के रूप में (हम होस्ट करते हैं) + क्लाइंट के रूप में (हम कनेक्ट करते हैं) + + + स्थान सेवाएँ आवश्यक + गोपनीयता पहले + bitchat आपकी लोकेशन ट्रैक नहीं करती।\n\nBluetooth स्कैनिंग और geohash चैट फीचर के लिए स्थान सेवाएँ आवश्यक हैं। + bitchat को इन कारणों से स्थान सेवाएँ चाहिए: + • Bluetooth डिवाइस स्कैनिंग\n• मेष नेटवर्क में पास के उपयोगकर्ताओं की खोज\n• geohash चैट फीचर\n• कोई ट्रैकिंग या लोकेशन संग्रह नहीं + स्थान सेटिंग्स खोलें + फिर से जाँचें + स्थान सेवाएँ उपलब्ध नहीं + इस डिवाइस पर स्थान सेवाएँ उपलब्ध नहीं हैं। यह असामान्य है क्योंकि Android डिवाइसों पर वे सामान्यतः होती हैं।\n\nBluetooth स्कैनिंग (Android आवश्यकता) के ठीक से काम करने के लिए स्थान सेवाएँ जरूरी हैं। इसके बिना ऐप पास के उपयोगकर्ताओं को नहीं ढूँढ़ सकती। + स्थान सेवाएँ जाँची जा रही हैं… + Bluetooth आवश्यक + bitchat को Bluetooth की आवश्यकता: + • पास के उपयोगकर्ताओं की खोज\n• मेष कनेक्शन बनाना\n• संदेश भेजना और प्राप्त करना\n• इंटरनेट या सर्वर के बिना काम करना + Bluetooth चालू करें + Bluetooth समर्थित नहीं + यह डिवाइस Bluetooth Low Energy (BLE) को सपोर्ट नहीं करता, जो bitchat के लिए आवश्यक है।\n\nइंटरनेट के बिना पास के डिवाइसों के साथ मेष नेटवर्क बनाने और संवाद करने के लिए BLE आवश्यक है। + Bluetooth स्थिति जाँच रहे हैं… + बैटरी अनुकूलन मिला + बैटरी अनुकूलन चालू है + मेष कनेक्शन बनाए रखने के लिए bitchat को पृष्ठभूमि में चलना होता है। बैटरी अनुकूलन इन कनेक्शनों को बाधित कर सकता है। + बंद करने के लाभ + • विश्वसनीय डिलीवरी\n• मेष कनेक्टिविटी बनाए रखें\n• डिसकनेक्शन रोकें + बैटरी अनुकूलन बंद करें + बैटरी अनुकूलन बंद + जारी रखें + मेष नेटवर्क प्रारंभ कर रहे हैं + Bluetooth मेष नेटवर्क सेट कर रहे हैं… + इसमें केवल कुछ सेकंड लगेंगे + सेटअप पूरा नहीं हुआ + फिर से प्रयास करें + आपकी गोपनीयता सुरक्षित है + • कोई ट्रैकिंग या डेटा संग्रह नहीं\n• Bluetooth मेष चैट पूरी तरह ऑफ़लाइन\n• geohash चैट इंटरनेट का उपयोग करती है + अनुमतियाँ + अनुमतियाँ दें + bitchat आपकी लोकेशन ट्रैक नहीं करती + आस‑पास कोई नहीं… + (आप) + geohash चुनने के लिए पैन/ज़ूम करें + चयन करें + संदेश लिखें… + उल्लेख + v%1$s + image/* + छवि + फ़ाइल + + + 📷 छवि भेजी + 🎤 वॉइस नोट भेजा + 📎 फ़ाइल भेजी + + + चलाएँ + रोकें + + + ब्लूटूथ के माध्यम से bitchat उपयोगकर्ताओं की खोज के लिए आवश्यक + bitchat को पास के उपकरणों से कनेक्ट करने दें + Android द्वारा आवश्यक: ब्लूटूथ के माध्यम से पास के bitchat उपयोगकर्ताओं की खोज + पास के उपकरण स्कैन करने के लिए bitchat को यह चाहिए + निजी संदेश मिलने पर सूचनाएँ प्राप्त करें + bitchat को आपको सूचनाएँ भेजने दें + बैटरी अनुकूलन बंद करें ताकि bitchat पृष्ठभूमि में विश्वसनीय रूप से चल सके और मेष कनेक्शन बनाए रख सके + bitchat को बैटरी प्रतिबंधों के बिना चलने दें + + + पास के उपकरण + सटीक स्थान + माइक्रोफ़ोन + सूचनाएँ + बैटरी अनुकूलन + अन्य + + + चैनल पासवर्ड दर्ज करें + चैनल %1$s पासवर्ड द्वारा सुरक्षित है। शामिल होने के लिए पासवर्ड दर्ज करें। + पासवर्ड + शामिल हों + रद्द करें + + + + और %d + और %d + + + + 1 व्यक्ति + %d लोग + + + + #%1$s ± 1 • %2$d नोट + #%1$s ± 1 • %2$d नोट्स + + अन्य आगंतुकों के लिए इस स्थान पर छोटी स्थायी नोट्स जोड़ें। + जियो रिले उपलब्ध नहीं; नोट्स विरामित + आसपास कोई जियो रिले नहीं + नोट्स जियो रिले पर निर्भर हैं। कनेक्शन जांचें और फिर से प्रयास करें। + नोट्स लोड हो रहे हैं… + अभी कोई नोट नहीं + इस स्थान के लिए पहला नोट जोड़ें। + बंद करें + इस स्थान के लिए एक नोट जोड़ें + ब्लूटूथ अनुशंसित + मेश चल रहा है — %1$d पीयर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + परिचय खोलें + देखें कि यहाँ नोट छोड़े गए हैं या नहीं + यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें + यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें + भाषा + ऐप की भाषा + सिस्टम डिफ़ॉल्ट + भाषा चुनें + diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml new file mode 100644 index 00000000..02c86a68 --- /dev/null +++ b/app/src/main/res/values-id/strings.xml @@ -0,0 +1,396 @@ + + + bitchat + + + Izin Bluetooth diperlukan untuk chatting langsung dengan orang lain tanpa Internet. + Izin lokasi diperlukan untuk menemukan perangkat terdekat melalui Bluetooth. + Izin notifikasi diperlukan untuk memberitahu Anda tentang pesan baru. + + + Nama panggilan + Ketik pesan… + Kata sandi + Bergabung ke channel + Keluar + Kirim + Tampilkan perintah + Kembali + Orang + Channel + Pengguna online + Tidak ada yang terhubung + Ketuk tiga kali untuk menghapus semua data + Jaringan + + + Optimalisasi baterai terdeteksi + Optimalisasi baterai dinonaktifkan + Optimalisasi baterai tidak diperlukan + Memeriksa optimalisasi baterai + Mengapa menonaktifkan optimalisasi baterai? + bitchat berjalan di latar belakang untuk mempertahankan koneksi mesh dengan perangkat terdekat. Optimalisasi baterai dapat mengganggu koneksi ini, menyebabkan pesan tertunda atau hilang.\n\nMenonaktifkan optimalisasi baterai memastikan komunikasi peer-to-peer yang andal. + Nonaktifkan optimalisasi baterai + Catatan: Anda dapat mengubahnya nanti di Pengaturan Android > Aplikasi > bitchat > Baterai. + Perangkat Anda tidak memerlukan pengaturan optimalisasi baterai. bitchat akan berjalan normal. + Perangkat Anda tidak memerlukan pengaturan optimalisasi baterai. bitchat akan berjalan normal. + bitchat dapat berjalan andal di latar belakang + • Pengiriman pesan yang andal\n• Mempertahankan koneksi mesh\n• Relay pesan latar belakang\n• Mencegah putusnya koneksi + Periksa lagi + Lewati sekarang + Lanjut + Coba lagi + Lewati + + + dan %1$d lagi + %1$d pesan dari %2$d orang + dan %1$d percakapan lagi + bitchatter terdekat! + 1 orang terdekat + %1$d orang terdekat + Pesan baru + Pesan lokasi baru + Disebut di %1$s + Disebut di %1$s (+%2$d lagi) + %1$d penyebutan di %2$s + Aktivitas baru di %1$s + Pesan di %1$s + %1$s bergabung dengan percakapan + bitchat – %1$d penyebutan + bitchat – Chat lokasi + %1$d pesan dari %2$d lokasi + Disebut di Mesh Chat + %1$d penyebutan di Mesh Chat + dan %1$d lokasi lagi + dan %1$d lagi + + + Tambah ke favorit + Hapus dari favorit + Tambah bookmark + + + Dapat dijangkau melalui Nostr + Pesan pribadi yang belum dibaca + Diteleportasi + Buka pengaturan lokasi dan channel + Peer yang terhubung + Peserta geohash + Siap untuk handshake + Handshake sedang berlangsung + Terenkripsi end-to-end + Handshake gagal + + + 📎 File diterima + 📄 %1$s + 📏 Ukuran: %1$s + 🏷️ Jenis: %1$s + 📂 Buka / Simpan + ❌ Tutup + Pilih gambar + + + Chat Mesh Offline + Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan. + Channel Geohash Online + Terhubung dengan orang-orang di area Anda melalui channel berbasis geohash. Perluas mesh melalui relay Internet publik. + Enkripsi end-to-end + Pesan pribadi dienkripsi. Pesan channel bersifat publik. + Sistem + Terang + Gelap + Proof of Work + PoW mati + PoW hidup + Tambahkan proof of work ke pesan geohash untuk mempersulit spam. + Kesulitan: %1$d bit (~%2$s) + Kesulitan %1$d memerlukan ~%2$s percobaan hash + tidak ada proof of work yang diperlukan + sangat rendah – perlindungan spam minimal + rendah – perlindungan spam dasar + sedang – perlindungan spam yang baik + tinggi – perlindungan spam yang kuat + sangat tinggi – dapat menyebabkan keterlambatan + ekstrem – memerlukan daya komputasi yang signifikan + Jaringan + Tor mati + Tor hidup + Rutekan lalu lintas Internet melalui Tor untuk privasi ekstra. + Status Tor: %1$s, Bootstrap %2$d%% + Terakhir: %1$s + Hapus data darurat + Pengaturan debug + Sumber Terbuka • Privasi Pertama • Terdesentralisasi + Tutup + batal + + + Peringatan + Layanan lokasi + Privasi + Kesalahan + Optimalisasi baterai + Manfaat + Memeriksa optimalisasi baterai + Optimalisasi baterai tidak didukung + Bluetooth + Pesan yang belum dibaca + Buka peta + Hapus bookmark + Teleportasi + Keluar dari channel + Dapat dijangkau melalui Nostr + Favorit (offline) + Kurangi presisi + Tingkatkan presisi + Pilih geohash + Gulir ke bawah + File + Gambar + Batal + Tautan + Pilih media + Pilih file + Simpan gambar saat ini + Tutup + Gambar %1$d dari %2$d + Privasi terlindungi + Rekam pesan suara + Chat Mesh Offline + Channel Geohash Online + Enkripsi end-to-end + + + Gambar %1$d dari %2$d + Gambar tidak tersedia + Gambar disimpan ke \"Downloads\" + Gagal menyimpan gambar + Gambar disimpan ke \"Downloads\" + Gagal menyimpan gambar + Pilih file + [File tidak tersedia] + tidak diketahui + + + Pilih aksi untuk pesan atau pengguna ini + Pilih aksi untuk pengguna ini + Salin pesan + Salin pesan ini ke clipboard + Tampar %1$s + Kirim tamparan main-main + Peluk %1$s + Kirim pelukan yang ramah + Blokir %1$s + Blokir semua pesan dari pengguna ini + + + #Channel lokasi + Chat dengan orang-orang di dekat Anda melalui channel geohash. Hanya geohash kasar yang dibagikan, bukan data GPS yang tepat. Harap jangan tangkap layar atau bagikan tampilan ini untuk privasi. + Berikan izin lokasi + Izin lokasi ditolak. Aktifkan di Pengaturan untuk menggunakan channel lokasi. + Buka Pengaturan + \u2713 Izin lokasi diberikan + Memeriksa izin … + Mencari channel terdekat … + Dibookmark + Geohash + Geohash tidak valid + teleportasi + Nonaktifkan layanan lokasi + Aktifkan layanan lokasi + mesh + Blok + Lingkungan + Kota + Provinsi + Wilayah + #bluetooth • %1$s + + + Alat debug + Alat pengembang untuk diagnostik dan kontrol + logging verbose + Log bergabung/keluar peer, arah koneksi, penerusan paket dan relay + Peran Bluetooth + GATT Server + Koneksi: %1$d / %2$d + server maks + GATT Client + client maks + Koneksi: %1$d / %2$d + maks keseluruhan + Packet Relay + sejak mulai: %1$d + Aktifkan/nonaktifkan peran dan tutup semua koneksi saat menonaktifkan + Pengaturan sinkronisasi + paket maks per sinkronisasi: %1$d + ukuran filter GCS maks: %1$d Byte (128–1024) + FPR target: %1$.2f%% + perangkat terhubung + ID perangkat kami: %1$s + tidak ada + putuskan koneksi + perangkat yang baru dipindai + hubungkan + Konsol debug + hapus + 10 detik terakhir: %1$d • 1m: %2$d • 15m: %3$d + P turunan: %1$s • elemen maks perkiraan: %2$s + • langsung + sebagai server (kami host) + sebagai client (kami hubungkan) + + + Layanan lokasi diperlukan + Privasi pertama + bitchat TIDAK melacak lokasi Anda.\n\nLayanan lokasi diperlukan untuk pemindaian Bluetooth dan fitur geohash chat. + bitchat memerlukan layanan lokasi untuk: + • Pemindaian perangkat Bluetooth\n• Menemukan pengguna terdekat di jaringan mesh\n• Fitur geohash chat\n• Tidak ada pelacakan atau pengumpulan lokasi + Buka pengaturan lokasi + Periksa lagi + Layanan lokasi tidak tersedia + Layanan lokasi tidak tersedia di perangkat ini. Ini tidak biasa karena layanan lokasi standar di perangkat Android.\n\nbitchat memerlukan layanan lokasi agar pemindaian Bluetooth berfungsi dengan benar (persyaratan Android). Tanpanya, aplikasi tidak dapat menemukan pengguna terdekat. + Memeriksa layanan lokasi … + Bluetooth diperlukan + bitchat memerlukan Bluetooth untuk: + • Menemukan pengguna terdekat\n• Membuat koneksi jaringan mesh\n• Mengirim dan menerima pesan\n• Berfungsi tanpa Internet atau server + Aktifkan Bluetooth + Bluetooth tidak didukung + Perangkat ini tidak mendukung Bluetooth Low Energy (BLE), yang diperlukan agar bitchat berfungsi.\n\nbitchat memerlukan BLE untuk membuat jaringan mesh dan berkomunikasi dengan perangkat terdekat tanpa Internet. + Memeriksa status Bluetooth … + Optimalisasi baterai terdeteksi + Optimalisasi baterai diaktifkan + bitchat harus berjalan di latar belakang untuk mempertahankan koneksi mesh. Optimalisasi baterai dapat mengganggu koneksi ini. + Manfaat menonaktifkan + • pengiriman pesan yang andal\n• mempertahankan koneksi mesh\n• mencegah putusnya koneksi + Nonaktifkan optimalisasi baterai + Optimalisasi baterai dinonaktifkan + Lanjut + Menginisialisasi jaringan mesh + Menyiapkan jaringan Bluetooth mesh … + Ini seharusnya hanya memakan waktu beberapa detik + Penyiapan tidak lengkap + Coba lagi + Privasi Anda terlindungi + • tidak ada pelacakan atau pengumpulan data\n• Bluetooth mesh chat sepenuhnya offline\n• Geohash chat menggunakan Internet + Izin + Berikan izin + bitchat TIDAK melacak lokasi Anda + tidak ada orang di sekitar … + (Anda) + geser dan zoom untuk memilih geohash + pilih + Ketik pesan … + sebut + v%1$s + image/* + Gambar + File + + + 📷 mengirim gambar + 🎤 mengirim pesan suara + 📎 mengirim file + + + Putar + Jeda + + + Diperlukan untuk menemukan pengguna bitchat melalui Bluetooth + Izinkan bitchat terhubung dengan perangkat terdekat + Diperlukan oleh Android untuk menemukan pengguna bitchat terdekat melalui Bluetooth + bitchat memerlukan ini untuk memindai perangkat terdekat + Terima notifikasi saat Anda menerima pesan pribadi + Izinkan bitchat mengirim notifikasi kepada Anda + Nonaktifkan optimalisasi baterai untuk memastikan bitchat berjalan andal di latar belakang dan mempertahankan koneksi jaringan mesh + Izinkan bitchat berjalan tanpa pembatasan baterai + + + Perangkat terdekat + Lokasi yang tepat + Mikrofon + Notifikasi + Optimalisasi baterai + Lainnya + + + Masukkan kata sandi channel + Channel %1$s dilindungi kata sandi. Masukkan kata sandi untuk bergabung. + Kata sandi + Bergabung + Batal + + + + dan %d lagi + dan %d lagi + + + + 1 orang + %d orang + + + + #%1$s ± 1 • %2$d catatan + #%1$s ± 1 • %2$d catatan + + tambahkan catatan singkat permanen ke lokasi ini agar pengunjung lain dapat menemukannya. + relay geo tidak tersedia; catatan dijeda + tidak ada relay geo di sekitar + catatan bergantung pada relay geo. periksa koneksi lalu coba lagi. + memuat catatan… + belum ada catatan + jadilah yang pertama menambahkan catatan untuk tempat ini. + tutup + tambahkan catatan untuk tempat ini + Bluetooth Recommended + Mesh berjalan — %1$d peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + Buka Tentang + periksa catatan yang ditinggalkan di sini + 1 catatan ditinggalkan di sini — ketuk untuk membaca + %d catatan ditinggalkan di sini — ketuk untuk membaca + Bahasa + Bahasa aplikasi + Default sistem + Pilih bahasa + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b1dd66e8..af59fad3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,20 +1,423 @@ bitchat - È richiesta l\'autorizzazione Bluetooth per la messaggistica peer-to-peer senza Internet. - È richiesta l\'autorizzazione alla posizione per rilevare i dispositivi nelle vicinanze tramite Bluetooth. - È richiesta l\'autorizzazione alle notifiche per avvisarti di nuovi messaggi. + + + Il permesso Bluetooth è necessario per la messaggistica peer-to-peer senza internet. + Il permesso di localizzazione è necessario per scoprire i dispositivi vicini tramite Bluetooth. + Il permesso di notifica è necessario per avvisarti di nuovi messaggi. + + nickname scrivi un messaggio… Password - Unisciti al canale + Entra nel canale Esci Invia + Mostra comandi Indietro Persone Canali Utenti online Nessuno connesso Tocca tre volte per cancellare tutti i dati - La tua rete + Rete + + + Ottimizzazione batteria rilevata + Ottimizzazione batteria disattivata + Ottimizzazione batteria non richiesta + Controllo ottimizzazione batteria + Perché disattivare l\'ottimizzazione della batteria? + bitchat viene eseguito in background per mantenere le connessioni di rete mesh con i dispositivi vicini. L\'ottimizzazione della batteria può interrompere queste connessioni, causando ritardi o perdita di messaggi.\n\nDisattivare l\'ottimizzazione della batteria garantisce una messaggistica peer-to-peer affidabile. + Disattiva ottimizzazione batteria + Nota: puoi modificare questa impostazione in seguito in Impostazioni Android > App > bitchat > Batteria + Il tuo dispositivo non richiede impostazioni di ottimizzazione della batteria. bitchat funzionerà normalmente. + Il tuo dispositivo non richiede impostazioni di ottimizzazione della batteria. bitchat funzionerà normalmente. + bitchat può funzionare in modo affidabile in background + • Garantisce la consegna affidabile dei messaggi\n• Mantiene la connettività della rete mesh\n• Consente l\'inoltro dei messaggi in background\n• Previene le interruzioni della connessione + Controlla di nuovo + Salta per ora + Continua + Riprova + Salta + + + e altri %1$d + %1$d messaggi da %2$d persone + e altre %1$d conversazioni + bitchatter nelle vicinanze! + 1 persona nei dintorni + %1$d persone nei dintorni + Nuovi messaggi + Nuovi messaggi di posizione + Menzionato in %1$s + Menzionato in %1$s (+%2$d altri) + %1$d menzioni in %2$s + Nuova attività in %1$s + Messaggi in %1$s + %1$s si è unito alla conversazione + bitchat - %1$d menzioni + bitchat - chat di posizione + %1$d messaggi da %2$d posizioni + Menzionato nella chat mesh + %1$d menzioni nella chat mesh + e altre %1$d posizioni + e altri %1$d + + + Aggiungi ai preferiti + Rimuovi dai preferiti + Aggiungi segnalibro + + + Raggiungibile via Nostr + Messaggi privati non letti + Teletrasportato + Apri le impostazioni di posizione e canali + Peer connessi + Partecipanti geohash + Pronto per l\'handshake + Handshake in corso + Crittografia end-to-end + Handshake fallito + + + 📎 File ricevuto + 📄 %1$s + 📏 Dimensione: %1$s + 🏷️ Tipo: %1$s + 📂 Apri / Salva + ❌ Chiudi + Scegli immagine + + + Chat mesh offline + Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata. + Canali geohash online + Connettiti con persone nella tua area usando canali basati su geohash. Estendi la rete mesh usando relay internet pubblici. + Crittografia end-to-end + I messaggi privati sono crittografati. I messaggi dei canali sono pubblici. + sistema + chiaro + scuro + proof of work + PoW off + PoW on + aggiungi proof of work ai messaggi geohash per scoraggiare lo spam. + difficoltà: %1$d bit (~%2$s) + difficoltà %1$d richiede ~%2$s tentativi di hash + nessun proof of work richiesto + molto bassa - protezione spam minima + bassa - protezione spam base + media - buona protezione spam + alta - forte protezione spam + molto alta - può causare ritardi + estrema - richiede calcoli significativi + rete + Tor off + Tor on + instrada internet su Tor per una maggiore privacy. + Stato Tor: %1$s, bootstrap %2$d%% + Ultimo: %1$s + Eliminazione dati di emergenza + Impostazioni di debug + Open Source • Privacy First • Decentralizzato + Chiudi + annulla + + + Avviso + Servizi di localizzazione + Privacy + Errore + Ottimizzazione batteria + Vantaggi + Controllo ottimizzazione batteria + Ottimizzazione batteria non supportata + Bluetooth + Messaggio non letto + Apri mappa + Rimuovi segnalibro + Teletrasporta + Esci dal canale + Raggiungibile via Nostr + Preferito offline + Diminuisci precisione + Aumenta precisione + Seleziona geohash + Scorri in fondo + File + Immagine + Annulla + Link + Scegli media + Scegli file + Salva immagine corrente + Chiudi + Immagine %1$d di %2$d + Privacy protetta + Registra nota vocale + Chat mesh offline + Canali geohash online + Crittografia end-to-end + + + Immagine %1$d di %2$d + Immagine non disponibile + Immagine salvata in Download + Impossibile salvare l\'immagine + Immagine salvata in Download + Impossibile salvare l\'immagine + Scegli file + [file non disponibile] + sconosciuto + + + scegli un\'azione per questo messaggio o utente + scegli un\'azione per questo utente + copia messaggio + copia questo messaggio negli appunti + schiaffeggia %1$s + invia uno schiaffo giocoso + abbraccia %1$s + invia un abbraccio amichevole + blocca %1$s + blocca tutti i messaggi da questo utente + + + #canali di posizione + chatta con persone vicino a te usando i canali geohash. viene condiviso solo un geohash approssimativo, mai la posizione GPS esatta. non fare screenshot o condividere questa schermata per proteggere la tua privacy. + concedi permesso di localizzazione + permesso di localizzazione negato. abilita nelle impostazioni per usare i canali di posizione. + Apri impostazioni + \u2713 permesso di localizzazione concesso + controllo permessi… + ricerca canali vicini… + segnalibro + geohash + geohash non valido + teletrasporta + disattiva servizi di localizzazione + attiva servizi di localizzazione + mesh + blocco + quartiere + città + provincia + regione + #bluetooth • %1$s + + + strumenti di debug + utilità per sviluppatori per diagnostica e controllo + logging dettagliato + registra entrate/uscite peer, direzione connessione, instradamento pacchetti e relay + ruoli Bluetooth + server GATT + connessioni: %1$d / %2$d + max server + client GATT + max client + connessioni: %1$d / %2$d + max totali + relay pacchetti + dall\'avvio: %1$d + attiva/disattiva ruoli e chiudi tutte le connessioni quando disabilitato + impostazioni sincronizzazione + max pacchetti per sync: %1$d + dimensione max filtro GCS: %1$d byte (128–1024) + FPR obiettivo: %1$.2f%% + dispositivi connessi + ID del nostro dispositivo: %1$s + nessuno + disconnetti + risultati scansione recenti + connetti + console di debug + cancella + ultimi 10s: %1$d • 1m: %2$d • 15m: %3$d + P derivato: %1$s • max elementi stimati: %2$s + • diretto + come server (noi ospitiamo) + come client (noi connettiamo) + + + Servizi di localizzazione richiesti + Privacy prima di tutto + bitchat NON traccia la tua posizione.\n\nI servizi di localizzazione sono necessari per la scansione Bluetooth e per la funzionalità di chat geohash. + bitchat ha bisogno dei servizi di localizzazione per: + • Scansione dispositivi Bluetooth\n• Scoprire utenti vicini nella rete mesh\n• Funzionalità chat geohash\n• Nessun tracciamento o raccolta di posizione + Apri impostazioni di localizzazione + Controlla di nuovo + Servizi di localizzazione non disponibili + I servizi di localizzazione non sono disponibili su questo dispositivo. Ciò è insolito poiché i servizi di localizzazione sono standard sui dispositivi Android.\n\nbitchat ha bisogno dei servizi di localizzazione per far funzionare correttamente la scansione Bluetooth (requisito Android). Senza questo, l\'app non può scoprire gli utenti vicini. + Controllo servizi di localizzazione… + Bluetooth richiesto + bitchat ha bisogno del Bluetooth per: + • Scoprire utenti vicini\n• Creare connessioni di rete mesh\n• Inviare e ricevere messaggi\n• Funzionare senza internet o server + Attiva Bluetooth + Bluetooth non supportato + Questo dispositivo non supporta Bluetooth Low Energy (BLE), che è necessario per il funzionamento di bitchat.\n\nbitchat ha bisogno di BLE per creare reti mesh e comunicare con dispositivi vicini senza internet. + Controllo stato Bluetooth… + ottimizzazione batteria rilevata + Ottimizzazione batteria abilitata + bitchat deve essere eseguito in background per mantenere le connessioni mesh. l\'ottimizzazione della batteria può interrompere queste connessioni. + Vantaggi della disattivazione + • consegna messaggi affidabile\n• mantiene la connettività mesh\n• previene le interruzioni della connessione + Disattiva ottimizzazione batteria + ottimizzazione batteria disattivata + Continua + Inizializzazione rete mesh + Configurazione rete mesh Bluetooth… + Dovrebbe richiedere solo pochi secondi + Configurazione non completata + Riprova + La tua privacy è protetta + • nessun tracciamento o raccolta dati\n• le chat mesh Bluetooth sono completamente offline\n• le chat geohash usano internet + permessi + Concedi permessi + bitchat NON traccia la tua posizione + nessuno nei dintorni… + (tu) + scorri e ingrandisci per selezionare un geohash + seleziona + scrivi un messaggio… + menzione + v%1$s + image/* + Immagine + File + + + 📷 ha inviato un\'immagine + 🎤 ha inviato un messaggio vocale + 📎 ha inviato un file + + + Riproduci + Pausa + + + Necessario per scoprire utenti bitchat tramite Bluetooth + Consenti a bitchat di connettersi a dispositivi vicini + Richiesto da Android per scoprire utenti bitchat vicini tramite Bluetooth + bitchat ha bisogno di questo per cercare dispositivi vicini + Ricevi notifiche quando ricevi messaggi privati + Consenti a bitchat di inviarti notifiche + Disattiva l\'ottimizzazione della batteria per garantire che bitchat funzioni in modo affidabile in background e mantenga le connessioni di rete mesh + Consenti a bitchat di funzionare senza restrizioni della batteria + + + Dispositivi vicini + Posizione precisa + Microfono + Notifiche + Ottimizzazione batteria + Altro + + + Inserisci password del canale + Il canale %1$s è protetto da password. Inserisci la password per entrare. + Password + Entra + Annulla + + + @ + · ⧉ + @%1$s + %1$d / %2$d + @%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + . + ⚠️ + + + + + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + ? + + + + e %d altro + e altri %d + + + + 1 persona + %d persone + + + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d note + + aggiungi brevi note permanenti a questo luogo per altri visitatori. + relay geolocalizzati non disponibili; note in pausa + nessun relay geolocalizzato nelle vicinanze + le note dipendono dai relay geo. controlla la connessione e riprova. + caricamento note… + nessuna nota ancora + sii il primo ad aggiungerne una per questo posto. + chiudi + aggiungi una nota per questo luogo + Bluetooth consigliato + Mesh in esecuzione — %1$d peer + + verifica + scansiona per verificarmi + scansiona qr altrui + scansiona qr altrui + mostra mio qr + rimuovi verifica + qr non disponibile + permesso fotocamera necessario + abilita fotocamera + incolla url verifica + convalida + verifica richiesta + verifica sicurezza + loro impronta + tua impronta + handshake in corso + apri chat privata + criptato & verificato + criptato + handshake + handshake fallito + non criptato + verificato + hai verificato questa persona. + non verificato + confronta con %1$s via canale sicuro. + segna verificato + avvia handshake + copia + Verifica reciproca + Tu e %1$s vi siete verificati + verifica reciproca con %1$s + Verificato + Hai verificato %1$s + verificato %1$s + Apri Informazioni + controlla se ci sono note lasciate qui + 1 nota lasciata qui — tocca per leggere + %d note lasciate qui — tocca per leggere + Lingua + Lingua dell’app + Predefinita di sistema + Seleziona lingua diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 2f5441d6..17841dcc 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,20 +1,396 @@ bitchat - インターネットに接続せずにピアツーピアメッセージングを行うには、Bluetooth の許可が必要です。 - Bluetooth 経由で近くのデバイスを検出するには、位置情報の許可が必要です。 - 新着メッセージを通知するには、通知の許可が必要です。 + + + Bluetooth の許可は、インターネットなしで他の人と直接チャットするために必要です。 + 位置情報の許可は、Bluetooth で近くのデバイスを見つけるために必要です。 + 通知の許可は、新しいメッセージをお知らせするために必要です。 + + ニックネーム メッセージを入力… パスワード チャンネルに参加 退出 送信 + コマンドを表示 戻る - ユーザー + 人 チャンネル オンラインユーザー - 誰も接続していません - トリプルタップですべてのデータを消去 + 接続中のユーザーはいません + 3 回タップで全データを消去 あなたのネットワーク + + + バッテリー最適化を検出 + バッテリー最適化は無効です + バッテリー最適化は不要です + バッテリー最適化を確認中 + なぜバッテリー最適化を無効にするのか + bitchat はバックグラウンドで動作して、近くのデバイスとのメッシュ接続を維持します。バッテリー最適化はこれらの接続を中断し、メッセージの遅延や欠落を引き起こす可能性があります。\n\nバッテリー最適化を無効にすると、信頼できるピアツーピア通信が可能になります。 + バッテリー最適化を無効にする + 注: 後で Android の設定 > アプリ > bitchat > バッテリー で変更できます。 + この端末ではバッテリー最適化の設定は不要です。bitchat は通常通り動作します。 + この端末ではバッテリー最適化の設定は不要です。bitchat は通常通り動作します。 + bitchat はバックグラウンドで信頼して動作できます + • メッセージの確実な配信\n• メッシュ接続の維持\n• バックグラウンド転送\n• 切断の防止 + 再確認 + 今はスキップ + 続行 + 再試行 + スキップ + + + ほか %1$d 件 + %2$d 人からの %1$d 件のメッセージ + ほか %1$d 会話 + 近くに bitchatter ! + 近くに 1 人 + 近くに %1$d 人 + 新着メッセージ + 新しい位置情報メッセージ + %1$s でのメンション + %1$s でのメンション (+%2$d) + %2$s で %1$d 件のメンション + %1$s の新しいアクティビティ + %1$s のメッセージ + %1$s が会話に参加しました + bitchat – %1$d 件のメンション + bitchat – ロケーションチャット + %2$d 箇所からの %1$d 件のメッセージ + メッシュチャットでメンション + メッシュチャットで %1$d 件のメンション + ほか %1$d 箇所 + ほか %1$d + + + お気に入りに追加 + お気に入りから削除 + ブックマークに追加 + + + Nostr 経由で到達可能 + 未読のプライベートメッセージ + テレポート済み + 位置情報とチャンネルの設定を開く + 接続中のピア + Geohash 参加者 + ハンドシェイク準備完了 + ハンドシェイク中 + エンドツーエンド暗号化 + ハンドシェイク失敗 + + + 📎 ファイルを受信 + 📄 %1$s + 📏 サイズ: %1$s + 🏷️ 種類: %1$s + 📂 開く / 保存 + ❌ 閉じる + 画像を選択 + + + オフライン・メッシュチャット + インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。 + オンライン Geohash チャンネル + Geohash ベースのチャンネルで近くの人々とつながります。公開インターネットのリレーを介してメッシュを拡張します。 + エンドツーエンド暗号化 + プライベートメッセージは暗号化されます。チャンネルメッセージは公開です。 + システム + ライト + ダーク + Proof of Work + PoW オフ + PoW オン + スパム対策として Geohash メッセージに Proof of Work を追加します。 + 難易度: %1$d ビット (~%2$s) + 難易度 %1$d は約 %2$s 回のハッシュ試行が必要 + Proof of Work は不要 + 非常に低い – 最小限のスパム対策 + 低い – 基本的なスパム対策 + 中 – 良好なスパム対策 + 高い – 強力なスパム対策 + 非常に高い – 遅延が発生する可能性 + 極めて高い – 相当な計算資源が必要 + ネットワーク + Tor オフ + Tor オン + プライバシー向上のため、インターネット通信を Tor 経由にします。 + Tor ステータス: %1$s、ブートストラップ %2$d%% + 最終: %1$s + 緊急データ消去 + デバッグ設定 + オープンソース • プライバシーファースト • 分散型 + 閉じる + キャンセル + + + 警告 + 位置情報サービス + プライバシー + エラー + バッテリー最適化 + 利点 + バッテリー最適化を確認中 + バッテリー最適化は非対応 + Bluetooth + 未読メッセージ + 地図を開く + ブックマークを削除 + テレポート + チャンネルから退出 + Nostr 経由で到達可能 + お気に入り (オフライン) + 精度を下げる + 精度を上げる + Geohash を選択 + 一番下へスクロール + ファイル + 画像 + キャンセル + リンク + メディアを選択 + ファイルを選択 + 現在の画像を保存 + 閉じる + 画像 %1$d / %2$d + プライバシー保護 + ボイスメモを録音 + オフライン・メッシュチャット + オンライン Geohash チャンネル + エンドツーエンド暗号化 + + + 画像 %1$d / %2$d + 画像は利用できません + 画像を「ダウンロード」に保存しました + 画像を保存できませんでした + 画像を「ダウンロード」に保存しました + 画像を保存できませんでした + ファイルを選択 + [ファイルは利用できません] + 不明 + + + このメッセージまたはユーザーの操作を選択 + このユーザーの操作を選択 + メッセージをコピー + このメッセージをクリップボードにコピー + %1$s を平手打ち + 冗談の平手打ちを送る + %1$s をハグ + フレンドリーなハグを送る + %1$s をブロック + このユーザーからのすべてのメッセージをブロック + + + #ロケーション・チャンネル + Geohash チャンネルで近くの人とチャットしましょう。共有されるのは大まかな geohash のみで、正確な GPS データは共有されません。プライバシー保護のため、この画面のスクリーンショットや共有は行わないでください。 + 位置情報の許可を与える + 位置情報の許可が拒否されました。ロケーション・チャンネルを使用するには設定で有効にしてください。 + 設定を開く + \u2713 位置情報の許可が付与されました + 権限を確認中… + 近くのチャンネルを検索中… + ブックマーク + Geohash + 無効な geohash + テレポート + 位置情報サービスを無効にする + 位置情報サービスを有効にする + mesh + ブロック + 近隣 + 市 + 州 / 県 + 地域 + #bluetooth • %1$s + + + デバッグツール + 診断と制御のための開発者ツール + 詳細ログ + ピアの参加/離脱、接続方向、パケット転送、リレーを記録 + Bluetooth ロール + GATT サーバー + 接続: %1$d / %2$d + 最大サーバー + GATT クライアント + 最大クライアント + 接続: %1$d / %2$d + 合計最大 + パケットリレー + 開始以降: %1$d + ロールのオン/オフを切り替え、オフ時にすべての接続を閉じる + 同期設定 + 同期あたりの最大パケット: %1$d + GCS フィルター最大サイズ: %1$d バイト (128–1024) + 目標 FPR: %1$.2f%% + 接続済みデバイス + 自分のデバイス ID: %1$s + なし + 切断 + 最近スキャンしたデバイス + 接続 + デバッグコンソール + クリア + 直近 10 秒: %1$d • 1 分: %2$d • 15 分: %3$d + 導出 P: %1$s • 推定最大アイテム: %2$s + • 直接 + サーバーとして (自分がホスト) + クライアントとして (自分が接続) + + + 位置情報サービスが必要です + プライバシー第一 + bitchat はあなたの位置情報を追跡しません。\n\n位置情報サービスは、Bluetooth スキャンと geohash チャット機能に必要です。 + bitchat が位置情報サービスを必要とする理由: + • Bluetooth デバイスのスキャン\n• メッシュネットワークで近くのユーザーを発見\n• geohash チャット機能\n• 位置情報の追跡や収集は行いません + 位置情報の設定を開く + 再確認 + 位置情報サービスが利用できません + この端末では位置情報サービスが利用できません。Android 端末では通常標準機能のため、これは珍しい状況です。\n\nBluetooth スキャン (Android の要件) を正しく動作させるために位置情報サービスが必要です。これがないと、近くのユーザーを検出できません。 + 位置情報サービスを確認中… + Bluetooth が必要です + bitchat が Bluetooth を必要とする理由: + • 近くのユーザーを発見\n• メッシュネットワーク接続を作成\n• メッセージの送受信\n• インターネットやサーバーなしで動作 + Bluetooth を有効にする + Bluetooth はサポートされていません + この端末は Bluetooth Low Energy (BLE) をサポートしていません。bitchat の動作に必要です。\n\nBLE は、インターネットなしで近くのデバイスとメッシュネットワークを作るために必要です。 + Bluetooth ステータスを確認中… + バッテリー最適化を検出 + バッテリー最適化が有効 + メッシュ接続を維持するため、bitchat はバックグラウンドで動作する必要があります。バッテリー最適化は接続を中断する可能性があります。 + 無効化の利点 + • 信頼できるメッセージ配信\n• メッシュ接続の維持\n• 切断の防止 + バッテリー最適化を無効化 + バッテリー最適化は無効 + 続行 + メッシュネットワークを初期化中 + Bluetooth メッシュネットワークを設定中… + 数秒で完了するはずです + 設定が完了していません + 再試行 + あなたのプライバシーは保護されています + • 追跡やデータ収集はありません\n• Bluetooth メッシュチャットは完全にオフライン\n• geohash チャットはインターネットを使用 + 権限 + 権限を付与 + bitchat はあなたの位置情報を追跡しません + 誰もいません… + (あなた) + パン/ズームして geohash を選択 + 選択 + メッセージを入力… + メンション + v%1$s + image/* + 画像 + ファイル + + + 📷 画像を送信しました + 🎤 ボイスメモを送信しました + 📎 ファイルを送信しました + + + 再生 + 一時停止 + + + Bluetooth で bitchat ユーザーを検出するために必要です + bitchat が近くのデバイスに接続することを許可 + Android の要件: Bluetooth 経由で近くの bitchat ユーザーを検出 + bitchat が近くのデバイスをスキャンするために必要です + プライベートメッセージを受信したときに通知を受け取る + bitchat から通知を送信することを許可 + bitchat がバックグラウンドで安定して動作し、メッシュ接続を維持できるように、バッテリー最適化を無効にします + bitchat がバッテリー制限なしで動作することを許可 + + + 近くのデバイス + 正確な位置情報 + マイク + 通知 + バッテリー最適化 + その他 + + + チャンネルのパスワードを入力 + チャンネル %1$s はパスワードで保護されています。参加するにはパスワードを入力してください。 + パスワード + 参加 + キャンセル + + + + ほか %d + ほか %d + + + + 1 人 + %d 人 + + + + #%1$s ± 1 • %2$d 件のメモ + #%1$s ± 1 • %2$d 件のメモ + + 他の訪問者が見つけられるよう、この場所に短いメモを追加しましょう。 + ジオリレーが利用できません。メモを一時停止中 + 近くにジオリレーがありません + メモはジオリレーに依存します。接続を確認して再試行してください。 + メモを読み込み中… + まだメモはありません + この場所の最初のメモを追加しましょう。 + 閉じる + この場所へのメモを追加 + Bluetooth推奨 + メッシュ実行中 — %1$d ピア + + 検証 + 私を検証するためにスキャン + 他人のQRをスキャン + 他人のQRをスキャン + QRコードを表示 + 検証を削除 + QR利用不可 + QRスキャンにカメラ権限が必要です + カメラを有効化 + 検証URLを貼り付け + 検証する + 検証をリクエストしました + セキュリティ検証 + 相手の指紋 + あなたの指紋 + ハンドシェイク保留中 + 指紋を見るにはプライベートチャットを開いてください + 暗号化&検証済み + 暗号化済み + ハンドシェイク中 + ハンドシェイク失敗 + 未暗号化 + 検証済み + この人物の身元を検証しました。 + 未検証 + 安全な経路で %1$s と指紋を比較してください。 + 検証済みにする + ハンドシェイク開始 + コピー + 相互検証 + あなたと %1$s は相互に検証しました + %1$s と相互検証しました + 検証済み + %1$s を検証しました + %1$s を検証しました + このアプリについてを開く + ここに残されたメモを確認 + ここに1件のメモがあります — タップして読む + ここに%d件のメモがあります — タップして読む + 言語 + アプリの言語 + システムのデフォルト + 言語を選択 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml new file mode 100644 index 00000000..8fe186cc --- /dev/null +++ b/app/src/main/res/values-ka/strings.xml @@ -0,0 +1,383 @@ + + + bitchat + + + Bluetooth-ის ნებართვა საჭიროა, რომ ინტერნეტის გარეშე პირდაპირ ისაუბროთ სხვებთან. + მდებარეობის ნებართვა საჭიროა ახლომდებარე მოწყობილობების Bluetooth-ით საპოვნელად. + შეტყობინებების ნებართვა საჭიროა ახალი შეტყობინებების შეგახსენებლად. + + + მეტსახელი + შეიყვანეთ შეტყობინება… + პაროლი + არხში შეუერთება + გამოსვლა + გაგზავნა + ბრძანებების ჩვენება + უკან + ხალხი + არხები + ონლაინ მომხმარებლები + არავინ არის დაკავშირებული + სამჯერ დააჭირეთ ყველა მონაცემის წასაშლელად + თქვენი ქსელი + + + ბატარეის ოპტიმიზაცია აღმოჩნდა + ბატარეის ოპტიმიზაცია გამორთულია + ბატარეის ოპტიმიზაცია არ არის საჭირო + ბატარეის ოპტიმიზაციის შემოწმება + რატომ უნდა გამოირთოს ბატარეის ოპტიმიზაცია? + bitchat მუშაობს ფონში, რათა ახლომდებარე მოწყობილობებთან mesh-კავშირები შეინარჩუნოს. ბატარეის ოპტიმიზაციამ შეიძლება დააწყვეტინოს ეს კავშირები, რის გამოც შეტყობინებები დაგვიანდეს ან დაიკარგოს.\n\nბატარეის ოპტიმიზაციის გამორთვა უზრუნველყოფს სანდო peer-to-peer კომუნიკაციას. + ბატარეის ოპტიმიზაციის გამორთვა + შენიშვნა: შეგიძლიათ შეცვალოთ მოგვიანებით Android-ის პარამეტრებში > აპები > bitchat > ბატარეა. + თქვენს მოწყობილობას ბატარეის ოპტიმიზაცია არ სჭირდება. bitchat ჩვეულებრივ იმუშავებს. + თქვენს მოწყობილობას ბატარეის ოპტიმიზაცია არ სჭირდება. bitchat ჩვეულებრივ იმუშავებს. + bitchat შეძლებს სანდოდ იმუშაოს ფონში + • შეტყობინებების სანდო მიწოდება\n• mesh-კავშირის შენარჩუნება\n• ფონური გადაგზავნა\n• გათიშვების თავიდან აცილება + თავიდან შემოწმება + ახლა გამოტოვება + გაგრძელება + კვლავ ცდა + გამოტოვება + + + და კიდევ %1$d + %2$d ადამიანიდან %1$d შეტყობინება + და კიდევ %1$d საუბარი + bitchatter-ები ახლოს! + 1 ადამიანი ახლოს + ახლოს %1$d ადამიანი + ახალი შეტყობინებები + მდებარეობის ახალი შეტყობინებები + ხსენება %1$s-ში + ხსენება %1$s-ში (+%2$d) + %2$s-ში %1$d ხსენება + ახალი აქტივობა %1$s-ში + შეტყობინებები %1$s-ში + %1$s შეუერთდა საუბარს + bitchat – %1$d ხსენება + bitchat – მდებარეობის ჩათები + %2$d ადგილიდან %1$d შეტყობინება + mesh-ჩათში ხსენება + mesh-ჩათში %1$d ხსენება + და კიდევ %1$d ადგილი + და კიდევ %1$d + + + რჩეულებში დამატება + რჩეულებიდან წაშლა + სანიშნეს დამატება + + + მისაწვდომია Nostr-ით + წაუკითხავი პირადი შეტყობინებები + ტელეპორტირებული + მდებარეობისა და არხების პარამეტრების გახსნა + დაკავშირებული პირები + geohash მონაწილეები + მზადაა ხელის ჩამოსართმევად + მიმდინარეობს ხელის ჩამორთმევა + ბოლო-მდე დაშიფვრა + ხელის ჩამორთმევა წარუმატებლად დასრულდა + + + 📎 ფაილი მიღებულია + 📄 %1$s + 📏 ზომა: %1$s + 🏷️ ტიპი: %1$s + 📂 გახსნა / შენახვა + ❌ დახურვა + სურათის არჩევა + + + ოფლაინ mesh-ჩათი + ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა. + ონლაინ geohash არხები + დაუკავშირდით ახლომდებარე ხალხს geohash-ზე დაფუძნებული არხებით. გააფართოვეთ mesh საჯარო ინტერნეტ რილეებით. + ბოლო-მდე დაშიფვრა + პირადი შეტყობინებები დაშიფრულია. არხის შეტყობინებები საჯაროა. + სისტემა + ღია + მუქი + Proof of Work + PoW გამორთულია + PoW ჩართულია + დაამატეთ Proof of Work geohash შეტყობინებებს სპამის შესამცირებლად. + სირთულე: %1$d ბიტი (~%2$s) + სირთულე %1$d მოითხოვს ~%2$s ჰეშის მცდელობას + Proof of Work საჭირო არ არის + ძალიან დაბალი – მინიმალური სპამის დაცვა + დაბალი – საბაზისო დაცვა + საშუალო – კარგი დაცვა + მაღალი – ძლიერი დაცვა + ძალიან მაღალი – შეიძლება გამოიწვიოს დაყოვნება + ექსტრემალური – საჭიროებს დიდ გამოთვლით რესურსს + ქსელი + Tor გამორთულია + Tor ჩართულია + ინტერნეტ ტრაფიკის Tor-ით მარშრუტიზაცია პერსონალურობისთვის. + Tor სტატუსი: %1$s, ჩატვირთვა %2$d%% + ბოლო: %1$s + სასწრაფო მონაცემების წაშლა + დebug პარამეტრები + ღია კოდი • კონფიდენციალობა პირველია • დეცენტრალიზებული + დახურვა + გაუქმება + + + გაფრთხილება + მდებარეობის სერვისები + კონფიდენციალობა + შეცდომა + ბატარეის ოპტიმიზაცია + სარგებელი + ბატარეის ოპტიმიზაციის შემოწმება + ბატარეის ოპტიმიზაცია მხარდაჭერილი არ არის + Bluetooth + წაუკითხავი შეტყობინება + რუკის გახსნა + სანიშნეს წაშლა + ტელეპორტი + არხიდან გამოსვლა + მისაწვდომია Nostr-ით + რჩეული (ოფლაინ) + სიზუსტის შემცირება + სიზუსტის გაზრდა + აირჩიეთ geohash + ქვემოთ გადახვევა + ფაილი + სურათი + გაუქმება + ბმული + მედიის არჩევა + ფაილის არჩევა + მიმდინარე სურათის შენახვა + დახურვა + სურათი %1$d %2$d-დან + კონფიდენციალობა დაცულია + ხმოვანი ჩანაწერის ჩაწერა + ოფლაინ mesh-ჩათი + ონლაინ geohash არხები + ბოლო-მდე დაშიფვრა + + + სურათი %1$d %2$d-დან + სურათი მიუწვდომელია + სურათი შენახულია "ჩამოტვირთვებში" + სურათის შენახვა ვერ მოხერხდა + სურათი შენახულია "ჩამოტვირთვებში" + სურათის შენახვა ვერ მოხერხდა + ფაილის არჩევა + [ფაილი მიუწვდომელია] + უცნობი + + + აირჩიეთ ქმედება ამ შეტყობინებისთვის ან მომხმარებლისთვის + აირჩიეთ ქმედება ამ მომხმარებლისთვის + შეტყობინების კოპირება + ამ შეტყობინების კოპირება ბუფერში + %1$s-ის ლ®აპით დარტყმა + გაგზავნეთ ხუმრობით ლ®აპი + %1$s-ის ჩახუტება + გაგზავნეთ მეგობრული ჩახუტება + %1$s-ის დაბლოკვა + ამ მომხმარებლის ყველა შეტყობინების დაბლოკვა + + + #მდებარეობის არხები + ესაუბრეთ თქვენს ახლოს მყოფ ადამიანებს geohash არხებით. იზიარება მხოლოდApprox geohash, არასოდეს ზუსტი GPS მონაცემები. თქვენი კონფიდენციალურობის დასაცავად ნუ გადაიღებთ სკრინშოტებს და ნუ გააზიარებთ ამ ეკრანს. + მდებარეობის ნებართვის მინიჭება + მდებარეობის ნებართვა უარყოფილია. ჩართეთ პარამეტრებში მდებარეობის არხების გამოსაყენებლად. + პარამეტრების გახსნა + \u2713 მდებარეობის ნებართვა მინიჭებულია + ნებართვების შემოწმება… + ახლომდებარე არხების ძიება… + სანიშნეები + Geohash + არასწორი geohash + ტელეპორტი + მდებარეობის სერვისების გამორთვა + მდებარეობის სერვისების ჩართვა + mesh + კვარტალი + ახლომდებარე რაიონი + ქალაქი + პროვინცია + რეგიონი + #bluetooth • %1$s + + + დაბაგირების ხელსაწყოები + დეველოპერის ხელსაწყოები დიაგნოზისთვის და კონტროლისთვის + გაფართოებული ლოგირება + იწერება პირების ჩართვა/გამორთვა, კავშირის მიმართულება, პაკეტების გადაცემა და რილეები + Bluetooth როლები + GATT სერვერი + კავშირები: %1$d / %2$d + მაქს. სერვერი + GATT კლიენტი + მაქს. კლიენტი + კავშირები: %1$d / %2$d + მაქს. სულ + პაკეტების რელე + გაშვებიდან: %1$d + როლების გადართვა და გამორთვისას ყველა კავშირის დახურვა + სინქრონიზაციის პარამეტრები + მაქს. პაკეტი სინქრონიზაციაზე: %1$d + მაქს. GCS ფილტრის ზომა: %1$d ბაიტი (128–1024) + სამიზნე FPR: %1$.2f%% + დაკავშირებული მოწყობილობები + ჩვენი მოწყობილობის ID: %1$s + არცერთი + გათიშვა + ბოლო სკანირებული მოწყობილობები + დაკავშირება + დაბაგ კონსოლი + გასუფთავება + ბოლო 10 წმ: %1$d • 1 წთ: %2$d • 15 წთ: %3$d + განმუხტული P: %1$s • სავარაუდო მაქს. ელემენტები: %2$s + • პირდაპირ + სერვერის როლში (ჩვენ ვმასპინძლობთ) + კლიენტის როლში (ჩვენ ვუკავშირდებით) + + + მდებარეობის სერვისებია საჭირო + კონფიდენციალობა პირველია + bitchat არ აკონტროლებს თქვენს მდებარეობას.\n\nმდებარეობის სერვისები საჭიროა Bluetooth სკანირებისა და geohash ჩათის ფუნქციისთვის. + bitchat-ს სჭირდება მდებარეობის სერვისები შემდეგისთვის: + • Bluetooth მოწყობილობების სკანირება\n• mesh ქსელში ახლომდებარე მომხმარებლების აღმოჩენა\n• geohash ჩათის ფუნქცია\n• არც ტრეკინგი, არც მდებარეობის შეგროვება + მდებარეობის პარამეტრების გახსნა + თავიდან შემოწმება + მდებარეობის სერვისები მიუწვდომელია + ამ მოწყობილობაზე მდებარეობის სერვისები მიუწვდომელია. ეს უჩვეულოა, რადგან Android მოწყობილობებზე ისინი სტანდარტულია.\n\nBluetooth სკანირებისთვის (Android-ის მოთხოვნა) საჭიროა მდებარეობის სერვისები. ამის გარეშე აპს არ შეუძლია ახლომდებარე მომხმარებლების აღმოჩენა. + მდებარეობის სერვისების შემოწმება… + Bluetooth საჭიროა + bitchat-ს სჭირდება Bluetooth შემდეგისთვის: + • ახლომდებარე მომხმარებლების აღმოჩენა\n• mesh ქსელის კავშირების შექმნა\n• შეტყობინებების გაგზავნა/მიღება\n• მუშაობა ინტერნეტის ან სერვერების გარეშე + Bluetooth-ის ჩართვა + Bluetooth არ მხარდაჭერილია + ამ მოწყობილობას არ უჭერს მხარს Bluetooth Low Energy (BLE), რომელიც საჭიროა bitchat-ისთვის.\n\nBLE საჭიროა mesh ქსელების შესაქმნელად და ახლომდებარე მოწყობილობებთან ინტერნეტის გარეშე კომუნიკაციისთვის. + Bluetooth სტატუსის შემოწმება… + ბატარეის ოპტიმიზაცია აღმოჩნდა + ბატარეის ოპტიმიზაცია ჩართულია + mesh კავშირების შესანარჩუნებლად bitchat უნდა იმუშავოს ფონში. ბატარეის ოპტიმიზაციამ შეიძლება დაარღვიოს ეს კავშირები. + გამორთვის სარგებელი + • სანდო მიწოდება\n• mesh კავშირის შენარჩუნება\n• გათიშვების თავიდან აცილება + ბატარეის ოპტიმიზაციის გამორთვა + ბატარეის ოპტიმიზაცია გამორთულია + გაგრძელება + mesh ქსელის ინიციალიზაცია + Bluetooth mesh ქსელის მოწყობა… + ეს რამდენიმე წამს უნდა გაგრძელდეს + მოწყობა დაუსრულებელია + კვლავ ცდა + თქვენი კონფიდენციალობა დაცულია + • ტრეკინგი ან მონაცემთა შეგროვება არა\n• Bluetooth mesh-ჩათები სრულად ოფლაინ\n• geohash ჩათი იყენებს ინტერნეტს + ნებართვები + ნებართვების მინიჭება + bitchat არ აკონტროლებს თქვენს მდებარეობას + არავინ არის ახლოს… + (თქვენ) + პანორამირება/ზუმი geohash-ის ასარჩევად + არჩევა + შეიყვანეთ შეტყობინება… + ხსენება + v%1$s + image/* + სურათი + ფაილი + + + 📷 გაგზავნა სურათი + 🎤 გაგზავნა ხმოვანი შენიშვნა + 📎 გაგზავნა ფაილი + + + დაკვრა + პაუზა + + + საჭიროა bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად + დაუშვით bitchat-ს დაუკავშირდეს ახლომდებარე მოწყობილობებს + Android მოითხოვს ახლომდებარე bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად + ეს საჭიროა ახლომდებარე მოწყობილობების დასასკანად + საინფორმაციო შეტყობინებების მიღება პირადი მესიჯებისას + დაუშვით bitchat-ს გამოგიგზავნოთ შეტყობინებები + გამორთეთ ბატარეის ოპტიმიზაცია, რომ bitchat სანდოდ იმუშაოს ფონში და შეინარჩუნოს mesh-კავშირები + დაუშვით bitchat-ს იმუშაოს ბატარეის შეზღუდვების გარეშე + + + ახლომდებარე მოწყობილობები + ზუსტი მდებარეობა + მიკროფონი + შეტყობინებები + ბატარეის ოპტიმიზაცია + სხვა + + + შეიყვანეთ არხის პაროლი + არხი %1$s დაცულია პაროლით. შეიყვანეთ პაროლი შესაერთებლად. + პაროლი + შეუერთდი + გაუქმება + + + + და კიდევ %d + და კიდევ %d + + + + 1 ადამიანი + %d ადამიანი + + + Bluetooth Recommended + Mesh გაშვებულია — %1$d პირები + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + აპის შესახებ გახსნა + აქ დატოვებული ჩანაწერების შემოწმება + აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ + აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ + ენა + აპის ენა + სისტემის ნაგულისხმევი + ენის არჩევა + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b00009b8..030165d6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,20 +1,396 @@ bitchat - 인터넷 없이 P2P(피어투피어) 메시징을 사용하려면 Bluetooth 권한이 필요합니다. - Bluetooth를 통해 근처 기기를 검색하려면 위치 권한이 필요합니다. - 새 메시지를 알리려면 알림 권한이 필요합니다. + + + 인터넷 없이 다른 사람과 직접 채팅하려면 블루투스 권한이 필요합니다. + 블루투스로 주변 기기를 찾으려면 위치 권한이 필요합니다. + 새 메시지를 알려면 알림 권한이 필요합니다. + + 닉네임 메시지 입력… 비밀번호 - 채널 가입 + 채널 참가 나가기 보내기 + 명령 보기 뒤로 사람들 채널 온라인 사용자 - 연결된 사용자 없음 - 세 번 탭하여 모든 데이터 삭제 - 귀하의 네트워크 + 연결된 사람이 없음 + 모든 데이터를 지우려면 세 번 탭하세요 + 네트워크 + + + 배터리 최적화 감지됨 + 배터리 최적화 비활성화됨 + 배터리 최적화 필요 없음 + 배터리 최적화 확인 중 + 왜 배터리 최적화를 끄나요? + bitchat은 근처 기기와의 메쉬 연결을 유지하기 위해 백그라운드에서 동작합니다. 배터리 최적화는 이러한 연결을 중단시켜 메시지가 지연되거나 손실될 수 있습니다.\n\n배터리 최적화를 끄면 신뢰할 수 있는 P2P 통신이 보장됩니다. + 배터리 최적화 끄기 + 참고: 이후 Android 설정 > 앱 > bitchat > 배터리에서 변경할 수 있습니다. + 이 기기는 배터리 최적화 설정이 필요하지 않습니다. bitchat은 정상적으로 동작합니다. + 이 기기는 배터리 최적화 설정이 필요하지 않습니다. bitchat은 정상적으로 동작합니다. + bitchat이 백그라운드에서 안정적으로 실행됩니다 + • 신뢰할 수 있는 메시지 전달\n• 메쉬 연결 유지\n• 백그라운드 메시지 릴레이\n• 연결 끊김 방지 + 다시 확인 + 지금 건너뛰기 + 계속 + 다시 시도 + 건너뛰기 + + + 그리고 %1$d개 더 + %2$d명에게서 %1$d개의 메시지 + 그리고 %1$d개의 대화 더 + 근처 bitchatter! + 근처 1명 + 근처 %1$d명 + 새 메시지 + 새 위치 메시지 + %1$s에서 언급됨 + %1$s에서 언급됨 (+%2$d) + %2$s에서 %1$d회 언급 + %1$s의 새 활동 + %1$s의 메시지 + %1$s님이 대화에 참여했습니다 + bitchat – %1$d회 언급 + bitchat – 위치 채팅 + %2$d개의 장소에서 온 %1$d개의 메시지 + 메쉬 채팅에서 언급됨 + 메쉬 채팅에서 %1$d회 언급 + 그리고 %1$d개의 장소 더 + 그리고 %1$d개 더 + + + 즐겨찾기에 추가 + 즐겨찾기에서 제거 + 북마크 추가 + + + Nostr로 연결 가능 + 읽지 않은 개인 메시지 + 텔레포트됨 + 위치 및 채널 설정 열기 + 연결된 피어 + geohash 참여자 + 핸드셰이크 준비됨 + 핸드셰이크 진행 중 + 종단간 암호화 + 핸드셰이크 실패 + + + 📎 파일 수신 + 📄 %1$s + 📏 크기: %1$s + 🏷️ 유형: %1$s + 📂 열기 / 저장 + ❌ 닫기 + 이미지 선택 + + + 오프라인 메쉬 채팅 + 인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다. + 온라인 geohash 채널 + geohash 기반 채널을 통해 주변 사람들과 연결하세요. 공용 인터넷 릴레이로 메쉬를 확장합니다. + 종단간 암호화 + 개인 메시지는 암호화됩니다. 채널 메시지는 공개입니다. + 시스템 + 라이트 + 다크 + 작업증명 + PoW 끔 + PoW 켬 + 스팸을 어렵게 만들기 위해 geohash 메시지에 작업증명을 추가하세요. + 난이도: %1$d 비트 (~%2$s) + 난이도 %1$d는 ~%2$s 해시 시도가 필요합니다 + 작업증명 필요 없음 + 매우 낮음 – 최소 스팸 보호 + 낮음 – 기본 스팸 보호 + 중간 – 좋은 스팸 보호 + 높음 – 강한 스팸 보호 + 매우 높음 – 지연이 발생할 수 있음 + 매우 극단 – 상당한 연산 능력 필요 + 네트워크 + Tor 끔 + Tor 켬 + 개인정보 보호를 위해 인터넷 트래픽을 Tor를 통해 라우팅합니다. + Tor 상태: %1$s, 부트스트랩 %2$d%% + 마지막: %1$s + 긴급 데이터 삭제 + 디버그 설정 + 오픈 소스 • 프라이버시 우선 • 탈중앙 + 닫기 + 취소 + + + 경고 + 위치 서비스 + 개인정보 + 오류 + 배터리 최적화 + 이점 + 배터리 최적화 확인 중 + 배터리 최적화 미지원 + 블루투스 + 읽지 않은 메시지 + 지도 열기 + 북마크 제거 + 텔레포트 + 채널 나가기 + Nostr로 연결 가능 + 즐겨찾기 (오프라인) + 정밀도 낮추기 + 정밀도 높이기 + geohash 선택 + 아래로 스크롤 + 파일 + 이미지 + 취소 + 링크 + 미디어 선택 + 파일 선택 + 현재 이미지 저장 + 닫기 + 이미지 %1$d / %2$d + 개인정보 보호됨 + 음성 메시지 녹음 + 오프라인 메쉬 채팅 + 온라인 geohash 채널 + 종단간 암호화 + + + 이미지 %1$d / %2$d + 이미지를 사용할 수 없음 + 이미지가 \"Downloads\"에 저장됨 + 이미지를 저장하지 못했습니다 + 이미지가 \"Downloads\"에 저장됨 + 이미지를 저장하지 못했습니다 + 파일 선택 + [파일을 사용할 수 없음] + 알 수 없음 + + + 이 메시지 또는 사용자에 대한 동작 선택 + 이 사용자에 대한 동작 선택 + 메시지 복사 + 이 메시지를 클립보드에 복사 + %1$s를 때리기 + 장난스러운 따귀 보내기 + %1$s를 안기 + 친근한 포옹 보내기 + %1$s 차단 + 이 사용자의 모든 메시지 차단 + + + #위치 채널 + geohash 채널을 통해 주변 사람들과 채팅하세요. 정확한 GPS 데이터가 아닌 대략적인 geohash만 공유됩니다. 개인 정보 보호를 위해 이 화면을 스크린샷하거나 공유하지 마세요. + 위치 권한 허용 + 위치 권한이 거부되었습니다. 위치 채널을 사용하려면 설정에서 활성화하세요. + 설정 열기 + \u2713 위치 권한 허용됨 + 권한 확인 중 … + 주변 채널 찾는 중 … + 북마크됨 + Geohash + 유효하지 않은 geohash + 텔레포트 + 위치 서비스 비활성화 + 위치 서비스 활성화 + mesh + 블록 + 이웃 + 도시 + 주/도 + 지역 + #bluetooth • %1$s + + + 디버그 도구 + 진단 및 제어를 위한 개발자 도구 + 상세 로깅 + 피어 참여/이탈, 연결 방향, 패킷 전달 및 릴레이를 기록합니다 + 블루투스 역할 + GATT 서버 + 연결: %1$d / %2$d + 최대 서버 + GATT 클라이언트 + 최대 클라이언트 + 연결: %1$d / %2$d + 총 최대 + 패킷 릴레이 + 시작 이후: %1$d + 역할을 켜고/끄고 비활성화 시 모든 연결 닫기 + 동기화 설정 + 동기화당 최대 패킷: %1$d + 최대 GCS 필터 크기: %1$d Byte (128–1024) + 목표 FPR: %1$.2f%% + 연결된 장치 + 우리 장치 ID: %1$s + 없음 + 연결 해제 + 최근 스캔된 장치 + 연결 + 디버그 콘솔 + 지우기 + 마지막 10초: %1$d • 1분: %2$d • 15분: %3$d + 유도 P: %1$s • 예상 최대 요소: %2$s + • 직접 + 서버로서 (우리가 호스트) + 클라이언트로서 (우리가 연결) + + + 위치 서비스 필요 + 개인정보 우선 + bitchat은 사용자의 위치를 추적하지 않습니다.\n\n블루투스 스캔 및 geohash 채팅 기능에 위치 서비스가 필요합니다. + bitchat은 다음을 위해 위치 서비스가 필요합니다: + • 블루투스 기기 스캔\n• 메쉬 네트워크에서 근처 사용자 발견\n• geohash 채팅 기능\n• 추적 또는 위치 수집 없음 + 위치 설정 열기 + 다시 확인 + 위치 서비스를 사용할 수 없음 + 이 기기에서는 위치 서비스를 사용할 수 없습니다. Android 기기에서 위치 서비스는 표준이므로 이는 드문 경우입니다.\n\n블루투스 스캔이 제대로 작동하려면 bitchat에 위치 서비스가 필요합니다(Android 요구 사항). 그렇지 않으면 앱이 주변 사용자를 찾지 못합니다. + 위치 서비스 확인 중 … + 블루투스 필요 + bitchat은 다음을 위해 블루투스가 필요합니다: + • 근처 사용자 찾기\n• 메쉬 네트워크 연결 생성\n• 메시지 보내고 받기\n• 인터넷이나 서버 없이 작동 + 블루투스 켜기 + 블루투스 미지원 + 이 기기는 bitchat이 동작하는 데 필요한 Bluetooth Low Energy (BLE)를 지원하지 않습니다.\n\nbitchat은 인터넷 없이 근처 장치와 통신하고 메쉬 네트워크를 생성하기 위해 BLE가 필요합니다. + 블루투스 상태 확인 중 … + 배터리 최적화 감지됨 + 배터리 최적화 활성화됨 + 메쉬 연결을 유지하려면 bitchat이 백그라운드에서 실행되어야 합니다. 배터리 최적화는 이러한 연결을 방해할 수 있습니다. + 비활성화의 이점 + • 신뢰할 수 있는 메시지 전달\n• 메쉬 연결 유지\n• 연결 끊김 방지 + 배터리 최적화 끄기 + 배터리 최적화 비활성화됨 + 계속 + 메쉬 네트워크 초기화 중 + 블루투스 메쉬 네트워크 설정 중 … + 몇 초 정도 소요됩니다 + 설정이 완료되지 않았습니다 + 다시 시도 + 개인정보가 보호됩니다 + • 추적 또는 데이터 수집 없음\n• 블루투스 메쉬 채팅은 완전 오프라인\n• geohash 채팅은 인터넷 사용 + 권한 + 권한 허용 + bitchat은 사용자의 위치를 추적하지 않습니다 + 주변에 아무도 없음 … + (나) + geohash를 선택하려면 이동하고 확대/축소하세요 + 선택 + 메시지 입력 … + 멘션 + v%1$s + image/* + 이미지 + 파일 + + + 📷 이미지 보냄 + 🎤 음성 메시지 보냄 + 📎 파일 보냄 + + + 재생 + 일시정지 + + + 블루투스를 통해 bitchat 사용자를 발견하는 데 필요 + 근처 기기와 연결하도록 bitchat을 허용 + 블루투스를 통해 근처 bitchat 사용자를 찾기 위해 Android에 필요 + 근처 기기를 스캔하려면 bitchat에 필요 + 개인 메시지를 받으면 알림 받기 + bitchat이 알림을 보내도록 허용 + bitchat이 백그라운드에서 안정적으로 실행되고 메쉬 연결을 유지하도록 배터리 최적화를 끄세요 + 배터리 제한 없이 실행하도록 bitchat을 허용 + + + 근처 기기 + 정확한 위치 + 마이크 + 알림 + 배터리 최적화 + 기타 + + + 채널 비밀번호 입력 + 채널 %1$s은 비밀번호로 보호됩니다. 참여하려면 비밀번호를 입력하세요. + 비밀번호 + 참가 + 취소 + + + + 그리고 %d개 더 + 그리고 %d개 더 + + + + 1명 + %d명 + + + + #%1$s ± 1 • %2$d개의 노트 + #%1$s ± 1 • %2$d개의 노트 + + 다른 방문자들이 볼 수 있도록 이 위치에 짧은 영구 노트를 추가하세요. + 지오 릴레이를 사용할 수 없음; 노트 일시 중지됨 + 주변에 지오 릴레이가 없음 + 노트는 지오 릴레이에 의존합니다. 연결을 확인하고 다시 시도하세요. + 노트 불러오는 중… + 아직 노트가 없습니다 + 이 장소에 첫 번째 노트를 추가해 보세요. + 닫기 + 이 장소에 노트 추가 + 블루투스 권장 + 메시 실행 중 — %1$d 피어 + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + 정보 열기 + 여기 남겨진 쪽지 확인 + 여기 남겨진 쪽지 1개 — 탭하여 읽기 + 여기 남겨진 쪽지 %d개 — 탭하여 읽기 + 언어 + 앱 언어 + 시스템 기본값 + 언어 선택 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml new file mode 100644 index 00000000..b7182e09 --- /dev/null +++ b/app/src/main/res/values-mg/strings.xml @@ -0,0 +1,403 @@ + + + bitchat + + + Ilaina ny alalana Bluetooth mba hifandraisan-davitra tsy misy Internet. + Ilaina ny alalana momba ny toerana mba hahitana fitaovana akaiky amin\'ny alalan\'ny Bluetooth. + Ilaina ny alalana fampandrenesana mba hampandre anao rehefa misy hafatra vaovao. + + + anarana kely + soraty hafatra… + Teny miafina + Hiditra amin\'ny fantsona + Hiala + Handefa + Haneho baiko + Hiverina + Olona + Fantsona + Mpampiasa an-tserasera + Tsy misy olona mifandray + Tsindrio in-telo mba hamafana ny angona rehetra + Tambazotry + + + Hita ny Fanamafisana Bateria + Najanona ny Fanamafisana Bateria + Tsy ilaina ny Fanamafisana Bateria + Manamarina ny Fanamafisana Bateria + Nahoana no mijanona ny fanamafisana bateria? + Mihazakazaka ao ambadika ny bitchat mba hitazona ny fifandraisana tambazotry mesh amin\'ny fitaovana akaiky. Ny fanamafisana bateria dia afaka manelingelina ireo fifandraisana ireo, ka mahatonga ny hafatra hataran-tara na very.\n\nNy fanajanona ny fanamafisana bateria dia miantoka ny fifandraisan-davitra azo itokisana. + Hajanona ny Fanamafisana Bateria + Fanamarihana: Azonao ovaina ato aoriana izany amin\'ny Toetry Android > Apps > bitchat > Bateria + Ny fitaovanao dia tsy mila fametrahana fanamafisana bateria. Ny bitchat dia handeha ara-dalàna. + Ny fitaovanao dia tsy mila fametrahana fanamafisana bateria. Ny bitchat dia handeha ara-dalàna. + Afaka mihazakazaka azo itokisana ao ambadika ny bitchat + • Miantoka ny fandefasana hafatra azo itokisana\n• Mitazona ny fifandraisana tambazotry mesh\n• Mamela ny famindrana hafatra ao ambadika\n• Misoroka ny fahatapahan\'ny fifandraisana + Hanamarina indray + Hitsambikina ankehitriny + Hanohy + Hanandrana indray + Hitsambikina + + + ary %1$d hafa + %1$d hafatra avy amin\'ny olona %2$d + ary %1$d resaka hafa + bitchatter akaiky! + Olona 1 manodidina + Olona %1$d manodidina + Hafatra vaovao + Hafatra Toerana Vaovao + Voatonona ao amin\'ny %1$s + Voatonona ao amin\'ny %1$s (+%2$d hafa) + %1$d fanaovana tsiahy ao amin\'ny %2$s + Hetsika vaovao ao amin\'ny %1$s + Hafatra ao amin\'ny %1$s + %1$s dia niditra tamin\'ny resaka + bitchat - %1$d fanaovana tsiahy + bitchat - resaka toerana + %1$d hafatra avy amin\'ny toerana %2$d + Voatonona ao amin\'ny Mesh Chat + %1$d fanaovana tsiahy ao amin\'ny Mesh Chat + ary %1$d toerana hafa + ary %1$d hafa + + + Hampidirina amin\'ny ankafizina + Hanesorana amin\'ny ankafizina + Hanampy bookmark + + + Azo tratrarina amin\'ny alalan\'ny Nostr + Hafatra manokana tsy voavaky + Nafindra toerana + Sokafy ny fikirana toerana sy fantsona + Mpiara-miasa mifandray + Mpandray anjara Geohash + Vonona ho amin\'ny fifampiraharahana + Fifampiraharahana mandeha eo + Voafono farany amin\'ny farany + Tsy nahomby ny fifampiraharahana + + + 📎 Rakitra Voaray + 📄 %1$s + 📏 Habeny: %1$s + 🏷️ Karazana: %1$s + 📂 Hanokatra / Hitahiry + ❌ Hanakatona + Hifidy sary + + + Mesh Chat tsy an-tserasera + Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana. + Fantsona Geohash an-tserasera + Mifandray amin\'ny olona eo amin\'ny faritra misy anao amin\'ny fampiasana fantsona mifototra amin\'ny geohash. Manitatra ny mesh amin\'ny fampiasana relay Internet ho an\'ny daholobe. + Fandokoana Farany amin\'ny Farany + Voafono ny hafatra manokana. Hafatra ho an\'ny daholobe ny hafatra fantsona. + rafitra + mazava + maizina + porofo asa + pow tapaka + pow mandeha + ampio porofo asa amin\'ny hafatra geohash mba hanakanana spam. + sarotra: %1$d bit (~%2$s) + sarotra %1$d mitaky ~%2$s fanandramana hash + tsy misy porofo asa ilaina + ambany indrindra - fiarovana spam kely + ambany - fiarovana spam fototra + antonony - fiarovana spam tsara + avo - fiarovana spam matanjaka + avo dia avo - mety miteraka fahatarana + faran\'izany - mitaky kajy lehibe + tambazotry + tor tapaka + tor mandeha + alefaso amin\'ny alalan\'ny tor ny Internet mba hahazoana fiainana manokana bebe kokoa. + Toe-javatra Tor: %1$s, bootstrap %2$d%% + Farany: %1$s + Famafana Angona Maika + Fametrahana Debug + Loharanom-baovao misokatra • Fiainana manokana voalohany • Tsy misy foibe + Hanakatona + hanafoana + + + Fampitandremana + Sampan-draharaham-pitoerana + Fiainana manokana + Hadisoana + Fanamafisana Bateria + Tombontsoa + Manamarina ny Fanamafisana Bateria + Tsy tohanana ny Fanamafisana Bateria + Bluetooth + Hafatra tsy voavaky + Hanokatra sarintany + Hanesorana bookmark + Hamindra toerana + Hiala amin\'ny fantsona + Azo tratrarina amin\'ny alalan\'ny Nostr + Ankafizina tsy an-tserasera + Hampihenana ny fahamarinana + Hampitombo ny fahamarinana + Hifidy geohash + Hidina any ambany + Rakitra + Sary + Hanafoana + Rohy + Hifidy media + Hifidy rakitra + Hitahiry ny sary ankehitriny + Hanakatona + Sary %1$d amin\'ny %2$d + Voaaro ny Fiainana Manokana + Handrakitra feo + Mesh Chat tsy an-tserasera + Fantsona Geohash an-tserasera + Fandokoana Farany amin\'ny Farany + + + Sary %1$d amin\'ny %2$d + Tsy misy sary + Sary voatahiry ao amin\'ny Downloads + Tsy afaka mitahiry sary + Sary voatahiry ao amin\'ny Downloads + Tsy afaka mitahiry sary + Hifidy rakitra + [rakitra tsy misy] + tsy fantatra + + + safidio hetsika ho an\'ity hafatra na mpampiasa ity + safidio hetsika ho an\'ity mpampiasa ity + adikao ny hafatra + adikao ity hafatra ity any amin\'ny clipboard + kapohina %1$s + handefasa hafatra kapohana an-dalao + honofinofy %1$s + handefasa hafatra honofinofy sariaka + hanorina %1$s + hanorina ny hafatra rehetra avy amin\'ity mpampiasa ity + + + #fantsona toerana + miresaka amin\'ny olona akaiky anao amin\'ny fampiasana fantsona geohash. geohash marivo ihany no zarainy, tsy GPS marina akory. aza maka sary an-efijery na mizara ity efijery ity mba hiarovana ny fiainanao manokana. + omeo alalana momba ny toerana + nolavina ny alalana momba ny toerana. alefaso ao amin\'ny fametrahana mba hampiasa fantsona toerana. + Hanokatra fametrahana + \u2713 omena ny alalana momba ny toerana + manamarina ny alalana... + mitady fantsona akaiky… + voaisy bookmark + geohash + geohash tsy mety + hamindra toerana + hanajanona sampan-draharaham-pitoerana + halefa sampan-draharaham-pitoerana + mesh + sakana + manodidina + tanàna + faritany + faritra + #bluetooth • %1$s + + + fitaovana debug + fitaovana mpamorona ho an\'ny diagnostika sy fifehezana + firaketana amin\'ny antsipiriany + raketo ny fidiran\'ny mpiara-miasa / ny fialana, ny fanolorana fifandraisana, ny routing pakets ary ny relays + andraikitry Bluetooth + mpizara GATT + fifandraisana: %1$d / %2$d + mpizara max + mpanjifa GATT + mpanjifa max + fifandraisana: %1$d / %2$d + max ankapobeny + relay pakets + hatramin\'ny fanombohana: %1$d + alefaso / tapaho ny andraikitra ary akatony ny fifandraisana rehetra rehefa tapaka + fametrahana sync + pakets max isaky ny sync: %1$d + haben\'ny sivana GCS max: %1$d bytes (128–1024) + FPR kendrena: %1$.2f%% + fitaovana mifandray + ID fitaovanay: %1$s + tsy misy + hisaraka + valim-panadihadiana vao haingana + hifandray + console debug + hamafa + 10s farany: %1$d • 1m: %2$d • 15m: %3$d + P azo: %1$s • tombana singa max: %2$s + • mivantana + ho mpizara (isika mampiantrano) + ho mpanjifa (isika mifandray) + + + Ilaina ny Sampan-draharaham-pitoerana + Fiainana Manokana Voalohany + Ny bitchat dia TSY manaraka ny toerananao.\n\nIlaina ny sampan-draharaham-pitoerana ho an\'ny scan Bluetooth sy ny endri-javatra resaka Geohash. + Ny bitchat dia mila sampan-draharaham-pitoerana ho an\'ny: + • Scan fitaovana Bluetooth\n• Fahitana mpampiasa akaiky amin\'ny tambazotry mesh\n• Endri-javatra resaka Geohash\n• Tsy misy fanaraha-maso na fanangonana toerana + Hanokatra Fametrahana Toerana + Hanamarina Indray + Tsy Misy Sampan-draharaham-pitoerana + Tsy misy sampan-draharaham-pitoerana amin\'ity fitaovana ity. Mahagaga izany satria mahazatra amin\'ny fitaovana Android ny sampan-draharaham-pitoerana.\n\nMila sampan-draharaham-pitoerana ny bitchat mba hiasa tsara ny scan Bluetooth (takiana Android). Raha tsy izany, ny fampiharana dia tsy afaka mahita mpampiasa akaiky. + Manamarina sampan-draharaham-pitoerana... + Ilaina ny Bluetooth + Mila Bluetooth ny bitchat mba: + • Hahita mpampiasa akaiky\n• Hamorona fifandraisana tambazotry mesh\n• Handefa sy handray hafatra\n• Hiasa tsy misy Internet na mpizara + Alefaso ny Bluetooth + Tsy Tohanana ny Bluetooth + Ity fitaovana ity dia tsy manohana ny Bluetooth Low Energy (BLE), izay ilaina mba hiasan\'ny bitchat.\n\nMila BLE ny bitchat mba hamoronana tambazotry mesh sy hifandray amin\'ny fitaovana akaiky tsy misy Internet. + Manamarina ny toe-javatra Bluetooth... + hita ny fanamafisana bateria + Alefa ny Fanamafisana Bateria + Mila mihazakazaka ao ambadika ny bitchat mba hitazona ny fifandraisana mesh. ny fanamafisana bateria dia afaka manelingelina ireo fifandraisana ireo. + Tombontsoam-panajanona + • fandefasana hafatra azo itokisana\n• mitazona ny fifandraisana mesh\n• misoroka ny fahatapahan\'ny fifandraisana + Hajanona ny Fanamafisana Bateria + najanona ny fanamafisana bateria + Hanohy + Manomboka ny tambazotry mesh + . + Mametraka ny tambazotry mesh Bluetooth... + Tokony haharitra segaondra vitsivitsy ihany izany + ⚠️ + Tsy Vita ny Fametrahana + Andramo Indray + Voaaro ny Fiainanao Manokana + • tsy misy fanaraha-maso na fanangonana angona\n• tsy an-tserasera tanteraka ny resaka mesh Bluetooth\n• mampiasa Internet ny resaka Geohash + alalana + Omeo Alalana + Ny bitchat dia TSY manaraka ny toerananao + @ + · ⧉ + tsy misy olona manodidina... + (ianao) + ahetsika sy ataovy lehibe mba hifidianana geohash + hifidy + soraty hafatra... + @%1$s + hanonona + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Sary + Rakitra + + + + + 📷 nandefasa sary + 🎤 nandefasa hafatra feo + 📎 nandefasa rakitra + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + Hilalao + Hijanona + + + Ilaina mba hahitana mpampiasa bitchat amin\'ny alalan\'ny Bluetooth + Avelao ny bitchat hifandray amin\'ny fitaovana akaiky + Takian\'ny Android mba hahitana mpampiasa bitchat akaiky amin\'ny alalan\'ny Bluetooth + Mila izany ny bitchat mba hisikidy fitaovana akaiky + Mandray fampandrenesana rehefa mahazo hafatra manokana ianao + Avelao ny bitchat handefasa fampandrenesana aminao + Ajanony ny fanamafisana bateria mba hahazoana antoka fa mihazakazaka azo itokisana ao ambadika ny bitchat ary mitazona ny fifandraisana tambazotry mesh + Avelao ny bitchat handeha tsy misy famerana bateria + + + Fitaovana Akaiky + Toerana Marina + Mikraofona + Fampandrenesana + Fanamafisana Bateria + Hafa + + + Ampidiro ny Teny Miafina Fantsona + Voaaro amin\'ny teny miafina ny fantsona %1$s. Ampidiro ny teny miafina mba hidirana. + Teny miafina + Hiditra + Hanafoana + + + + ary %d hafa + ary %d hafa + + + + Olona 1 + Olona %d + + + Bluetooth Recommended + Mandeha ny Mesh — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + Sokafy ny momba + hizaha raha misy naoty navela teto + naoty 1 no navela teto — tsindrio raha hamaky + naoty %d no navela teto — tsindrio raha hamaky + Fiteny + Fitenin’ny app + Fitenin’ny rafitra + Safidio ny fiteny + diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml new file mode 100644 index 00000000..79bf5b1c --- /dev/null +++ b/app/src/main/res/values-ms/strings.xml @@ -0,0 +1,436 @@ + + + + bitchat + Kebenaran Bluetooth diperlukan untuk pemesejan rakan-ke-rakan tanpa internet. + Kebenaran lokasi diperlukan untuk mengesan peranti berdekatan melalui Bluetooth. + Kebenaran pemberitahuan diperlukan untuk memaklumkan anda tentang mesej baharu. + nama samaran + taip mesej… + Kata Laluan + Sertai Saluran + Keluar + Hantar + Tunjukkan arahan + Kembali + Orang + Saluran + Pengguna Dalam Talian + Tiada sesiapa yang disambungkan + Ketik tiga kali untuk memadam semua data + Rangkaian + + + Pengoptimuman Bateri Dikesan + Pengoptimuman Bateri Dilumpuhkan + Pengoptimuman Bateri Tidak Diperlukan + Menyemak Pengoptimuman Bateri + Kenapa lumpuhkan pengoptimuman bateri? + bitchat berjalan di latar belakang untuk mengekalkan sambungan rangkaian mesh dengan peranti berdekatan. Pengoptimuman bateri boleh mengganggu sambungan ini, menyebabkan mesej lewat atau terlepas.\n\nMelumpuhkan pengoptimuman bateri memastikan pemesejan rakan-ke-rakan yang boleh dipercayai. + Lumpuhkan Pengoptimuman Bateri + Nota: Anda boleh menukar tetapan ini kemudian dalam Tetapan Android > Apl > bitchat > Bateri + Peranti anda tidak memerlukan tetapan pengoptimuman bateri. bitchat akan berjalan seperti biasa. + Peranti anda tidak memerlukan tetapan pengoptimuman bateri. bitchat akan berjalan seperti biasa. + bitchat boleh berjalan dengan boleh dipercayai di latar belakang + • Memastikan penghantaran mesej yang boleh dipercayai\n• Mengekalkan ketersambungan rangkaian mesh\n• Membenarkan geganti mesej latar belakang\n• Mengelakkan sambungan terputus + Semak Semula + Langkau Buat Masa Ini + Teruskan + Cuba Lagi + Skip + + + dan %1$d lagi + %1$d mesej daripada %2$d orang + dan %1$d perbualan lagi + 👥 bitchatter berdekatan! + 1 orang berdekatan + %1$d orang berdekatan + Mesej Baharu + Mesej Lokasi Baharu + Disebut dalam %1$s + Disebut dalam %1$s (+%2$d lagi) + %1$d sebutan dalam %2$s + Aktiviti baharu dalam %1$s + Mesej dalam %1$s + %1$s telah menyertai perbualan + bitchat - %1$d sebutan + bitchat - sembang lokasi + %1$d mesej daripada %2$d lokasi + Disebut dalam Sembang Mesh + %1$d sebutan dalam Sembang Mesh + dan %1$d lokasi lagi + + + Perkhidmatan Latar Belakang Mesh + Mengekalkan mesh Bluetooth berjalan di latar belakang + Mesh sedang berjalan — %1$d rakan + + + Tambah ke kegemaran + Alih keluar daripada kegemaran + Tambah penanda buku + + + Boleh dihubungi melalui Nostr + Mesej peribadi belum dibaca + Nota lokasi + Diteleport + Buka tetapan lokasi dan saluran + Rakan disambungkan + Peserta geohash + Sedia untuk berjabat tangan + Jabat tangan sedang berlangsung + Disulitkan hujung-ke-hujung + Jabat tangan gagal + + + 📎 Fail Diterima + 📄 %1$s + 📏 Saiz: %1$s + 🏷️ Jenis: %1$s + 📂 Buka / Simpan + ❌ Tutup + Pilih imej + Simpan imej semasa + Tutup + Imej disimpan ke Muat Turun + Gagal menyimpan imej + Imej %1$d daripada %2$d + Pilih fail + + + pemesejan mesh terdesentralisasi dengan penyulitan hujung-ke-hujung + Sembang Mesh Luar Talian + Berkomunikasi terus melalui Bluetooth LE tanpa internet atau pelayan. Mesej digeganti melalui peranti berdekatan untuk melanjutkan jarak. + Saluran Geohash Dalam Talian + Berhubung dengan orang di kawasan anda menggunakan saluran berasaskan geohash. Melanjutkan mesh menggunakan geganti internet awam. + Penyulitan Hujung-ke-Hujung + Mesej peribadi disulitkan. Mesej saluran adalah awam. + sistem + cerah + gelap + bukti kerja + pow dimatikan + pow dihidupkan + tambah bukti kerja pada mesej geohash untuk mengelakkan spam. + tahap kesukaran: %1$d bit (~%2$s) + kesukaran %1$d memerlukan ~%2$s percubaan hash + tiada bukti kerja diperlukan + sangat rendah - perlindungan spam minimum + rendah - perlindungan spam asas + sederhana - perlindungan spam yang baik + tinggi - perlindungan spam yang kuat + sangat tinggi - mungkin menyebabkan kelewatan + melampau - pengiraan besar diperlukan + rangkaian + tor dimatikan + tor dihidupkan + laluan internet melalui tor untuk privasi yang lebih baik. + Status tor: %1$s, but-mula %2$d%% + Terakhir: %1$s + Pemadaman Data Kecemasan + Tetapan Nyahpepijat + Sumber Terbuka • Privasi Diutamakan • Terdesentralisasi + Tutup + Privasi Dilindungi + batal + + + Amaran + Perkhidmatan Lokasi + Privasi + Ralat + Pengoptimuman Bateri + Faedah + Menyemak Pengoptimuman Bateri + Pengoptimuman Bateri Tidak Disokong + Bluetooth + Mesej belum dibaca + Buka peta + Alih keluar penanda buku + Teleport + Keluar bitchat + jalankan di latar belakang + kekalkan mesh aktif apabila apl ditutup (perkhidmatan latar depan) + Keluar saluran + Boleh dihubungi melalui Nostr + Kegemaran luar talian + Kurangkan ketepatan + Tingkatkan ketepatan + Pilih geohash + Tatal ke bawah + Fail + Imej + Batal + Pautan + Rakam nota suara + Pilih media + Sembang Mesh Luar Talian + Saluran Geohash Dalam Talian + Penyulitan Hujung-ke-Hujung + #bluetooth • %1$s + + Imej %1$d daripada %2$d + Imej tidak tersedia + Imej disimpan ke Muat Turun + Gagal menyimpan imej + Pilih fail + [fail tidak tersedia] + tidak diketahui + + + pilih tindakan untuk mesej atau pengguna ini + pilih tindakan untuk pengguna ini + salin mesej + salin mesej ini ke papan keratan + tampar %1$s + hantar mesej tamparan bergurau + peluk %1$s + hantar mesej pelukan mesra + sekat %1$s + sekat semua mesej daripada pengguna ini + mesej %1$s + hantar mesej peribadi + + + + #saluran lokasi + berbual dengan orang berdekatan anda menggunakan saluran geohash. hanya geohash kasar dikongsi, tidak sekali-kali gps tepat. jangan ambil tangkapan skrin atau kongsi skrin ini untuk melindungi privasi anda. + berikan kebenaran lokasi + kebenaran lokasi ditolak. aktifkan dalam tetapan untuk menggunakan saluran lokasi. + + ✓ kebenaran lokasi diberikan + menyemak kebenaran... + mencari saluran berdekatan… + ditanda buku + geohash + geohash tidak sah + teleport + lumpuhkan perkhidmatan lokasi + aktifkan perkhidmatan lokasi + mesh + blok + kejiranan + bandar + wilayah + rantau + + + + #%1$s ± 1 • %2$d nota + + tambah nota kekal ringkas pada lokasi ini untuk pelawat lain temui. + geganti geo tidak tersedia; nota dijeda + tiada geganti geo berdekatan + nota bergantung pada geganti geo. semak sambungan dan cuba lagi. + memuatkan nota… + belum ada nota + jadilah yang pertama menambah satu untuk tempat ini. + tolak + tambah nota untuk tempat ini + + + alat nyahpepijat + utiliti pembangun untuk diagnostik dan kawalan + log terperinci + merekod penyertaan/pemergian rakan, arah sambungan, penghalaan paket dan geganti + peranan bluetooth + pelayan gatt + sambungan: %1$d / %2$d + maksimum pelayan + klien gatt + maksimum klien + sambungan: %1$d / %2$d + maksimum keseluruhan + geganti paket + sejak permulaan: %1$d + hidup/matikan peranan dan tutup semua sambungan apabila dilumpuhkan + tetapan penyegerakan + maksimum paket setiap penyegerakan: %1$d + saiz penapis GCS maksimum: %1$d bait (128–1024) + FPR sasaran: %1$.2f%% + peranti disambungkan + id peranti kami: %1$s + tiada + putuskan sambungan + keputusan imbasan terkini + + sahkan + imbas untuk sahkan saya + imbas kod qr orang lain + imbas kod qr orang lain + tunjukkan kod qr saya + alih keluar pengesahan + kod qr tidak tersedia + kebenaran kamera diperlukan untuk mengimbas kod qr + dayakan kamera + tampal pautan pengesahan + sahkan + pengesahan diminta + pengesahan keselamatan + cap jari mereka + cap jari anda + jabat tangan tertangguh + buka sembang peribadi untuk melihat cap jari + disulitkan & disahkan + disulitkan + berjabat tangan + jabat tangan gagal + tidak disulitkan + disahkan + anda telah mengesahkan identiti orang ini. + tidak disahkan + bandingkan cap jari ini dengan %1$s menggunakan saluran selamat. + tanda sebagai disahkan + mulakan jabat tangan + salin + + Pengesahan bersama + Anda dan %1$s telah saling mengesahkan + pengesahan bersama dengan %1$s + Disahkan + Anda telah mengesahkan %1$s + telah mengesahkan %1$s + + sambung + konsol nyahpepijat + kosongkan + 10s lepas: %1$d • 1m: %2$d • 15m: %3$d + P terbitan: %1$s • anggaran unsur maks: %2$s + • terus + RSSI: %1$s + ? + sebagai pelayan (kami hos) + sebagai klien (kami sambung) + + + Perkhidmatan Lokasi Diperlukan + Privasi Diutamakan + bitchat TIDAK menjejaki lokasi anda.\n\nPerkhidmatan lokasi diperlukan untuk imbasan Bluetooth dan untuk ciri sembang Geohash. + bitchat memerlukan perkhidmatan lokasi untuk: + • Imbasan peranti Bluetooth\n• Mengesan pengguna berdekatan pada rangkaian mesh\n• Ciri sembang Geohash\n• Tiada penjejakan atau pengumpulan lokasi + Lokasi Latar Belakang Disyorkan + pilihan, meningkatkan kebolehpercayaan mesh + Android mengesyorkan lokasi latar belakang supaya bitchat boleh mengimbas peranti berdekatan semasa apl tidak dibuka. Ini mengekalkan mesh aktif selepas but semula. + Apabila tetapan dibuka, pilih "Benarkan sepanjang masa". + bitchat menggunakan lokasi latar belakang untuk: + - mengimbas peranti berdekatan semasa apl ditutup\n- menyambung semula selepas but semula\n- mengekalkan mesh berjalan di latar belakang + Kami TIDAK PERNAH mengumpul atau menyimpan lokasi anda. Privasi anda selamat. + Benarkan Lokasi Latar Belakang + Teruskan tanpa lokasi latar belakang + Buka Tetapan Lokasi + Semak Semula + Perkhidmatan Lokasi Tidak Tersedia + Perkhidmatan lokasi tidak tersedia pada peranti ini. Ini luar biasa kerana perkhidmatan lokasi adalah standard pada peranti Android.\n\nbitchat memerlukan perkhidmatan lokasi untuk imbasan Bluetooth berfungsi dengan betul (keperluan Android). Tanpa ini, apl tidak dapat mengesan pengguna berdekatan. + Menyemak perkhidmatan lokasi... + Bluetooth Diperlukan + bitchat memerlukan Bluetooth untuk: + • Mengesan pengguna berdekatan\n• Mewujudkan sambungan rangkaian mesh\n• Menghantar dan menerima mesej\n• Berfungsi tanpa internet atau pelayan + Aktifkan Bluetooth + Bluetooth Tidak Disokong + Peranti ini tidak menyokong Bluetooth Low Energy (BLE), yang diperlukan untuk bitchat berfungsi.\n\nbitchat memerlukan BLE untuk mewujudkan rangkaian mesh dan berkomunikasi dengan peranti berdekatan tanpa internet. + Menyemak status Bluetooth... + pengoptimuman bateri dikesan + Pengoptimuman Bateri Diaktifkan + bitchat perlu berjalan di latar belakang untuk mengekalkan sambungan mesh. pengoptimuman bateri boleh mengganggu sambungan ini. + Faedah Melumpuhkan + • penghantaran mesej yang boleh dipercayai\n• mengekalkan ketersambungan mesh\n• mengelakkan sambungan terputus + Lumpuhkan Pengoptimuman Bateri + pengoptimuman bateri dilumpuhkan + Teruskan + Memulakan rangkaian mesh + . + Menyediakan rangkaian mesh Bluetooth... + Ini sepatutnya hanya mengambil masa beberapa saat + ⚠️ + Persediaan Tidak Lengkap + Cuba Lagi + Buka Tetapan + Privasi Anda Dilindungi + • tiada penjejakan atau pengumpulan data\n• sembang mesh Bluetooth sepenuhnya luar talian\n• sembang Geohash menggunakan internet + kebenaran + Berikan Kebenaran + bitchat TIDAK menjejaki lokasi anda + @ + · ⧉ + tiada sesiapa berdekatan... + (anda) + gerak dan zum untuk memilih geohash + pilih + taip mesej... + @%1$s + sebutan + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Imej + Fail + + + + + 📷 menghantar imej + 🎤 menghantar mesej suara + 📎 menghantar fail + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + Main + Jeda + + + Diperlukan untuk mengesan pengguna bitchat melalui Bluetooth + Benarkan bitchat menyambung kepada peranti berdekatan + Diperlukan oleh Android untuk mengesan pengguna bitchat berdekatan melalui Bluetooth + bitchat memerlukan ini untuk mengimbas peranti berdekatan + Disyorkan untuk mengimbas peranti berdekatan semasa apl di latar belakang + Benarkan akses lokasi latar belakang + Terima pemberitahuan apabila anda menerima mesej peribadi + Benarkan bitchat menghantar pemberitahuan kepada anda + Lumpuhkan pengoptimuman bateri untuk memastikan bitchat berjalan dengan boleh dipercayai di latar belakang dan mengekalkan sambungan rangkaian mesh + Benarkan bitchat berjalan tanpa sekatan bateri + + + Peranti Berdekatan + Lokasi Tepat + Lokasi Latar Belakang + Mikrofon + Pemberitahuan + Pengoptimuman Bateri + Lain-lain + + + Masukkan Kata Laluan Saluran + Saluran %1$s dilindungi kata laluan. Masukkan kata laluan untuk menyertai. + Kata Laluan + Sertai + Batal + Tor tidak tersedia dalam binaan ini + + + + dan %d lagi + + + + %d orang + + + Bluetooth Disyorkan + Bahasa + Bahasa aplikasi + Lalai sistem + Pilih bahasa + diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml new file mode 100644 index 00000000..59d52853 --- /dev/null +++ b/app/src/main/res/values-ne/strings.xml @@ -0,0 +1,394 @@ + + + bitchat + + + इन्टरनेट बिना प्रत्यक्ष कुराकानीका लागि ब्लुटुथ अनुमति आवश्यक हुन्छ। + ब्लुटुथमार्फत नजिकका यन्त्रहरू फेला पार्न स्थान अनुमति आवश्यक हुन्छ। + नयाँ सन्देशबारे सूचित गर्न सूचनाको अनुमति आवश्यक हुन्छ। + + + उपनाम + सन्देश टाइप गर्नुहोस्… + पासवर्ड + च्यानलमा जोडिनुहोस् + छोड्नुहोस् + पठाउनुहोस् + आज्ञाहरू देखाउनुहोस् + पछाडि + मानिसहरू + च्यानलहरू + अनलाइन प्रयोगकर्ता + कसैसँग जडान छैन + सबै डेटा मेटाउन तीन पटक ट्याप गर्नुहोस् + सञ्जाल + + + ब्याट्री अनुकूलन भेटियो + ब्याट्री अनुकूलन बन्द + ब्याट्री अनुकूलन आवश्यक छैन + ब्याट्री अनुकूलन जाँचिँदै + ब्याट्री अनुकूलन किन बन्द गर्ने? + bitchat पछाडिबाट चल्छ ताकि नजिकका यन्त्रहरूसँग मेष जडान कायम रहोस्। ब्याट्री अनुकूलनले यी जडान विच्छेद गराउन सक्छ, जसले सन्देश ढिलो वा हराउन सक्छ।\n\nअनुकूलन बन्द गर्दा भरपर्दो peer‑to‑peer सन्देश सुनिश्चित हुन्छ। + ब्याट्री अनुकूलन बन्द गर्नुहोस् + नोट: यो सेटिङ पछि Android सेटिङहरू > Apps > bitchat > Battery मा परिवर्तन गर्न सकिन्छ। + तपाईंको यन्त्रलाई ब्याट्री अनुकूलन सेटिङ आवश्यक छैन। bitchat सामान्य रूपमा चल्नेछ। + तपाईंको यन्त्रलाई ब्याट्री अनुकूलन सेटिङ आवश्यक छैन। bitchat सामान्य रूपमा चल्नेछ। + bitchat पछाडिबाट भरपर्दो रूपमा चल्न सक्छ + • सन्देश विश्वसनीय डेलिभरी\n• मेष जडान कायम राख्ने\n• पृष्ठभूमिमा रिले\n• जडान विच्छेद हुन नदिने + फेरि जाँच्नुहोस् + अहिलेलाई बेवास्ता गर्नुहोस् + जारी राख्नुहोस् + फेरि प्रयास गर्नुहोस् + बेवास्ता गर्नुहोस् + + + र %1$d थप + %1$d सन्देश %2$d जनाबाट + र %1$d अरू कुराकानी + नजिकै bitchatter हरू! + १ जना नजिकै + %1$d जना नजिकै + नयाँ सन्देश + नयाँ स्थान सन्देश + %1$s मा उल्लेख + %1$s मा उल्लेख (+%2$d थप) + %1$d उल्लेख %2$s मा + %1$s मा नयाँ गतिविधि + %1$s मा सन्देश + %1$s वार्तालापमा सामेल भयो + bitchat – %1$d उल्लेख + bitchat – स्थान च्याट + %2$d स्थानबाट %1$d सन्देश + मेष च्याटमा उल्लेख + मेष च्याटमा %1$d उल्लेख + र %1$d थप स्थान + र %1$d थप + + + मनपर्नेमा थप्नुहोस् + मनपरिबाट हटाउनुहोस् + बुकमार्क थप्नुहोस् + + + Nostr मार्फत पुग्न सकिने + नपढिएका निजी सन्देश + टेलिपोर्ट भयो + स्थान र च्यानल सेटिङ खोल्नुहोस् + जडान भएका साथीहरू + Geohash सहभागिहरू + ह्यान्डशेकका लागि तयार + ह्यान्डशेक भइरहेको + अन्त्यदेखि अन्त्य एनक्रिप्टेड + ह्यान्डशेक असफल + + + 📎 फाइल प्राप्त भयो + 📄 %1$s + 📏 साइज: %1$s + 🏷️ प्रकार: %1$s + 📂 खोल्नुहोस् / बचत गर्नुहोस् + ❌ बन्द गर्नुहोस् + तस्वीर छान्नुहोस् + + + अफलाइन मेष च्याट + इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ। + अनलाइन Geohash च्यानलहरू + Geohash आधारित च्यानलमार्फत नजिकका मानिसहरूसँग जोडिनुहोस्। सार्वजनिक इन्टरनेट रिलेलाई प्रयोग गरेर मेष विस्तार गर्नुहोस्। + एन्ड‑टु‑एन्ड इन्क्रिप्सन + निजी सन्देश इन्क्रिप्टेड हुन्छन्। च्यानल सन्देश सार्वजनिक हुन्छन्। + सिस्टम + हल्का + गाढा + प्रुफ अफ वर्क + pow बन्द + pow चालु + स्पाम रोकथामका लागि geohash सन्देशमा proof of work थप्नुहोस्। + कठिनाइ: %1$d बिट (~%2$s) + कठिनाइ %1$d का लागि ~%2$s ह्यास प्रयास + proof of work आवश्यक छैन + अति कम – न्यून सुरक्षा + कम – आधारभूत सुरक्षा + मध्यम – राम्रो सुरक्षा + उच्च – बलियो सुरक्षा + एकदम उच्च – ढिलाइ हुन सक्छ + अत्यधिक – धेरै गणना आवश्यक + सञ्जाल + tor बन्द + tor चालु + बढी गोपनीयताका लागि Tor मार्फत इन्टरनेट रुट गर्नुहोस्। + Tor स्थिति: %1$s, बुटस्ट्र्याप %2$d%% + अन्तिम: %1$s + आपतकालीन डेटा मेटाउने + डिबग सेटिङ + Open Source • Privacy First • विकेन्द्रित + बन्द गर्नुहोस् + रद्द गर्नुहोस् + + + चेतावनी + स्थान सेवाहरू + गोपनीयता + त्रुटि + ब्याट्री अनुकूलन + फाइदाहरू + ब्याट्री अनुकूलन जाँचिँदै + समर्थन छैन + ब्लुटुथ + नपढिएको सन्देश + नक्सा खोल्नुहोस् + बुकमार्क हटाउनुहोस् + टेलिपोर्ट + च्यानल छोड्नुहोस् + Nostr बाट पहुँचयोग्य + मनपर्ने (अफलाइन) + शुद्धता घटाउनुहोस् + शुद्धता बढाउनुहोस् + geohash छान्नुहोस् + तल स्क्रोल गर्नुहोस् + फाइल + तस्वीर + रद्द + लिङ्क + मेडिया छान्नुहोस् + भ्वाइस नोट रेकर्ड + अफलाइन मेष च्याट + अनलाइन Geohash च्यानल + एन्ड‑टु‑एन्ड इन्क्रिप्सन + + + तस्वीर %1$d / %2$d + तस्वीर उपलब्ध छैन + तस्वीर "Downloads" मा सुरक्षित भयो + तस्वीर बचत असफल + फाइल छान्नुहोस् + [फाइल उपलब्ध छैन] + अज्ञात + + + यो सन्देश वा प्रयोगकर्ताका लागि कार्य छान्नुहोस् + यस प्रयोगकर्ताका लागि कार्य छान्नुहोस् + सन्देश प्रतिलिपि + यो सन्देश क्लिपबोर्डमा प्रतिलिपि गर्नुहोस् + %1$s लाई चड्कन + मनोरञ्जनका लागि चड्कन पठाउनुहोस् + %1$s लाई अँगालो + मैत्रीपूर्ण अँगालो पठाउनुहोस् + %1$s लाई ब्लक गर्नुहोस् + यस प्रयोगकर्ताबाट सबै सन्देश ब्लक गर्नुहोस् + + + #स्थान च्यानल + तपाईंको वरिपरिका मानिसहरूसँग geohash च्यानल प्रयोग गरी च्याट गर्नुहोस्। केवल सामान्य geohash साझा हुन्छ, कहिल्यै ठ्याक्कै GPS होइन। गोपनीयताका लागि स्क्रिनसट नलिनुहोस् र नबाँड्नुहोस्। + स्थान अनुमति दिनुहोस् + स्थान अनुमति अस्वीकार। स्थान च्यानल प्रयोग गर्न सेटिङमा सक्षम गर्नुहोस्। + सेटिङ खोल्नुहोस् + \u2713 स्थान अनुमति दिइयो + अनुमति जाँचिँदै… + नजिकका च्यानल खोजिँदै… + बुकमार्क गरिएको + geohash + अवैध geohash + टेलिपोर्ट + स्थान सेवाहरू बन्द गर्नुहोस् + स्थान सेवाहरू सुरु गर्नुहोस् + #bluetooth • %1$s + mesh + ब्लक + छिमेक + सहर + प्रान्त + क्षेत्र + + + डिबग उपकरण + विस्तृत लगिङ + ब्लुटुथ भूमिकाहरू + GATT सर्भर + जडान: %1$d / %2$d + अधिकतम सर्भर + GATT क्लाइन्ट + अधिकतम क्लाइन्ट + जडान: %1$d / %2$d + अधिकतम कुल + प्याकेट रिल레이 + सुरु भएकोयता: %1$d + सिङ्क सेटिङ + प्रत्येक सिङ्कका अधिकतम प्याकेट: %1$d + GCS फिल्टर साइज अधिकतम: %1$d बाइट (128–1024) + लक्ष्य FPR: %1$.2f%% + जडान भएका यन्त्रहरू + हाम्रो यन्त्र आईडी: %1$s + केही छैन + डिस्कनेक्ट + हालैका स्क्यान नतिजा + जोड्नुहोस् + डिबग कन्सोल + खाली गर्नुहोस् + अन्तिम 10s: %1$d • 1m: %2$d • 15m: %3$d + व्युत्पन्न P: %1$s • अनुमानित अधिकतम तत्व: %2$s + • प्रत्यक्ष + ? + सर्भरको रूपमा (हामी होस्ट गर्छौं) + क्लाइन्टको रूपमा (हामी जडान गर्छौं) + + + स्थान सेवाहरू आवश्यक + गोपनीयता पहिलो + bitchat ले तपाईंको स्थान पछ्याउँदैन।\n\nब्लुटुथ स्क्यान र Geohash च्याट सुविधा लागि स्थान सेवा आवश्यक छन्। + bitchat लाई स्थान सेवा आवश्यक पर्छ: + • ब्लुटुथ यन्त्र स्क्यान\n• मेष सञ्जालमा नजिकका प्रयोगकर्ता पत्ता लगाउने\n• Geohash च्याट सुविधा\n• कुनै ट्र्याकिङ वा स्थान सङ्कलन छैन + स्थान सेटिङ खोल्नुहोस् + फेरि जाँच्नुहोस् + स्थान सेवाहरू उपलब्ध छैनन् + यस यन्त्रमा स्थान सेवाहरू उपलब्ध छैनन्। यो असामान्य हो किनकि Android यन्त्रमा यो मानक हो।\n\nब्लुटुथ स्क्यान ठीकसँग चल्न (Android को आवश्यक) स्थान सेवा चाहिन्छ। अन्यथा नजिकका प्रयोगकर्ता पत्ता लाग्दैनन्। + स्थान सेवाहरू जाँचिँदै… + ब्लुटुथ आवश्यक + bitchat लाई ब्लुटुथ आवश्यक छ: + • नजिकका प्रयोगकर्ता पत्ता लगाउने\n• मेष जडान बनाउने\n• सन्देश पठाउने/प्राप्त गर्ने\n• इन्टरनेट वा सर्भर बिना काम गर्ने + ब्लुटुथ सक्षम गर्नुहोस् + ब्लुटुथ समर्थित छैन + यो यन्त्रले Bluetooth Low Energy (BLE) समर्थन गर्दैन, जुन bitchat को लागि आवश्यक छ।\n\nनजिकका यन्त्रहरूसँग इन्टरनेट बिना सञ्चार गर्न र मेष बनाउन BLE चाहिन्छ। + ब्लुटुथ स्थिति जाँचिँदै… + ब्याट्री अनुकूलन भेटियो + ब्याट्री अनुकूलन सक्षम + मेष जडान कायम राख्न bitchat पछाडिबाट चल्नुपर्छ। ब्याट्री अनुकूलनले जडान काट्न सक्छ। + बन्द गर्ने फाइदाहरू + • भरपर्दो डेलिभरी\n• मेष कन्नेक्टिभिटी कायम\n• विच्छेद रोक्छ + अनुकूलन बन्द गर्नुहोस् + ब्याट्री अनुकूलन बन्द + जारी राख्नुहोस् + मेष सञ्जाल सुरु गर्दै + . + ब्लुटुथ मेष सेटअप गर्दै… + यसलाई केही सेकेन्ड मात्र लाग्छ + ⚠️ + सेटअप पूरा भएन + फेरि प्रयास गर्नुहोस् + तपाईंको गोपनीयता सुरक्षित छ + • कुनै ट्र्याकिङ वा डेटा सङ्कलन छैन\n• ब्लुटुथ मेष च्याट पूर्णतया अफलाइन\n• Geohash च्याट इन्टरनेट प्रयोग गर्छ + अनुमति + अनुमति दिनुहोस् + bitchat ले तपाईंको स्थान पछ्याउँदैन + @ + · ⧉ + वरिपरि कोही छैन… + (तपाईं) + geohash छान्न प्यान र जुम गर्नुहोस् + छान्नुहोस् + सन्देश टाइप गर्नुहोस्… + @%1$s + उल्लेख + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + तस्वीर + फाइल + + + + + 📷 तस्वीर पठाइयो + 🎤 भ्वाइस सन्देश पठाइयो + 📎 फाइल पठाइयो + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + प्ले + पज + + + ब्लुटुथमार्फत bitchat प्रयोगकर्ता फेला पार्न आवश्यक + bitchat लाई नजिकका यन्त्र जडान गर्न अनुमति दिनुहोस् + Android अनुसार ब्लुटुथमार्फत नजिकका प्रयोगकर्ता फेला पार्न आवश्यक + नजिकका यन्त्र स्क्यान गर्न bitchat लाई आवश्यक + निजी सन्देश आउँदा सूचना पाउनुहोस् + bitchat लाई तपाईंलाई सूचना पठाउन दिनुहोस् + bitchat लाई पछाडिबाट भरपर्दो रूपमा चलाउन र मेष जडान कायम राख्न ब्याट्री अनुकूलन बन्द गर्नुहोस् + ब्याट्री प्रतिबन्ध बिना चल्न अनुमति दिनुहोस् + + + नजिकका यन्त्रहरू + ठ्याक्कै स्थान + माइक + सूचना + ब्याट्री अनुकूलन + अन्य + + + च्यानल पासवर्ड प्रविष्ट गर्नुहोस् + %1$s च्यानल पासवर्ड सुरक्षित छ। जडान हुन पासवर्ड लेख्नुहोस्। + पासवर्ड + जोडिनुहोस् + रद्द + + + + र %d थप + र %d थप + + + + 1 जना + %d जना + + + ब्लुटुथ सिफारिस गरिएको + मेश चलिरहेको छ — %1$d पियर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + परिचय खोल्नुहोस् + यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस् + यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस् + यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस् + भाषा + एपको भाषा + प्रणालीको पूर्वनिर्धारित + भाषा छान्नुहोस् + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 00000000..1e46f196 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,421 @@ + + + bitchat + + + Bluetooth-toestemming is vereist voor peer-to-peer berichten zonder internet. + Locatietoestemming is vereist om apparaten in de buurt via Bluetooth te ontdekken. + Meldingstoestemming is vereist om u te waarschuwen voor nieuwe berichten. + + + bijnaam + typ een bericht… + Wachtwoord + Kanaal deelnemen + Verlaten + Verzenden + Commando\'s weergeven + Terug + Personen + Kanalen + Online gebruikers + Niemand verbonden + Tik drie keer om alle gegevens te wissen + Netwerk + + + Batterijoptimalisatie gedetecteerd + Batterijoptimalisatie uitgeschakeld + Batterijoptimalisatie niet vereist + Batterijoptimalisatie controleren + Waarom batterijoptimalisatie uitschakelen? + bitchat draait op de achtergrond om meshnetwerkverbindingen met apparaten in de buurt te onderhouden. Batterijoptimalisatie kan deze verbindingen onderbreken, waardoor berichten vertraagd of gemist worden.\n\nHet uitschakelen van batterijoptimalisatie zorgt voor betrouwbare peer-to-peer communicatie. + Batterijoptimalisatie uitschakelen + Opmerking: U kunt deze instelling later wijzigen in Android-instellingen > Apps > bitchat > Batterij + Uw apparaat vereist geen batterijoptimalisatie-instellingen. bitchat zal normaal draaien. + Uw apparaat vereist geen batterijoptimalisatie-instellingen. bitchat zal normaal draaien. + bitchat kan betrouwbaar op de achtergrond draaien + • Zorgt voor betrouwbare berichtbezorging\n• Onderhoudt meshnetwerkconnectiviteit\n• Staat berichtrelay op achtergrond toe\n• Voorkomt verbindingsonderbrekingen + Opnieuw controleren + Nu overslaan + Doorgaan + Opnieuw proberen + Overslaan + + + en %1$d meer + %1$d berichten van %2$d personen + en %1$d meer conversaties + bitchatters in de buurt! + 1 persoon in de buurt + %1$d personen in de buurt + Nieuwe berichten + Nieuwe locatieberichten + Vermeld in %1$s + Vermeld in %1$s (+%2$d meer) + %1$d vermeldingen in %2$s + Nieuwe activiteit in %1$s + Berichten in %1$s + %1$s is toegetreden tot het gesprek + bitchat - %1$d vermeldingen + bitchat - locatiechats + %1$d berichten van %2$d locaties + Vermeld in meshchat + %1$d vermeldingen in meshchat + en %1$d meer locaties + en %1$d meer + + + Toevoegen aan favorieten + Verwijderen uit favorieten + Bladwijzer toevoegen + + + Bereikbaar via Nostr + Ongelezen privéberichten + Geteleporteerd + Locatie- en kanaalinstellingen openen + Verbonden peers + Geohash-deelnemers + Klaar voor handshake + Handshake bezig + End-to-end versleuteld + Handshake mislukt + + + 📎 Bestand ontvangen + 📄 %1$s + 📏 Grootte: %1$s + 🏷️ Type: %1$s + 📂 Openen / Opslaan + ❌ Sluiten + Afbeelding kiezen + + + Offline meshchat + Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten. + Online geohash-kanalen + Maak contact met mensen in uw omgeving met geohash-gebaseerde kanalen. Breid het mesh uit met openbare internetrelays. + End-to-end-versleuteling + Privéberichten zijn versleuteld. Kanaalberichten zijn openbaar. + systeem + licht + donker + proof of work + PoW uit + PoW aan + voeg proof of work toe aan geohash-berichten voor spamafschrikking. + moeilijkheidsgraad: %1$d bits (~%2$s) + moeilijkheidsgraad %1$d vereist ~%2$s hash-pogingen + geen proof of work vereist + zeer laag - minimale spambescherming + laag - basis spambescherming + gemiddeld - goede spambescherming + hoog - sterke spambescherming + zeer hoog - kan vertragingen veroorzaken + extreem - aanzienlijke berekening vereist + netwerk + Tor uit + Tor aan + route internet via Tor voor verbeterde privacy. + Tor-status: %1$s, bootstrap %2$d%% + Laatste: %1$s + Noodgegevensverwijdering + Debug-instellingen + Open Source • Privacy First • Gedecentraliseerd + Sluiten + annuleren + + + Waarschuwing + Locatiediensten + Privacy + Fout + Batterijoptimalisatie + Voordelen + Batterijoptimalisatie controleren + Batterijoptimalisatie niet ondersteund + Bluetooth + Ongelezen bericht + Kaart openen + Bladwijzer verwijderen + Teleporteren + Kanaal verlaten + Bereikbaar via Nostr + Offline favoriet + Precisie verlagen + Precisie verhogen + Geohash selecteren + Naar beneden scrollen + Bestand + Afbeelding + Annuleren + Link + Media kiezen + Bestand kiezen + Huidige afbeelding opslaan + Sluiten + Afbeelding %1$d van %2$d + Privacy beschermd + Spraaknotitie opnemen + Offline meshchat + Online geohash-kanalen + End-to-end-versleuteling + + + Afbeelding %1$d van %2$d + Afbeelding niet beschikbaar + Afbeelding opgeslagen in Downloads + Opslaan afbeelding mislukt + Afbeelding opgeslagen in Downloads + Opslaan afbeelding mislukt + Bestand kiezen + [bestand niet beschikbaar] + onbekend + + + kies een actie voor dit bericht of deze gebruiker + kies een actie voor deze gebruiker + bericht kopiëren + kopieer dit bericht naar klembord + %1$s een tik geven + stuur een speelse tik + %1$s knuffelen + stuur een vriendelijke knuffel + %1$s blokkeren + blokkeer alle berichten van deze gebruiker + + + #locatiekanalen + chat met mensen in uw buurt via geohash-kanalen. alleen een grove geohash wordt gedeeld, nooit exacte GPS. maak geen screenshot of deel dit scherm niet om uw privacy te beschermen. + locatietoestemming verlenen + locatietoestemming geweigerd. schakel in instellingen in om locatiekanalen te gebruiken. + Instellingen openen + \u2713 locatietoestemming verleend + toestemmingen controleren… + nabije kanalen zoeken… + bladwijzer toegevoegd + geohash + ongeldige geohash + teleporteren + locatiediensten uitschakelen + locatiediensten inschakelen + mesh + blok + buurt + stad + provincie + regio + #bluetooth • %1$s + + + debug-tools + ontwikkelaarsgereedschap voor diagnostiek en controle + uitgebreide logging + logt peer deelnames/vertrek, verbindingsrichting, pakketrouting en relays + Bluetooth-rollen + GATT-server + verbindingen: %1$d / %2$d + max server + GATT-client + max client + verbindingen: %1$d / %2$d + max totaal + pakketrelay + sinds start: %1$d + schakel rollen in/uit en sluit alle verbindingen bij uitschakelen + sync-instellingen + max pakketten per sync: %1$d + max GCS-filtergrootte: %1$d bytes (128–1024) + doel-FPR: %1$.2f%% + verbonden apparaten + ons apparaat-ID: %1$s + geen + verbreken + recente scanresultaten + verbinden + debug-console + wissen + laatste 10s: %1$d • 1m: %2$d • 15m: %3$d + afgeleide P: %1$s • gesch. max elementen: %2$s + • direct + als server (wij hosten) + als client (wij verbinden) + + + Locatiediensten vereist + Privacy eerst + bitchat volgt NIET uw locatie.\n\nLocatiediensten zijn vereist voor Bluetooth-scanning en voor de geohash-chatfunctie. + bitchat heeft locatiediensten nodig voor: + • Bluetooth-apparaat scanning\n• Ontdekken van gebruikers in de buurt op meshnetwerk\n• Geohash-chatfunctie\n• Geen tracking of locatieverzameling + Locatie-instellingen openen + Opnieuw controleren + Locatiediensten niet beschikbaar + Locatiediensten zijn niet beschikbaar op dit apparaat. Dit is ongebruikelijk omdat locatiediensten standaard zijn op Android-apparaten.\n\nbitchat heeft locatiediensten nodig om Bluetooth-scanning goed te laten werken (Android-vereiste). Zonder dit kan de app geen gebruikers in de buurt ontdekken. + Locatiediensten controleren… + Bluetooth vereist + bitchat heeft Bluetooth nodig om: + • Gebruikers in de buurt te ontdekken\n• Meshnetwerkverbindingen te maken\n• Berichten te verzenden en ontvangen\n• Te werken zonder internet of servers + Bluetooth inschakelen + Bluetooth niet ondersteund + Dit apparaat ondersteunt geen Bluetooth Low Energy (BLE), wat vereist is om bitchat te laten functioneren.\n\nbitchat heeft BLE nodig om meshnetwerken te maken en te communiceren met apparaten in de buurt zonder internet. + Bluetooth-status controleren… + batterijoptimalisatie gedetecteerd + Batterijoptimalisatie ingeschakeld + bitchat moet op de achtergrond draaien om meshverbindingen te onderhouden. batterijoptimalisatie kan deze verbindingen onderbreken. + Voordelen van uitschakelen + • betrouwbare berichtbezorging\n• onderhoudt meshconnectiviteit\n• voorkomt verbindingsonderbrekingen + Batterijoptimalisatie uitschakelen + batterijoptimalisatie uitgeschakeld + Doorgaan + Meshnetwerk initialiseren + Bluetooth-meshnetwerk instellen… + Dit zou slechts enkele seconden moeten duren + Installatie niet voltooid + Opnieuw proberen + Uw privacy is beschermd + • geen tracking of gegevensverzameling\n• Bluetooth-meshchats zijn volledig offline\n• Geohash-chats gebruiken internet + toestemmingen + Toestemmingen verlenen + bitchat volgt NIET uw locatie + niemand in de buurt… + (jij) + pan en zoom om een geohash te selecteren + selecteren + typ een bericht… + vermelden + v%1$s + image/* + Afbeelding + Bestand + + + 📷 heeft een afbeelding verstuurd + 🎤 heeft een spraakbericht verstuurd + 📎 heeft een bestand verstuurd + + + Afspelen + Pauzeren + + + Vereist om bitchat-gebruikers via Bluetooth te ontdekken + bitchat toestaan om verbinding te maken met apparaten in de buurt + Vereist door Android om bitchat-gebruikers in de buurt via Bluetooth te ontdekken + bitchat heeft dit nodig om te scannen naar apparaten in de buurt + Ontvang meldingen wanneer u privéberichten ontvangt + bitchat toestaan om u meldingen te sturen + Schakel batterijoptimalisatie uit om ervoor te zorgen dat bitchat betrouwbaar op de achtergrond draait en meshnetwerkverbindingen onderhoudt + bitchat toestaan om zonder batterijbeperkingen te draaien + + + Apparaten in de buurt + Precieze locatie + Microfoon + Meldingen + Batterijoptimalisatie + Overig + + + Kanaalwachtwoord invoeren + Kanaal %1$s is met een wachtwoord beveiligd. Voer het wachtwoord in om deel te nemen. + Wachtwoord + Deelnemen + Annuleren + + + @ + · ⧉ + @%1$s + %1$d / %2$d + @%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + . + ⚠️ + ? + + + + + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + + en %d meer + en %d meer + + + + 1 persoon + %d personen + + + + #%1$s ± 1 • %2$d notitie + #%1$s ± 1 • %2$d notities + + voeg korte permanente notities toe aan deze locatie zodat andere bezoekers ze kunnen vinden. + geo-relays niet beschikbaar; notities gepauzeerd + geen geo-relays in de buurt + notities zijn afhankelijk van geo-relays. controleer de verbinding en probeer opnieuw. + notities laden… + nog geen notities + wees de eerste die een notitie voor deze plek toevoegt. + sluiten + voeg een notitie toe voor deze plek + Bluetooth aanbevolen + Mesh actief — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + Info openen + kijk of hier notities zijn achtergelaten + 1 notitie hier achtergelaten — tik om te lezen + %d notities hier achtergelaten — tik om te lezen + Taal + App-taal + Systeemstandaard + Taal selecteren + diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml new file mode 100644 index 00000000..a3f67735 --- /dev/null +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -0,0 +1,383 @@ + + + bitchat + + + انٹرنیٹ توں بغیر سیدھی چیٹ لئی بلوٹوتھ دی اجازت ضروری اے۔ + بلوٹوتھ راہین نیڑے ڈیوائس لبھن لئی لوکیشن دی اجازت ضروری اے۔ + نوٹیفکیشن دی اجازت نواں پیغام دسدے لئی ضروری اے۔ + + + عرفی ناں + پیغام لکھو… + پاس ورڈ + چینل وچ شامل ہوو + باہر آؤ + پھجو + کمانڈ وکھاؤ + واپس + لوگ + چینل + آن لائن یوزر + کوئی وی منسلک نہیں + ساری ڈیٹا مٹاون لئی تین وار ٹیپ کرو + نیٹ ورک + + + بیٹری آپٹمائزیشن لبھیا گیا + بیٹری آپٹمائزیشن بند اے + بیٹری آپٹمائزیشن دی لوڑ نئیں + بیٹری آپٹمائزیشن چیک ہورہی اے + بیٹری آپٹمائزیشن کیوں بند کریے؟ + bitchat پس منظر وچ چلدا اے تے نیڑے ڈیوائس نال میش کنکشن برقرار رکھدا اے۔ بیٹری آپٹمائزیشن ایہ کنکشناں نوں روک سکدی اے، جِنہ نال پیغام لیٹ یا غائب ہوسکدے نیں۔\n\nبیٹری آپٹمائزیشن بند کرن نال معتبر ہم رتبہ رابطہ یقینی ہوندا اے۔ + بیٹری آپٹمائزیشن بند کرو + نوٹ: تُسی ایہ بعد وچ Android سیٹنگز > ایپس > bitchat > بیٹری وچ بدَل سکدے او۔ + توانڈے ڈیوائس نوں بیٹری آپٹمائزیشن سیٹنگ دی لوڑ نئیں۔ bitchat عام طرح چلّو گا۔ + توانڈے ڈیوائس نوں بیٹری آپٹمائزیشن سیٹنگ دی لوڑ نئیں۔ bitchat عام طرح چلّو گا۔ + bitchat پس منظر وچ قابلِ اعتماد چل سکدا اے + • معتبر پیغام ترسیل\n• میش کنکشن برقرار\n• پس منظر وچ پیغام ریلے\n• کنکشن ٹٹّن توں بچاؤ + فیر چیک کرو + ہور ویلے + جاری رکھو + فیر کوشِش کرو + سکِپ + + + تے %1$d ہور + %2$d لوکان کولوں %1$d پیغام + تے %1$d ہور گلاں + نیڑے bitchatter! + نیڑے 1 بندہ + نیڑے %1$d بندے + نویں پیغام + نویں لوکیشن پیغام + %1$s وچ ذکر ہویا + %1$s وچ ذکر (+%2$d ہور) + %2$s وچ %1$d وار ذکر + %1$s وچ نویں سرگرمی + %1$s وچ پیغام + %1$s گفتگو وچ شامل ہویا + bitchat – %1$d ذکر + bitchat – لوکیشن چیٹ + %2$d ٹھاں توں %1$d پیغام + میش چیٹ وچ ذکر + میش چیٹ وچ %1$d ذکر + تے %1$d ہور ٹھاں + تے %1$d ہور + + + پسندیدہ وچ پاؤ + پسندیدہ توں ہٹاؤ + بُک مارک پاؤ + + + Nostr راہین پہنچ ممکن اے + انہ پڑھیا ذاتی پیغام + ٹیلپورٹ کیتا گیا + مقام تے چینل دیاں ترتیباں کھولو + جُڑے پیئر + geohash شریک + ہینڈشیک لئی تیار + ہینڈشیک جاری + اینڈ ٹو اینڈ انکرپٹڈ + ہینڈشیک فیل ہویا + + + 📎 فائل ملی + 📄 %1$s + 📏 سائز: %1$s + 🏷️ قسم: %1$s + 📂 کھولو / سیو کرو + ❌ بند کرو + تصویر چُنو + + + آف لائن میش چیٹ + انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔ + آن لائن geohash چینل + geohash بیسڈ چینلاں راہین اپنے علاقے دے لوکاں نال جڑو۔ پبلک انٹرنیٹ ریلے راہین میش نوں وسیع کرو۔ + اینڈ ٹو اینڈ انکرپشن + ذاتی پیغام انکرپٹڈ ہوندے نیں۔ چینل پیغام پبلک ہوندے نیں۔ + سسٹم + ہلکا + گھُپ + ثبوتِ کار (Proof of Work) + PoW بند + PoW چالو + سپیم مشکل کرن لئی geohash پیغام وچ Proof of Work شامل کرو۔ + مشکلی: %1$d بٹ (~%2$s) + مشکلی %1$d لئی ~%2$s ہیش کوششاں چاہیدن + کسے Proof of Work دی لوڑ نئیں + بوہت گھٹ – گھٹ توں گھٹ سپیم تحفظ + گھٹ – بنیادی سپیم تحفظ + درمیانہ – اچھا سپیم تحفظ + وڈا – مضبوط سپیم تحفظ + بوہت وڈا – ڈیلے ہوسکدا اے + انتہائی – وڈی کمپیوٹنگ پاور دی لوڑ + نیٹ ورک + Tor بند + Tor چالو + وَدھ پرائیویسی لئی انٹرنیٹ ٹریفک نوں Tor راہین رُوٹ کرو۔ + Tor حالت: %1$s، بوٹ اسٹرَیپ %2$d%% + آخری: %1$s + ایمرجنسی ڈیٹا صاف + ڈی بگ سیٹنگا + اوپن سورس • پرائیویسی پہلے • غیر مرکزی + بند کرو + منسوخ + + + خبردار + لوکیشن سروسز + پرائیویسی + غلطی + بیٹری آپٹمائزیشن + فائدے + بیٹری آپٹمائزیشن چیک ہورہی اے + بیٹری آپٹمائزیشن سپورٹڈ نئیں + بلوٹوتھ + انہ پڑھیا پیغام + نقشہ کھولو + بُک مارک ہٹاؤ + ٹیلپورٹ + چینل چھوڑو + Nostr راہین پہنچ ممکن اے + پسندیدہ (آف لائن) + درستگی گھٹاؤ + درستگی وداؤ + geohash چُنو + تھلے سکرول کرو + فائل + تصویر + منسوخ + لنک + میڈیا چُنو + فائل چُنو + موجودہ تصویر سیو کرو + بند کرو + تصویر %1$d توں %2$d + پرائیویسی محفوظ اے + وائس پیغام ریکارڈ کرو + آف لائن میش چیٹ + آن لائن geohash چینل + اینڈ ٹو اینڈ انکرپشن + + + تصویر %1$d وچوں %2$d + تصویر دستیاب نئیں + تصویر \"Downloads\" وچ سیو ہو گئی + تصویر سیو نئیں ہو سکدی + تصویر \"Downloads\" وچ سیو ہو گئی + تصویر سیو نئیں ہو سکدی + فائل چُنو + [فائل دستیاب نئیں] + نامعلوم + + + اس پیغام یا یوزر لئی عمل چُنو + اس یوزر لئی عمل چُنو + پیغام کاپی کرو + ایہ پیغام کلپ بورڈ وچ کاپی کرو + %1$s نوں چپیڑ مارو + مزاحیہ چپیڑ پھجو + %1$s نوں گلے لاؤ + دوستانہ گلے ملنا پھجو + %1$s نوں بلاک کرو + اس یوزر دے سارے پیغام بلاک کرو + + + #لوکیشن چینل + geohash چینلاں راہین اپنے نیڑے والے لوکاں نال چیٹ کرو۔ صرف اوکھا geohash شیئر ہوندا اے، ٹھیک GPS ڈیٹا نئیں۔ پرائیویسی لئی مہربانی کر کے ایہہ ویو اسکرین شاٹ یا شیئر نہ کرو۔ + لوکیشن دی اجازت دو + لوکیشن دی اجازت رد ہوگئی۔ لوکیشن چینل لئی سیٹنگز وچ فعال کرو۔ + سیٹنگز کھولو + \u2713 لوکیشن دی اجازت مل گئی + اجازت چیک ہورہی اے … + نیڑے چینل لبھ رہے آں … + بُک مارک ہویا + Geohash + غلط geohash + ٹیلپورٹ + لوکیشن سروسز بند کرو + لوکیشن سروسز چالو کرو + mesh + بلاک + پڑوس + شہر + صوبہ + خطہ + #bluetooth • %1$s + + + ڈی بگ ٹول + تشخیصی تے کنٹرول لئی ڈویلپر ٹول + تفصیلی لاگنگ + پیئر جوائن/لیو، کنکشن سمت، پیکٹ فارورڈنگ تے ریلے لاگ کرو + بلوٹوتھ کردار + GATT سرور + کنکشن: %1$d / %2$d + وڈ توں وڈ سرور + GATT کلائنٹ + وڈ توں وڈ کلائنٹ + کنکشن: %1$d / %2$d + کل وڈ توں وڈ + پیکٹ ریلے + شروع توں: %1$d + کردار آن/آف کرو تے غیر فعال کرن تے سارے کنکشن بند کرو + سنک سیٹنگا + ہر سنک وِچ وڈ توں وڈ پیکٹ: %1$d + وڈ توں وڈ GCS فلٹر سائز: %1$d بائٹ (128–1024) + ٹارگٹ FPR: %1$.2f%% + جڑے ڈیوائس + ساڈا ڈیوائس ID: %1$s + کچھ وی نئیں + ڈس کنیکٹ + حال ہی وچ سکین ہوۓ ڈیوائس + جڑو + ڈی بگ کنسول + صاف کرو + پچھلے 10s: %1$d • 1m: %2$d • 15m: %3$d + مشتق P: %1$s • اندازاً وڈ توں وڈ عناصر: %2$s + • سیدھا + سرور (اسی ہوسٹ آں) + کلائنٹ (اسی جُڑدے آں) + + + لوکیشن سروسز لازمی نیں + پرائیویسی پہلے + bitchat توانڈی لوکیشن نئیں ٹریک کردا۔\n\nبلوٹوتھ سکیننگ تے geohash چیٹ فیچر لئی لوکیشن سروسز دی لوڑ اے۔ + bitchat نوں لوکیشن سروسز دی لوڑ اے لئی: + • بلوٹوتھ ڈیوائس سکیننگ\n• میش نیٹ ورک وچ نیڑے یوزراں دی تلاش\n• geohash چیٹ فیچر\n• کوئی ٹریکنگ یا لوکیشن جمع نئیں + لوکیشن سیٹنگز کھولو + فیر چیک کرو + لوکیشن سروسز دستیاب نئیں + اس ڈیوائس تے لوکیشن سروسز دستیاب نئیں۔ ایہ غیر معمولی اے کیونجے Android ڈیوائساں تے لوکیشن سروسز معیاری نیں۔\n\nبلوٹوتھ سکیننگ نوں ٹھیک کم کرن لئی bitchat نوں لوکیشن سروسز چاہیدیاں نیں (Android دی شرط)۔ بنا اس دے، ایپ نیڑے یوزر نئیں لبھ سکدی۔ + لوکیشن سروسز چیک ہورھیاں نیں … + بلوٹوتھ لازمی اے + bitchat نوں بلوٹوتھ چاہیدا اے لئی: + • نیڑے یوزر لبھنا\n• میش نیٹ ورک کنکشن بنانا\n• پیغام بھیجنا تے لینا\n• انٹرنیٹ یا سرور توں بغیر کم کرنا + بلوٹوتھ چالو کرو + بلوٹوتھ سپورٹڈ نئیں + ایہ ڈیوائس Bluetooth Low Energy (BLE) نئیں سپورٹ کردا، جو bitchat لئی ضروری اے۔\n\nbitchat نوں BLE چاہیدا اے میش نیٹ ورک بناؤن تے نیڑے ڈیوائساں نال انٹرنیٹ توں بغیر گل کرن لئی۔ + بلوٹوتھ حالت چیک ہورہی اے … + بیٹری آپٹمائزیشن لبھیا گیا + بیٹری آپٹمائزیشن چالو اے + میش کنکشن قائم رکھن لئی bitchat نوں پس منظر وچ چلنا پوے گا۔ بیٹری آپٹمائزیشن ایہ کنکشناں وچ رکاوٹ پا سکدی اے۔ + بند کرن دے فائدے + • معتبر پیغام رسانی\n• میش کنکشن برقرار\n• کنکشن ٹٹّن توں بچاؤ + بیٹری آپٹمائزیشن بند کرو + بیٹری آپٹمائزیشن بند اے + جاری رکھو + میش نیٹ ورک شروع ہورہا اے + بلوٹوتھ میش نیٹ ورک سیٹ اپ ہورہا … + ایہ صرف چند سیکنڈ لگنے چاہیدے نیں + سیٹ اپ مکمل نئیں + فیر کوشِش کرو + توانڈی پرائیویسی محفوظ اے + • کوئی ٹریکنگ یا ڈیٹا جمع نئیں\n• بلوٹوتھ میش چیٹ مکمل آف لائن\n• geohash چیٹ انٹرنیٹ استعمال کردی اے + اجازتاں + اجازت دو + bitchat توانڈی لوکیشن نئیں ٹریک کردا + کوئی وی نیڑے نئیں … + (تُسی) + geohash چُنن لئی پین تے زوم کرو + چُنو + پیغام لکھو … + ذکر + v%1$s + image/* + تصویر + فائل + + + 📷 تصویر بھیجی + 🎤 وائس پیغام بھیجیا + 📎 فائل بھیجی + + + چلاؤ + روکੋ + + + بلوٹوتھ راہین bitchat یوزر لبھن لئی ضروری + bitchat نوں نیڑے ڈیوائساں نال جُڑن دی اجازت دو + Android نوں بلوٹوتھ راہین نیڑے bitchat یوزراں دی تلاش لئی لوڑ اے + bitchat نوں نیڑے ڈیوائساں سکین کرن لئی ایہ چاہیدہ + جدو تُہانوں ذاتی پیغام مِلن تاں نوٹیفکیشن لؤ + bitchat نوں نوٹیفکیشن بھیجن دی اجازت دو + ایہ یقینی بناؤن لئی بیٹری آپٹمائزیشن بند کرو کہ bitchat پس منظر وچ معتبر چلّے تے میش نیٹ ورک کنکشناں برقرار رہن + bitchat نوں بغیر بیٹری پابندیاں دے چلّن دی اجازت دو + + + نیڑے ڈیوائس + ٹھیک لوکیشن + مائیکروفون + نوٹیفکیشن + بیٹری آپٹمائزیشن + ہور + + + چینل دا پاس ورڈ لکھو + چینل %1$s پاس ورڈ نال محفوظ اے۔ شامل ہون لئی پاس ورڈ لکھو۔ + پاس ورڈ + شامل ہوو + منسوخ + + + + تے %d ہور + تے %d ہور + + + + 1 بندہ + %d بندے + + + Bluetooth Recommended + میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + ایپ بارے کھولو + ایتھے چھڈے نوٹس ویکھو + ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو + ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو + بولی + ایپ دی بولی + سسٹم دی ڈیفالٹ + بولی چݨو + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 00000000..3ec84dd4 --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,401 @@ + + + + + + + #%1$s ± 1 • %2$d notatka + #%1$s ± 1 • %2$d notatek + + dodaj krótkie stałe notatki do tej lokalizacji, aby inni odwiedzający mogli je znaleźć. + przekaźniki geo niedostępne; notatki wstrzymane + brak przekaźników geo w pobliżu + notatki zależą od przekaźników geo. sprawdź połączenie i spróbuj ponownie. + ładowanie notatek… + brak notatek + bądź pierwszy, który doda notatkę dla tego miejsca. + zamknij + dodaj notatkę dla tego miejsca + Pomiń + Bluetooth zalecany + Mesh działa — %1$d peerów + + weryfikuj + zeskanuj, aby mnie zweryfikować + zeskanuj kod QR innej osoby + zeskanuj kod QR innej osoby + pokaż mój kod QR + usuń weryfikację + kod QR niedostępny + wymagane jest uprawnienie do aparatu, aby zeskanować kody QR + włącz aparat + wklej link weryfikacyjny + zweryfikuj + poproszono o weryfikację + weryfikacja bezpieczeństwa + odcisk drugiej osoby + twój odcisk + oczekiwanie na uzgadnianie + otwórz czat prywatny, aby zobaczyć odciski + zaszyfrowane i zweryfikowane + zaszyfrowane + uzgadnianie + uzgadnianie nie powiodło się + niezaszyfrowane + zweryfikowano + zweryfikowałeś tożsamość tej osoby. + niezweryfikowano + porównaj te odciski z %1$s za pomocą bezpiecznego kanału. + oznacz jako zweryfikowane + rozpocznij uzgadnianie + kopiuj + Weryfikacja wzajemna + Ty i %1$s zweryfikowaliście się nawzajem + wzajemna weryfikacja z %1$s + Zweryfikowano + Zweryfikowałeś %1$s + zweryfikowano %1$s + + + bitchat + Uprawnienie Bluetooth jest wymagane do komunikacji peer-to-peer bez internetu. + Uprawnienie lokalizacji jest wymagane do wykrywania pobliskich urządzeń przez Bluetooth. + Uprawnienie powiadomień jest wymagane, aby informować Cię o nowych wiadomościach. + pseudonim + napisz wiadomość… + Hasło + Dołącz do kanału + Opuść + Wyślij + Pokaż komendy + Wstecz + Osoby + Kanały + Użytkownicy online + Nikt nie jest połączony + Stuknij trzykrotnie, aby wyczyścić wszystkie dane + Sieć + Wykryto optymalizację baterii + Optymalizacja baterii wyłączona + Optymalizacja baterii niewymagana + Sprawdzanie optymalizacji baterii + Dlaczego wyłączyć optymalizację baterii? + bitchat działa w tle, aby utrzymać połączenia z siecią mesh z pobliskimi urządzeniami. Optymalizacja baterii może przerywać te połączenia, powodując opóźnienia lub utratę wiadomości.\n\nWyłączenie optymalizacji baterii zapewnia niezawodną komunikację peer-to-peer. + Wyłącz optymalizację baterii + Uwaga: możesz zmienić to ustawienie później w Ustawieniach Androida > Aplikacje > bitchat > Bateria + Twoje urządzenie nie wymaga ustawień optymalizacji baterii. bitchat będzie działać normalnie. + Twoje urządzenie nie wymaga ustawień optymalizacji baterii. bitchat będzie działać normalnie. + bitchat może działać niezawodnie w tle + • Zapewnia niezawodne dostarczanie wiadomości\n• Utrzymuje łączność sieci mesh\n• Umożliwia przekazywanie wiadomości w tle\n• Zapobiega zrywaniu połączeń + Sprawdź ponownie + Pomiń na razie + Kontynuuj + Ponów + i jeszcze %1$d + %1$d wiadomości od %2$d osób + i jeszcze %1$d rozmów + 👥 bitchatowicze w pobliżu! + 1 osoba w pobliżu + %1$d osób w pobliżu + Nowe wiadomości + Nowe wiadomości lokalizacyjne + Wzmianka w %1$s + Wzmianka w %1$s (+%2$d więcej) + %1$d wzmianek w %2$s + Nowa aktywność w %1$s + Wiadomości w %1$s + %1$s dołączył(a) do rozmowy + bitchat - %1$d wzmianek + bitchat - czaty lokalizacyjne + %1$d wiadomości z %2$d lokalizacji + Wzmianka w czacie Mesh + %1$d wzmianek w czacie Mesh + i jeszcze %1$d lokalizacji + Usługa mesh w tle + Utrzymuje działanie sieci mesh Bluetooth w tle + Dodaj do ulubionych + Usuń z ulubionych + Dodaj zakładkę + Dostępny przez Nostr + Nieprzeczytane wiadomości prywatne + Notatki lokalizacji + Teleportowany + Otwórz ustawienia lokalizacji i kanałów + Połączeni użytkownicy + Uczestnicy geohash + Gotowy do uzgadniania + Uzgadnianie w toku + Szyfrowane end-to-end + Uzgadnianie nie powiodło się + 📎 Otrzymano plik + 📄 %1$s + 📏 Rozmiar: %1$s + 🏷️ Typ: %1$s + 📂 Otwórz / Zapisz + ❌ Zamknij + Wybierz obraz + Zapisz bieżący obraz + Zamknij + Obraz zapisany w Pobranych + Nie udało się zapisać obrazu + Obraz %1$d z %2$d + Wybierz plik + zdecentralizowana komunikacja mesh z szyfrowaniem end-to-end + Czat mesh offline + Komunikuj się bezpośrednio przez Bluetooth LE bez internetu i serwerów. Wiadomości są przekazywane przez pobliskie urządzenia, aby zwiększyć zasięg. + Kanały geohash online + Łącz się z ludźmi w Twojej okolicy za pomocą kanałów opartych na geohash. Rozszerz sieć mesh, korzystając z publicznych przekaźników internetowych. + Szyfrowanie end-to-end + Wiadomości prywatne są szyfrowane. Wiadomości na kanałach są publiczne. + systemowy + jasny + ciemny + dowód pracy + pow wyłączony + pow włączony + dodaj dowód pracy do wiadomości geohash, aby zniechęcić do spamu. + trudność: %1$d bitów (~%2$s) + trudność %1$d wymaga ~%2$s prób hashowania + dowód pracy niewymagany + bardzo niska - minimalna ochrona przed spamem + niska - podstawowa ochrona przed spamem + średnia - dobra ochrona przed spamem + wysoka - silna ochrona przed spamem + bardzo wysoka - może powodować opóźnienia + ekstremalna - wymaga znacznych obliczeń + sieć + tor wyłączony + tor włączony + kieruj ruch internetowy przez Tor dla większej prywatności. + Status tor: %1$s, bootstrap %2$d%% + Ostatnio: %1$s + Awaryjne usuwanie danych + Ustawienia debugowania + Open Source • Prywatność przede wszystkim • Zdecentralizowane + Zamknij + Prywatność chroniona + anuluj + Ostrzeżenie + Usługi lokalizacji + Prywatność + Błąd + Optymalizacja baterii + Korzyści + Sprawdzanie optymalizacji baterii + Optymalizacja baterii nieobsługiwana + Bluetooth + Nieprzeczytana wiadomość + Otwórz mapę + Usuń zakładkę + Teleportuj + Zamknij bitchat + działaj w tle + utrzymuj sieć mesh aktywną po zamknięciu aplikacji (usługa pierwszoplanowa) + Opuść kanał + Dostępny przez Nostr + Ulubiony offline + Zmniejsz precyzję + Zwiększ precyzję + Wybierz geohash + Przewiń na dół + Plik + Obraz + Anuluj + Link + Nagraj notatkę głosową + Wybierz multimedia + Czat mesh offline + Kanały geohash online + Szyfrowanie end-to-end + #bluetooth • %1$s + Obraz %1$d z %2$d + Obraz niedostępny + Obraz zapisany w Pobranych + Nie udało się zapisać obrazu + Wybierz plik + [plik niedostępny] + nieznany + wybierz działanie dla tej wiadomości lub użytkownika + wybierz działanie dla tego użytkownika + kopiuj wiadomość + skopiuj tę wiadomość do schowka + spoliczkuj %1$s + wyślij żartobliwą wiadomość o spoliczkowaniu + przytul %1$s + wyślij przyjazną wiadomość z przytuleniem + zablokuj %1$s + zablokuj wszystkie wiadomości od tego użytkownika + napisz do %1$s + wyślij prywatną wiadomość + #kanały lokalizacyjne + rozmawiaj z osobami w pobliżu za pomocą kanałów geohash. udostępniany jest tylko przybliżony geohash, nigdy dokładne GPS. nie rób zrzutów ekranu ani nie udostępniaj tego ekranu, aby chronić swoją prywatność. + przyznaj uprawnienie lokalizacji + odmówiono uprawnienia lokalizacji. włącz je w ustawieniach, aby korzystać z kanałów lokalizacyjnych. + \u2713 przyznano uprawnienie lokalizacji + sprawdzanie uprawnień... + wyszukiwanie pobliskich kanałów… + dodano do zakładek + geohash + nieprawidłowy geohash + teleportuj + wyłącz usługi lokalizacji + włącz usługi lokalizacji + mesh + blok + dzielnica + miasto + województwo + region + narzędzia debugowania + narzędzia deweloperskie do diagnostyki i kontroli + szczegółowe logowanie + loguje dołączenia/odejścia peerów, kierunek połączenia, trasowanie pakietów i przekaźniki + role bluetooth + serwer gatt + połączenia: %1$d / %2$d + maks. serwer + klient gatt + maks. klient + połączenia: %1$d / %2$d + maks. ogółem + przekazywanie pakietów + od startu: %1$d + włączaj/wyłączaj role i zamykaj wszystkie połączenia po wyłączeniu + ustawienia synchronizacji + maks. pakietów na synchronizację: %1$d + maks. rozmiar filtra GCS: %1$d bajtów (128–1024) + docelowy FPR: %1$.2f%% + połączone urządzenia + id naszego urządzenia: %1$s + brak + rozłącz + ostatnie wyniki skanowania + połącz + konsola debugowania + wyczyść + ostatnie 10s: %1$d • 1min: %2$d • 15min: %3$d + wyliczone P: %1$s • szac. maks. elementów: %2$s + • bezpośrednio + RSSI: %1$s + ? + jako serwer (hostujemy) + jako klient (łączymy się) + Wymagane usługi lokalizacji + Prywatność przede wszystkim + bitchat NIE śledzi Twojej lokalizacji.\n\nUsługi lokalizacji są wymagane do skanowania Bluetooth oraz do funkcji czatu Geohash. + bitchat potrzebuje usług lokalizacji do: + • Skanowania urządzeń Bluetooth\n• Wykrywania pobliskich użytkowników w sieci mesh\n• Funkcji czatu geohash\n• Brak śledzenia lub gromadzenia lokalizacji + Zalecana lokalizacja w tle + opcjonalne, poprawia niezawodność sieci mesh + Android zaleca lokalizację w tle, aby bitchat mógł skanować pobliskie urządzenia, gdy aplikacja nie jest otwarta. Utrzymuje to sieć mesh aktywną po ponownym uruchomieniu. + Gdy otworzą się ustawienia, wybierz „Zawsze zezwalaj”. + bitchat używa lokalizacji w tle do: + - skanowania pobliskich urządzeń, gdy aplikacja jest zamknięta\n- ponownego łączenia po restarcie\n- utrzymywania działania sieci mesh w tle + NIGDY nie gromadzimy ani nie zapisujemy Twojej lokalizacji. Twoja prywatność jest bezpieczna. + Zezwól na lokalizację w tle + Kontynuuj bez lokalizacji w tle + Otwórz ustawienia lokalizacji + Sprawdź ponownie + Usługi lokalizacji niedostępne + Usługi lokalizacji są niedostępne na tym urządzeniu. Jest to nietypowe, ponieważ usługi lokalizacji są standardem w urządzeniach z Androidem.\n\nbitchat potrzebuje usług lokalizacji, aby skanowanie Bluetooth działało poprawnie (wymóg Androida). Bez tego aplikacja nie może wykrywać pobliskich użytkowników. + Sprawdzanie usług lokalizacji... + Wymagany Bluetooth + bitchat potrzebuje Bluetooth, aby: + • Wykrywać pobliskich użytkowników\n• Tworzyć połączenia sieci mesh\n• Wysyłać i odbierać wiadomości\n• Działać bez internetu i serwerów + Włącz Bluetooth + Bluetooth nieobsługiwany + To urządzenie nie obsługuje Bluetooth Low Energy (BLE), który jest wymagany do działania bitchat.\n\nbitchat potrzebuje BLE, aby tworzyć sieci mesh i komunikować się z pobliskimi urządzeniami bez internetu. + Sprawdzanie stanu Bluetooth... + wykryto optymalizację baterii + Optymalizacja baterii włączona + bitchat musi działać w tle, aby utrzymać połączenia z siecią mesh. optymalizacja baterii może przerywać te połączenia. + Korzyści z wyłączenia + • niezawodne dostarczanie wiadomości\n• utrzymuje łączność sieci mesh\n• zapobiega zrywaniu połączeń + Wyłącz optymalizację baterii + optymalizacja baterii wyłączona + Kontynuuj + Inicjalizowanie sieci mesh + . + Konfigurowanie sieci mesh Bluetooth... + To powinno zająć tylko kilka sekund + ⚠️ + Konfiguracja niekompletna + Spróbuj ponownie + Otwórz ustawienia + Twoja prywatność jest chroniona + • brak śledzenia ani gromadzenia danych\n• czaty sieci mesh Bluetooth są całkowicie offline\n• czaty geohash korzystają z internetu + uprawnienia + Przyznaj uprawnienia + bitchat NIE śledzi Twojej lokalizacji + @ + · ⧉ + nikogo w pobliżu... + (ty) + przesuwaj i powiększaj, aby wybrać geohash + wybierz + napisz wiadomość... + @%1$s + wzmianka + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Obraz + Plik + 📷 wysłał(a) obraz + 🎤 wysłał(a) wiadomość głosową + 📎 wysłał(a) plik + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + Odtwórz + Wstrzymaj + Wymagane do wykrywania użytkowników bitchat przez Bluetooth + Zezwól bitchat na łączenie się z pobliskimi urządzeniami + Wymagane przez Android do wykrywania pobliskich użytkowników bitchat przez Bluetooth + bitchat potrzebuje tego, aby skanować pobliskie urządzenia + Zalecane do skanowania pobliskich urządzeń, gdy aplikacja działa w tle + Zezwól na dostęp do lokalizacji w tle + Otrzymuj powiadomienia, gdy dostaniesz prywatne wiadomości + Zezwól bitchat na wysyłanie Ci powiadomień + Wyłącz optymalizację baterii, aby zapewnić niezawodne działanie bitchat w tle i utrzymanie połączeń z siecią mesh + Zezwól bitchat na działanie bez ograniczeń baterii + Pobliskie urządzenia + Precyzyjna lokalizacja + Lokalizacja w tle + Mikrofon + Powiadomienia + Optymalizacja baterii + Inne + Wprowadź hasło kanału + Kanał %1$s jest chroniony hasłem. Wprowadź hasło, aby dołączyć. + Hasło + Dołącz + Anuluj + Tor niedostępny w tej wersji + + + i jeszcze %1$d + i jeszcze %1$d + i jeszcze %1$d + i jeszcze %1$d + + + %1$d osoba + %1$d osoby + %1$d osób + %1$d osoby + + Język + Język aplikacji + Domyślny systemu + Wybierz język + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 8c40eb3c..42340857 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,39 +1,395 @@ bitchat - A permissão Bluetooth é necessária para mensagens ponto a ponto sem internet. - É necessária permissão de localização para descobrir dispositivos próximos via Bluetooth. - A permissão de notificação é necessária para alertá-lo sobre novas mensagens. - apelido - digite uma mensagem… + + + A permissão de Bluetooth é necessária para conversar diretamente com outras pessoas sem internet. + A permissão de localização é necessária para encontrar dispositivos próximos via Bluetooth. + A permissão de notificações é necessária para avisar sobre novas mensagens. + + + Apelido + Digite uma mensagem… Senha Entrar no canal Sair Enviar + Mostrar comandos Voltar Pessoas Canais - Usuários Online + Usuários online Ninguém conectado - Toque três vezes para limpar todos os dados + Toque 3 vezes para apagar todos os dados Sua rede - - Otimização de Bateria Detectada - Otimização de Bateria Desativada - Otimização de Bateria Não Requerida - Verificando a Otimização de Bateria + + Otimização de bateria detectada + Otimização de bateria desativada + Otimização de bateria não necessária + Verificando otimização de bateria Por que desativar a otimização de bateria? - O bitchat é executado em segundo plano para manter conexões de rede mesh com dispositivos próximos. A otimização da bateria pode interromper essas conexões e atrasar ou impedir a entrega de mensagens. - Desativar a otimização da bateria - Observação: você pode alterar essa configuração posteriormente em Configurações do Android > Aplicativos > bitchat > Bateria - Seu dispositivo não requer configurações de otimização da bateria. O bitchat funcionará normalmente. - Seu dispositivo não requer configurações de otimização da bateria. O bitchat funcionará normalmente. - O bitchat pode ser executado de forma confiável em segundo plano - • Garante a entrega confiável de mensagens • Mantém a conectividade da rede mesh • Permite o retransmissão de mensagens em segundo plano • Evita desconexões inesperadas + O bitchat roda em segundo plano para manter conexões mesh com dispositivos próximos. A otimização de bateria pode interromper essas conexões, fazendo com que mensagens atrasem ou se percam.\n\nDesativar a otimização de bateria garante comunicação P2P confiável. + Desativar otimização de bateria + Nota: Você pode alterar isso depois em Configurações do Android > Apps > bitchat > Bateria. + Seu dispositivo não requer ajustes de otimização de bateria. O bitchat funcionará normalmente. + Seu dispositivo não requer ajustes de otimização de bateria. O bitchat funcionará normalmente. + O bitchat pode operar de forma confiável em segundo plano + • Entrega confiável de mensagens\n• Manutenção da conexão mesh\n• Encaminhamento em segundo plano\n• Evita quedas de conexão Verificar novamente - Ignorar por enquanto + Pular agora Continuar Tentar novamente - Ignorar + Pular + + + e mais %1$d + %1$d mensagens de %2$d pessoas + e mais %1$d conversas + pessoas do bitchat por perto! + 1 pessoa por perto + %1$d pessoas por perto + Novas mensagens + Novas mensagens de localização + Mencionado em %1$s + Mencionado em %1$s (+%2$d) + %1$d menções em %2$s + Nova atividade em %1$s + Mensagens em %1$s + %1$s entrou na conversa + bitchat – %1$d menções + bitchat – Chats por localização + %1$d mensagens de %2$d locais + Mencionado no chat mesh + %1$d menções no chat mesh + e mais %1$d locais + e mais %1$d + + + Adicionar aos favoritos + Remover dos favoritos + Adicionar favorito + + + Acessível via Nostr + Mensagens privadas não lidas + Teletransportado + Abrir configurações de localização e canais + Peers conectados + Participantes geohash + Pronto para handshake + Handshake em andamento + Criptografado de ponta a ponta + Handshake falhou + + + 📎 Arquivo recebido + 📄 %1$s + 📏 Tamanho: %1$s + 🏷️ Tipo: %1$s + 📂 Abrir / Salvar + ❌ Fechar + Escolher imagem + + + Chat mesh offline + Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance. + Canais geohash online + Conecte-se com pessoas próximas através de canais baseados em geohash. Expanda a mesh por meio de relays públicos na internet. + Criptografia de ponta a ponta + Mensagens privadas são criptografadas. Mensagens de canal são públicas. + Sistema + Claro + Escuro + Prova de Trabalho + PoW desligado + PoW ligado + Adicione prova de trabalho às mensagens geohash para dificultar spam. + Dificuldade: %1$d bits (~%2$s) + Dificuldade %1$d requer ~%2$s tentativas de hash + nenhuma prova de trabalho necessária + muito baixa – proteção mínima contra spam + baixa – proteção básica contra spam + média – boa proteção contra spam + alta – proteção forte contra spam + muito alta – pode causar atrasos + extrema – requer muita capacidade de processamento + Rede + Tor desligado + Tor ligado + Roteie o tráfego de internet via Tor para mais privacidade. + Status do Tor: %1$s, bootstrap %2$d%% + Último: %1$s + Limpeza de dados de emergência + Configurações de depuração + Código aberto • Privacidade em primeiro lugar • Descentralizado + Fechar + cancelar + + + Aviso + Serviços de localização + Privacidade + Erro + Otimização de bateria + Benefícios + Verificando otimização de bateria + Otimização de bateria não suportada + Bluetooth + Mensagem não lida + Abrir mapa + Remover favorito + Teletransportar + Sair do canal + Acessível via Nostr + Favorito (offline) + Diminuir precisão + Aumentar precisão + Selecionar geohash + Rolar para o fim + Arquivo + Imagem + Cancelar + Link + Escolher mídia + Escolher arquivo + Salvar imagem atual + Fechar + Imagem %1$d de %2$d + Privacidade protegida + Gravar nota de voz + Chat mesh offline + Canais geohash online + Criptografia de ponta a ponta + + + Imagem %1$d de %2$d + Imagem indisponível + Imagem salva em "Downloads" + Não foi possível salvar a imagem + Imagem salva em "Downloads" + Não foi possível salvar a imagem + Escolher arquivo + [Arquivo indisponível] + desconhecido + + + Escolha uma ação para esta mensagem ou usuário + Escolha uma ação para este usuário + Copiar mensagem + Copiar esta mensagem para a área de transferência + Dar um tapa em %1$s + Enviar um tapa brincalhão + Abraçar %1$s + Enviar um abraço amigável + Bloquear %1$s + Bloquear todas as mensagens deste usuário + + + #Canais de localização + Converse com pessoas próximas através de canais geohash. Apenas um geohash aproximado é compartilhado, nunca dados exatos de GPS. Para preservar sua privacidade, não tire capturas de tela nem compartilhe esta tela. + Conceder permissão de localização + Permissão de localização negada. Ative nas configurações para usar canais de localização. + Abrir configurações + \u2713 Permissão de localização concedida + Verificando permissões… + Procurando canais próximos… + Marcado + Geohash + Geohash inválido + teletransportar + Desativar serviços de localização + Ativar serviços de localização + mesh + Quadra + Vizinhança + Cidade + Estado + Região + #bluetooth • %1$s + + + Ferramentas de depuração + Ferramentas do desenvolvedor para diagnóstico e controle + log detalhado + Registra entradas/saídas de peers, direção da conexão, retransmissão de pacotes e relays + Funções do Bluetooth + Servidor GATT + Conexões: %1$d / %2$d + máx. servidor + Cliente GATT + máx. cliente + Conexões: %1$d / %2$d + máx. geral + Retransmissão de pacotes + desde o início: %1$d + Alternar funções e fechar todas as conexões ao desativar + Configurações de sincronização + máx. pacotes por sincronização: %1$d + máx. tamanho do filtro GCS: %1$d bytes (128–1024) + FPR alvo: %1$.2f%% + dispositivos conectados + nosso ID do dispositivo: %1$s + nenhum + desconectar + dispositivos escaneados recentemente + conectar + Console de depuração + limpar + últimos 10s: %1$d • 1m: %2$d • 15m: %3$d + P derivado: %1$s • itens máx. estimados: %2$s + • direto + como servidor (nós hospedamos) + como cliente (nós conectamos) + + + Serviços de localização necessários + Privacidade em primeiro lugar + O bitchat NÃO rastreia sua localização.\n\nOs serviços de localização são necessários para a varredura do Bluetooth e o recurso de chat geohash. + O bitchat precisa de serviços de localização para: + • Varredura de dispositivos Bluetooth\n• Descobrir usuários próximos na rede mesh\n• Recurso de chat geohash\n• Sem rastreamento nem coleta de localização + Abrir configurações de localização + Verificar novamente + Serviços de localização indisponíveis + Os serviços de localização não estão disponíveis neste dispositivo. Isso é incomum, pois serviços de localização são padrão em dispositivos Android.\n\nO bitchat requer serviços de localização para a varredura Bluetooth funcionar corretamente (requisito do Android). Sem isso, o app não consegue descobrir usuários próximos. + Verificando serviços de localização… + Bluetooth necessário + O bitchat precisa de Bluetooth para: + • Descobrir usuários próximos\n• Criar conexões mesh\n• Enviar e receber mensagens\n• Funcionar sem internet ou servidores + Ativar Bluetooth + Bluetooth não suportado + Este dispositivo não suporta Bluetooth Low Energy (BLE), necessário para o bitchat.\n\nO BLE é necessário para criar redes mesh e comunicar com dispositivos próximos sem internet. + Verificando status do Bluetooth… + Otimização de bateria detectada + Otimização de bateria ativada + O bitchat precisa rodar em segundo plano para manter conexões mesh. A otimização de bateria pode interromper essas conexões. + Benefícios de desativar + • entrega confiável\n• mantém conectividade mesh\n• evita quedas + Desativar otimização de bateria + Otimização de bateria desativada + Continuar + Inicializando rede mesh + Configurando rede mesh Bluetooth… + Deve levar apenas alguns segundos + Configuração não concluída + Tentar novamente + Sua privacidade está protegida + • sem rastreamento ou coleta de dados\n• chats mesh Bluetooth são totalmente offline\n• chats geohash usam internet + Permissões + Conceder permissões + O bitchat NÃO rastreia sua localização + ninguém por perto… + (você) + arraste/amplie para selecionar um geohash + selecionar + Digite uma mensagem… + mencionar + v%1$s + image/* + Imagem + Arquivo + + + 📷 enviou uma imagem + 🎤 enviou uma mensagem de voz + 📎 enviou um arquivo + + + Reproduzir + Pausar + + + Necessário para descobrir usuários do bitchat via Bluetooth + Permita que o bitchat se conecte a dispositivos próximos + Necessário pelo Android para descobrir usuários próximos via Bluetooth + O bitchat precisa disso para escanear dispositivos próximos + Receber notificações quando você receber mensagens privadas + Permita que o bitchat envie notificações + Desative a otimização de bateria para que o bitchat funcione de forma confiável em segundo plano e mantenha conexões mesh + Permita que o bitchat funcione sem restrições de bateria + + + Dispositivos próximos + Localização precisa + Microfone + Notificações + Otimização de bateria + Outros + + + Digite a senha do canal + O canal %1$s é protegido por senha. Digite a senha para entrar. + Senha + Entrar + Cancelar + + + + e mais %d + e mais %d + + + + 1 pessoa + %d pessoas + + + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d notas + + adicione notas curtas e permanentes a este local para que outros visitantes as encontrem. + relés geográficos indisponíveis; notas em pausa + nenhum relé geográfico nas proximidades + as notas dependem de relés geográficos. verifique a conexão e tente novamente. + carregando notas… + ainda não há notas + seja o primeiro a adicionar uma para este local. + fechar + adicione uma nota para este local + Bluetooth recomendado + Mesh rodando — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler + Idioma + Idioma do app + Padrão do sistema + Selecionar idioma diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 289d519a..0e01bb07 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,15 +1,20 @@ bitchat - A permissão de Bluetooth é necessária para mensagens ponto a ponto sem internet. - A permissão de localização é necessária para descobrir dispositivos próximos através de Bluetooth. - A permissão de notificações é necessária para o alertar sobre novas mensagens. - alcunha - introduza uma mensagem… + + + A permissão do Bluetooth é necessária para conversar diretamente com outros sem internet. + A permissão de localização é necessária para encontrar dispositivos próximos via Bluetooth. + A permissão de notificações é necessária para alertá-lo sobre novas mensagens. + + + Apelido + Digite uma mensagem… Palavra-passe - Entrar no canal + Juntar-se ao canal Sair Enviar + Mostrar comandos Voltar Pessoas Canais @@ -17,4 +22,375 @@ Ninguém ligado Toque três vezes para apagar todos os dados A sua rede + + + Detetada otimização da bateria + Otimização da bateria desativada + Otimização da bateria não necessária + A verificar otimização da bateria + Por que desativar a otimização da bateria? + O bitchat funciona em segundo plano para manter conexões de rede mesh com dispositivos próximos. A otimização da bateria pode interromper essas conexões, fazendo com que as mensagens cheguem atrasadas ou se percam.\n\nDesativar a otimização da bateria garante comunicação peer-to-peer confiável. + Desativar otimização da bateria + Nota: Pode alterar isso posteriormente em Definições do Android > Aplicações > bitchat > Bateria. + O seu dispositivo não requer definições de otimização da bateria. O bitchat funcionará normalmente. + O seu dispositivo não requer definições de otimização da bateria. O bitchat funcionará normalmente. + O bitchat pode funcionar de forma confiável em segundo plano + • Entrega confiável de mensagens\n• Manutenção da conexão mesh\n• Reencaminhamento de mensagens em segundo plano\n• Previne desconexões + Verificar novamente + Ignorar por agora + Continuar + Tentar novamente + Ignorar + + + e mais %1$d + %1$d mensagens de %2$d pessoas + e mais %1$d conversas + bitchatters por perto! + 1 pessoa por perto + %1$d pessoas por perto + Novas mensagens + Novas mensagens de localização + Mencionado em %1$s + Mencionado em %1$s (+%2$d) + %1$d menções em %2$s + Nova atividade em %1$s + Mensagens em %1$s + %1$s juntou-se à conversa + bitchat – %1$d menções + bitchat – Conversas de localização + %1$d mensagens de %2$d localizações + Mencionado no chat mesh + %1$d menções no chat mesh + e mais %1$d localizações + e mais %1$d + + + Adicionar aos favoritos + Remover dos favoritos + Adicionar marcador + + + Alcançável via Nostr + Mensagens privadas não lidas + Teletransportado + Abrir definições de localização e canais + Pares ligados + Participantes geohash + Pronto para handshake + Handshake em andamento + Encriptado ponto a ponto + Handshake falhado + + + 📎 Ficheiro recebido + 📄 %1$s + 📏 Tamanho: %1$s + 🏷️ Tipo: %1$s + 📂 Abrir / Guardar + ❌ Fechar + Escolher imagem + + + Chat mesh offline + Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance. + Canais geohash online + Conecte-se com pessoas perto de si através de canais baseados em geohash. Estenda a mesh através de repetidores de internet pública. + Encriptação ponto a ponto + Mensagens privadas são encriptadas. Mensagens de canal são públicas. + Sistema + Claro + Escuro + Prova de Trabalho + PoW desligado + PoW ligado + Adicionar prova de trabalho às mensagens geohash para tornar o spam mais difícil. + Dificuldade: %1$d bits (~%2$s) + Dificuldade %1$d requer ~%2$s tentativas de hash + nenhuma prova de trabalho necessária + muito baixo – proteção mínima contra spam + baixo – proteção básica contra spam + médio – boa proteção contra spam + alto – proteção forte contra spam + muito alto – pode causar atrasos + extremo – requer potência computacional significativa + Rede + Tor desligado + Tor ligado + Encaminhar tráfego de internet através do Tor para maior privacidade. + Estado do Tor: %1$s, bootstrap %2$d%% + Último: %1$s + Limpeza de dados de emergência + Definições de depuração + Código aberto • Privacidade primeiro • Descentralizado + Fechar + cancelar + + + Aviso + Serviços de localização + Privacidade + Erro + Otimização da bateria + Benefícios + A verificar otimização da bateria + Otimização da bateria não suportada + Bluetooth + Mensagem não lida + Abrir mapa + Remover marcador + Teletransportar + Sair do canal + Alcançável via Nostr + Favorito (offline) + Diminuir precisão + Aumentar precisão + Selecionar geohash + Deslocar para baixo + Ficheiro + Imagem + Cancelar + Ligação + Escolher multimédia + Escolher ficheiro + Guardar imagem atual + Fechar + Imagem %1$d de %2$d + Privacidade protegida + Gravar nota de voz + Chat mesh offline + Canais geohash online + Encriptação ponto a ponto + + + Imagem %1$d de %2$d + Imagem indisponível + Imagem guardada em "Transferências" + Não foi possível guardar a imagem + Imagem guardada em "Transferências" + Não foi possível guardar a imagem + Escolher ficheiro + [Ficheiro indisponível] + desconhecido + + + Escolher ação para esta mensagem ou utilizador + Escolher ação para este utilizador + Copiar mensagem + Copiar esta mensagem para a área de transferência + Dar uma palmada em %1$s + Enviar uma palmada brincalhona + Abraçar %1$s + Enviar um abraço amigável + Bloquear %1$s + Bloquear todas as mensagens deste utilizador + + + #Canais de localização + Converse com pessoas perto de si através de canais geohash. Apenas um geohash aproximado é partilhado, nunca dados GPS exatos. Para preservar a sua privacidade, não tire capturas de ecrã nem partilhe este ecrã. + Conceder permissão de localização + Permissão de localização negada. Ative nas definições para usar canais de localização. + Abrir definições + \u2713 Permissão de localização concedida + A verificar permissões… + A procurar canais próximos… + Marcado + Geohash + Geohash inválido + teletransportar + Desativar serviços de localização + Ativar serviços de localização + mesh + Quarteirão + Bairro + Cidade + Província + Região + #bluetooth • %1$s + + + Ferramentas de depuração + Ferramentas de programador para diagnóstico e controlo + registo detalhado + Regista entradas/saídas de pares, direção de conexão, retransmissão de pacotes e repetidores + Funções Bluetooth + Servidor GATT + Conexões: %1$d / %2$d + máx. servidor + Cliente GATT + máx. cliente + Conexões: %1$d / %2$d + máx. geral + Retransmissão de pacotes + desde o início: %1$d + Alternar funções ligado/desligado e fechar todas as conexões ao desativar + Definições de sincronização + máx. pacotes por sincronização: %1$d + máx. tamanho do filtro GCS: %1$d bytes (128–1024) + FPR alvo: %1$.2f%% + dispositivos ligados + nosso ID do dispositivo: %1$s + nenhum + desligar + dispositivos digitalizados recentemente + ligar + Consola de depuração + limpar + últimos 10s: %1$d • 1m: %2$d • 15m: %3$d + P derivado: %1$s • máx. itens estimados: %2$s + • direto + como servidor (nós hospedamos) + como cliente (nós ligamos) + + + Serviços de localização necessários + Privacidade primeiro + O bitchat NÃO rastreia a sua localização.\n\nOs serviços de localização são necessários para a digitalização Bluetooth e a funcionalidade de chat geohash. + O bitchat precisa de serviços de localização para: + • Digitalização de dispositivos Bluetooth\n• Descobrir utilizadores próximos na rede mesh\n• Funcionalidade de chat geohash\n• Sem rastreamento ou recolha de localização + Abrir definições de localização + Verificar novamente + Serviços de localização indisponíveis + Os serviços de localização não estão disponíveis neste dispositivo. Isto é incomum, uma vez que os serviços de localização são padrão em dispositivos Android.\n\nO bitchat requer serviços de localização para o funcionamento adequado da digitalização Bluetooth (requisito do Android). Sem isso, a aplicação não pode descobrir utilizadores próximos. + A verificar serviços de localização… + Bluetooth necessário + O bitchat precisa de Bluetooth para: + • Descobrir utilizadores próximos\n• Criar conexões de rede mesh\n• Enviar e receber mensagens\n• Funcionar sem internet ou servidores + Ativar Bluetooth + Bluetooth não suportado + Este dispositivo não suporta Bluetooth Low Energy (BLE), que é necessário para o bitchat funcionar.\n\nO BLE é necessário para criar redes mesh e comunicar com dispositivos próximos sem internet. + A verificar estado do Bluetooth… + Otimização da bateria detetada + Otimização da bateria ativada + O bitchat precisa de funcionar em segundo plano para manter conexões mesh. A otimização da bateria pode interromper essas conexões. + Benefícios de desativar + • entrega confiável de mensagens\n• mantém conectividade mesh\n• previne desconexões + Desativar otimização da bateria + Otimização da bateria desativada + Continuar + A inicializar rede mesh + A configurar rede mesh Bluetooth… + Isto deve demorar apenas alguns segundos + Configuração não concluída + Tentar novamente + A sua privacidade está protegida + • sem rastreamento ou recolha de dados\n• chats mesh Bluetooth são completamente offline\n• chats geohash usam internet + Permissões + Conceder permissões + O bitchat NÃO rastreia a sua localização + ninguém por perto… + (você) + arraste e amplie para selecionar um geohash + selecionar + Digite uma mensagem… + mencionar + v%1$s + image/* + Imagem + Ficheiro + + + 📷 enviou uma imagem + 🎤 enviou uma nota de voz + 📎 enviou um ficheiro + + + Reproduzir + Pausar + + + Necessário para descobrir utilizadores bitchat via Bluetooth + Permitir que o bitchat se ligue a dispositivos próximos + Requerido pelo Android para descobrir utilizadores bitchat próximos via Bluetooth + O bitchat precisa disto para digitalizar dispositivos próximos + Receber notificações quando recebe mensagens privadas + Permitir que o bitchat lhe envie notificações + Desativar otimização da bateria para garantir que o bitchat funcione de forma confiável em segundo plano e mantenha conexões de rede mesh + Permitir que o bitchat funcione sem restrições de bateria + + + Dispositivos próximos + Localização precisa + Microfone + Notificações + Otimização da bateria + Outro + + + Inserir palavra-passe do canal + O canal %1$s está protegido por palavra-passe. Insira a palavra-passe para entrar. + Palavra-passe + Entrar + Cancelar + + + + e mais %d + e mais %d + + + + 1 pessoa + %d pessoas + + + + #%1$s ± 1 • %2$d nota + #%1$s ± 1 • %2$d notas + + adicione notas curtas e permanentes a este local para que outros visitantes as encontrem. + relés geográficos indisponíveis; notas em pausa + nenhum relé geográfico nas proximidades + as notas dependem de relés geográficos. verifique a conexão e tente novamente. + carregando notas… + ainda não há notas + seja o primeiro a adicionar uma para este local. + fechar + adicione uma nota para este local + Bluetooth recomendado + Mesh em execução — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s + Abrir Sobre + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler + Idioma + Idioma da aplicação + Predefinição do sistema + Selecionar idioma diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 37996bc9..02b1175d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,20 +1,383 @@ bitchat - Для обмена сообщениями по одноранговой сети без подключения к Интернету требуется разрешение на Bluetooth. - Для обнаружения устройств поблизости через Bluetooth требуется разрешение на определение местоположения. - Разрешение на уведомления требуется для оповещения о новых сообщениях. - Псевдоним - Введите сообщение… + Требуется разрешение Bluetooth для общения p2p без интернета. + Требуется доступ к геопозиции для поиска ближайших устройств по Bluetooth. + Требуется доступ к уведомлениям для оповещения о новых сообщениях. + ник + введите сообщение… Пароль - Присоединиться к каналу + Войти в канал Покинуть Отправить + Показать команды Назад Люди Каналы Пользователи онлайн - Никто не подключен - Трижды нажмите, чтобы очистить все данные - Ваша сеть + Нет подключённых + Тройной тап для удаления всех данных + Сеть + + Обнаружена оптимизация батареи + Оптимизация батареи отключена + Оптимизация батареи не требуется + Проверка оптимизации батареи + Зачем отключать оптимизацию? + bitchat работает в фоне для поддержания mesh‑подключений с ближайшими устройствами. Оптимизация батареи может их прервать, что ведёт к задержкам и потерям сообщений.\n\nОтключение гарантирует надёжный обмен p2p. + Отключить оптимизацию + Примечание: это можно изменить позже в Настройки Android > Приложения > bitchat > Батарея + На вашем устройстве не требуются настройки оптимизации. bitchat будет работать штатно. + На вашем устройстве не требуются настройки оптимизации. bitchat будет работать штатно. + bitchat может надёжно работать в фоне + • Надёжная доставка сообщений\n• Поддержка mesh‑связи\n• Фоновая ретрансляция\n• Предотвращение обрывов связи + Проверить снова + Пропустить пока + Продолжить + Повторить + Пропустить + + и ещё %1$d + %1$d сообщений от %2$d людей + и ещё %1$d бесед + bitchatter рядом! + Рядом 1 человек + Рядом %1$d человек + Новые сообщения + Новые сообщения локаций + Упоминание в %1$s + Упоминание в %1$s (+%2$d) + %1$d упоминаний в %2$s + Новая активность в %1$s + Сообщения в %1$s + %1$s присоединился к беседе + bitchat — %1$d упоминаний + bitchat — чаты по локациям + %1$d сообщений из %2$d локаций + Упоминание в Mesh‑чате + %1$d упоминаний в Mesh‑чате + и ещё %1$d локаций + и ещё %1$d + + В избранное + Убрать из избранного + Добавить закладку + + Доступен через Nostr + Непрочитанные личные + Телепортировано + Открыть настройки местоположения и каналов + Подключённые узлы + Участники geohash + Готов к рукопожатию + Рукопожатие… + Сквозное шифрование + Не удалось договориться + + 📎 Получен файл + 📄 %1$s + 📏 Размер: %1$s + 🏷️ Тип: %1$s + 📂 Открыть / Сохранить + ❌ Закрыть + Выбрать изображение + + Оффлайн Mesh‑чат + Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами. + Онлайн‑каналы Geohash + Общайся с людьми поблизости через каналы на основе geohash. Расширяй mesh через публичные ретрансляторы. + Сквозное шифрование + Личные сообщения — зашифрованы. Канальные — публичны. + система + светлая + тёмная + proof of work + pow выкл + pow вкл + добавь proof of work к сообщениям geohash для защиты от спама. + сложность: %1$d бит (~%2$s) + сложность %1$d ≈ ~%2$s попыток хеширования + proof of work не требуется + очень низкая — минимальная защита + низкая — базовая защита + средняя — хорошая защита + высокая — сильная защита + очень высокая — возможны задержки + экстремальная — требуется много вычислений + сеть + tor выкл + tor вкл + направляй интернет через Tor для приватности. + статус Tor: %1$s, загрузка %2$d%% + Последнее: %1$s + Экстренное удаление данных + Настройки отладки + Open Source • Privacy First • Децентрализация + Закрыть + отмена + + Предупреждение + Службы локации + Приватность + Ошибка + Оптимизация батареи + Преимущества + Проверка оптимизации + Не поддерживается + Bluetooth + Непрочитанное сообщение + Открыть карту + Удалить закладку + Телепорт + Покинуть канал + Доступен через Nostr + Избранное (оффлайн) + Уменьшить точность + Увеличить точность + Выбрать geohash + Прокрутить вниз + Файл + Изображение + Отмена + Ссылка + Выбрать медиа + Записать голос + Оффлайн Mesh‑чат + Онлайн‑каналы Geohash + Сквозное шифрование + + Изображение %1$d / %2$d + Изображение недоступно + Изображение сохранено в «Загрузки» + Не удалось сохранить изображение + Выбрать файл + [файл недоступен] + неизвестно + + выберите действие для сообщения или пользователя + выберите действие для пользователя + копировать сообщение + скопировать сообщение в буфер + дать пощёчину %1$s + отправить шутливую пощёчину + обнять %1$s + отправить дружеские объятия + заблокировать %1$s + заблокировать все сообщения от этого пользователя + + #каналы локации + общайся с людьми рядом через каналы geohash. делится только грубый geohash, никогда точный gps. не делай скриншоты и не делись экраном для защиты приватности. + выдать доступ к локации + доступ к локации запрещён. включите в настройках для использования каналов. + Открыть настройки + \u2713 доступ к локации выдан + проверка разрешений… + поиск близких каналов… + в закладках + geohash + некорректный geohash + телепорт + отключить службы локации + включить службы локации + #bluetooth • %1$s + mesh + квартал + район + город + область + регион + + инструменты отладки + подробное логирование + роли Bluetooth + сервер GATT + подключения: %1$d / %2$d + макс. сервер + клиент GATT + макс. клиент + подключения: %1$d / %2$d + макс. всего + ретрансляция пакетов + с начала: %1$d + настройки синхр. + макс. пакетов на синхр.: %1$d + макс. размер фильтра GCS: %1$d байт (128–1024) + целевой FPR: %1$.2f%% + подключённые устройства + ID нашего устройства: %1$s + нет + отключить + последние результаты сканирования + подключить + консоль отладки + очистить + посл. 10с: %1$d • 1м: %2$d • 15м: %3$d + выведенный P: %1$s • оцен. макс. элементов: %2$s + • напрямую + ? + как сервер (мы хостим) + как клиент (мы подключаемся) + + Требуются службы локации + Приватность прежде всего + bitchat НЕ отслеживает вашу геопозицию.\n\nСлужбы локации нужны для сканирования Bluetooth и функции чата Geohash. + bitchat нужны службы локации для: + • Сканирования устройств Bluetooth\n• Поиска ближайших пользователей в mesh\n• Функции чата Geohash\n• Без отслеживания и сбора геоданных + Открыть настройки локации + Проверить снова + Службы локации недоступны + На этом устройстве службы локации недоступны — это необычно.\n\nОни требуются Android для корректной работы сканирования Bluetooth. Иначе невозможно обнаружить ближайших пользователей. + Проверка служб локации… + Требуется Bluetooth + bitchat нужен Bluetooth для: + • Обнаружения ближайших пользователей\n• Создания mesh‑подключений\n• Отправки и получения сообщений\n• Работы без интернета/серверов + Включить Bluetooth + Bluetooth не поддерживается + Это устройство не поддерживает Bluetooth Low Energy (BLE), необходимый для работы bitchat.\n\nBLE нужен для построения mesh и обмена с ближайшими устройствами без интернета. + Проверка статуса Bluetooth… + обнаружена оптимизация батареи + Оптимизация включена + bitchat должен работать в фоне для mesh; оптимизация может прерывать. + Преимущества отключения + • надёжная доставка\n• поддержка связности\n• предотвращает обрывы + Отключить оптимизацию + оптимизация отключена + Продолжить + Инициализация mesh‑сети + . + Настройка Bluetooth mesh… + Займёт лишь несколько секунд + ⚠️ + Настройка не завершена + Повторить + Ваша приватность защищена + • без трекинга и сбора данных\n• Bluetooth‑mesh чаты полностью оффлайн\n• Geohash‑чаты используют интернет + разрешения + Выдать разрешения + bitchat НЕ отслеживает вашу геопозицию + @ + · ⧉ + никого рядом… + (вы) + прокрутите/масштабируйте для выбора geohash + выбрать + введите сообщение… + @%1$s + упоминание + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Изображение + Файл + + 📷 отправил изображение + 🎤 отправил голосовое + 📎 отправил файл + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + Воспроизвести + Пауза + + Нужно для поиска пользователей bitchat по Bluetooth + Разрешить bitchat подключаться к ближайшим устройствам + Требуется Android для поиска по Bluetooth + bitchat нужно для сканирования ближайших устройств + Получать уведомления о личных сообщениях + Разрешить bitchat отправлять вам уведомления + Отключить оптимизацию, чтобы bitchat работал в фоне и поддерживал mesh‑связь + Разрешить работу без ограничений батареи + + Ближайшие устройства + Точная геопозиция + Микрофон + Уведомления + Оптимизация батареи + Другое + + Введите пароль канала + Канал %1$s защищён паролем. Введите пароль, чтобы присоединиться. + Пароль + Войти + Отмена + + + и ещё %d + и ещё %d + + + + 1 человек + %d человек + + + + #%1$s ± 1 • %2$d заметка + #%1$s ± 1 • %2$d заметок + + добавьте короткие постоянные заметки к этому месту, чтобы другие посетители могли их найти. + гео-релей недоступны; заметки приостановлены + рядом нет гео-релеев + заметки зависят от гео-релеев. проверьте подключение и повторите попытку. + загрузка заметок… + заметок пока нет + станьте первым, кто добавит заметку для этого места. + закрыть + добавьте заметку для этого места + Рекомендуется Bluetooth + Mesh запущен — %1$d пиров + + проверить + сканируй для проверки + сканировать чужой QR + сканировать чужой QR + показать мой QR + удалить проверку + QR недоступен + нужен доступ к камере + включить камеру + вставить URL проверки + проверить + запрос проверки + проверка безопасности + их отпечаток + ваш отпечаток + ожидание рукопожатия + откройте личный чат + шифровано и проверено + шифровано + рукопожатие + сбой рукопожатия + не шифровано + проверено + вы проверили этого человека. + не проверено + сравните с %1$s. + отметить проверенным + начать рукопожатие + копировать + Взаимная проверка + Вы и %1$s проверили друг друга + взаимная проверка с %1$s + Проверено + Вы проверили %1$s + проверен %1$s + Открыть раздел «О приложении» + проверить, есть ли здесь заметки + здесь оставлена 1 заметка — нажмите, чтобы прочитать + здесь оставлено заметок: %d — нажмите, чтобы прочитать + Язык + Язык приложения + Системный язык + Выбрать язык diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml new file mode 100644 index 00000000..b958faad --- /dev/null +++ b/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,381 @@ + + + bitchat + Bluetooth-behörighet krävs för direktmeddelanden utan internet. + Platsbehörighet krävs för att hitta enheter i närheten via Bluetooth. + Aviseringsbehörighet krävs för att meddela nya meddelanden. + smeknamn + skriv ett meddelande… + Lösenord + Gå med i kanal + Lämna + Skicka + Visa kommandon + Tillbaka + Personer + Kanaler + Användare online + Ingen ansluten + Tryck tre gånger för att rensa all data + Nätverk + + Batterioptimering upptäckt + Batterioptimering inaktiverad + Batterioptimering krävs inte + Kontrollerar batterioptimering + Varför inaktivera? + bitchat körs i bakgrunden för att upprätthålla mesh-anslutningar till enheter i närheten. Batterioptimering kan avbryta dessa och orsaka fördröjda eller missade meddelanden.\n\nAtt inaktivera säkerställer tillförlitlig P2P-meddelandetrafik. + Inaktivera optimering + Obs: kan ändras senare i Android-inställningar > Appar > bitchat > Batteri + Din enhet kräver inte batterioptimeringsinställningar. bitchat körs normalt. + Din enhet kräver inte batterioptimeringsinställningar. bitchat körs normalt. + bitchat kan köras tillförlitligt i bakgrunden + • Tillförlitlig leverans\n• Upprätthåller mesh-anslutning\n• Bakgrundsrelä\n• Förhindrar avbrott + Kontrollera igen + Hoppa över nu + Fortsätt + Försök igen + Hoppa över + + och %1$d till + %1$d meddelanden från %2$d personer + och %1$d fler konversationer + bitchatter i närheten! + 1 person i närheten + %1$d personer i närheten + Nya meddelanden + Nya platsmeddelanden + Nämnd i %1$s + Nämnd i %1$s (+%2$d till) + %1$d nämningar i %2$s + Ny aktivitet i %1$s + Meddelanden i %1$s + %1$s gick med i konversationen + bitchat – %1$d nämningar + bitchat – platschattar + %1$d meddelanden från %2$d platser + Nämnd i Mesh‑chatt + %1$d nämningar i Mesh‑chatt + och %1$d fler platser + och %1$d till + + Lägg till som favorit + Ta bort favorit + Lägg till bokmärke + + Nås via Nostr + Olästa privata meddelanden + Teleporterad + Öppna plats- och kanalinställningar + Anslutna noder + Geohash‑deltagare + Redo för handshake + Handshake pågår + Ända‑till‑ända‑krypterat + Handshake misslyckades + + 📎 Fil mottagen + 📄 %1$s + 📏 Storlek: %1$s + 🏷️ Typ: %1$s + 📂 Öppna / Spara + ❌ Stäng + Välj bild + + Offline Mesh‑chatt + Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten. + Online Geohash‑kanaler + Anslut med personer i närheten via geohash‑kanaler. Utöka mesh via publika internetreläer. + Ända‑till‑ända‑kryptering + Privata meddelanden är krypterade. Kanalmeddelanden är publika. + system + ljus + mörk + proof of work + pow av + pow på + lägg till proof of work i geohash‑meddelanden för att hindra spam. + svårighet: %1$d bitar (~%2$s) + svårighet %1$d kräver ~%2$s hash‑försök + inget proof of work krävs + mycket låg – minimal skydd + låg – grundläggande skydd + medel – bra skydd + hög – starkt skydd + mycket hög – kan orsaka fördröjning + extrem – kräver mycket beräkning + nätverk + tor av + tor på + routa internet via Tor för ökad integritet. + Tor‑status: %1$s, bootstrap %2$d%% + Senast: %1$s + Nödradering av data + Felsökningsinställningar + Open Source • Privacy First • Decentraliserad + Stäng + avbryt + + Varning + Plats­tjänster + Integritet + Fel + Batterioptimering + Fördelar + Kontrollerar optimering + Stöds ej + Bluetooth + Oläst meddelande + Öppna karta + Ta bort bokmärke + Teleportera + Lämna kanal + Nås via Nostr + Favorit (offline) + Minska precision + Öka precision + Välj geohash + Rulla längst ned + Fil + Bild + Avbryt + Länk + Välj media + Spela in röstanteckning + Offline Mesh‑chatt + Online Geohash‑kanaler + Ända‑till‑ända‑kryptering + + Bild %1$d / %2$d + Bild ej tillgänglig + Bild sparad i "Hämtade filer" + Kunde inte spara bild + Välj fil + [fil ej tillgänglig] + okänd + + välj en åtgärd för meddelandet eller användaren + välj en åtgärd för den här användaren + kopiera meddelande + kopiera meddelandet till urklipp + ge %1$s en örfil + skicka en skämtsam örfil + krama %1$s + skicka en vänlig kram + blockera %1$s + blockera alla meddelanden från den här användaren + + #platskanaler + chatta med folk nära dig via geohash‑kanaler. endast en grov geohash delas, aldrig exakt gps. ta inte skärmdumpar eller dela skärmen för att skydda din integritet. + ge platsbehörighet + platsbehörighet nekades. aktivera i inställningar för att använda platskanaler. + Öppna inställningar + \u2713 platsbehörighet beviljad + kontrollerar behörigheter… + söker efter kanaler i närheten… + bokmärkt + geohash + ogiltig geohash + teleportera + inaktivera platstjänster + aktivera platstjänster + #bluetooth • %1$s + mesh + kvarter + grannskap + stad + län + region + + felsökningsverktyg + utförlig loggning + Bluetooth‑roller + GATT‑server + anslutningar: %1$d / %2$d + max server + GATT‑klient + max klient + anslutningar: %1$d / %2$d + max totalt + paketrelä + sedan start: %1$d + synkinställningar + max paket per synk: %1$d + max GCS‑filterstorlek: %1$d byte (128–1024) + mål‑FPR: %1$.2f%% + anslutna enheter + vår enhets‑ID: %1$s + ingen + koppla från + senaste skanningsresultat + anslut + felsökningskonsol + rensa + senaste 10 s: %1$d • 1 m: %2$d • 15 m: %3$d + härlett P: %1$s • uppsk. max element: %2$s + • direkt + ? + som server (vi hostar) + som klient (vi ansluter) + + Platstjänster krävs + Integritet först + bitchat SPÅRAR INTE din plats.\n\nPlatstjänster krävs för Bluetooth‑skanning och Geohash‑chatt. + bitchat behöver platstjänster för: + • Bluetooth‑skanning av enheter\n• Hitta användare i närheten i mesh‑nätet\n• Geohash‑chatt\n• Ingen spårning eller insamling + Öppna platsinställningar + Kontrollera igen + Platstjänster inte tillgängliga + Platstjänster saknas på den här enheten. Det är ovanligt.\n\nAndroid kräver dem för att Bluetooth‑skanning ska fungera. Utan dem kan vi inte hitta användare i närheten. + Kontrollerar platstjänster… + Bluetooth krävs + bitchat behöver Bluetooth för: + • Hitta användare i närheten\n• Skapa mesh‑anslutningar\n• Skicka och ta emot meddelanden\n• Fungera utan internet/ servrar + Aktivera Bluetooth + Bluetooth stöds inte + Denna enhet stöder inte Bluetooth Low Energy (BLE), vilket krävs för bitchat.\n\nBLE behövs för att bygga mesh och kommunicera utan internet. + Kontrollerar Bluetooth‑status… + batterioptimering upptäckt + Batterioptimering på + bitchat måste köra i bakgrunden för mesh; optimering kan bryta. + Fördelar med inaktivering + • tillförlitlig leverans\n• håller anslutning\n• förhindrar avbrott + Inaktivera optimering + optimering av + Fortsätt + Initierar mesh‑nätverk + . + Ställer in Bluetooth‑mesh… + Det tar bara några sekunder + ⚠️ + Slutför inte inställningen + Försök igen + Din integritet är skyddad + • ingen spårning eller insamling\n• Bluetooth‑mesh‑chattar helt offline\n• geohash‑chattar använder internet + behörigheter + Ge behörigheter + bitchat spårar INTE din plats + @ + · ⧉ + ingen i närheten… + (du) + panorera och zooma för att välja geohash + välj + skriv ett meddelande… + @%1$s + nämn + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Bild + Fil + + 📷 skickade en bild + 🎤 skickade ett röstmeddelande + 📎 skickade en fil + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + Spela + Pausa + + Krävs för att hitta bitchat‑användare via Bluetooth + Tillåt bitchat att ansluta till enheter i närheten + Krävs av Android för att hitta användare via Bluetooth + bitchat behöver detta för att skanna enheter + Ta emot aviseringar vid privata meddelanden + Tillåt bitchat att skicka aviseringar + Inaktivera batterioptimering för tillförlitlig drift i bakgrunden och mesh‑anslutning + Tillåt körning utan batteribegränsningar + + Enheter i närheten + Exakt plats + Mikrofon + Aviseringar + Batterioptimering + Övrigt + + Ange kanalens lösenord + Kanal %1$s är lösenordsskyddad. Ange lösenordet för att gå med. + Lösenord + Gå med + Avbryt + + + och %d till + och %d till + + + 1 person + %d personer + + + #%1$s ± 1 • %2$d anteckning + #%1$s ± 1 • %2$d anteckningar + + lägg till korta permanenta anteckningar för den här platsen så att andra besökare kan hitta dem. + geo-reläer är inte tillgängliga; anteckningar pausade + inga geo-reläer i närheten + anteckningar är beroende av geo-reläer. kontrollera anslutningen och försök igen. + laddar anteckningar… + inga anteckningar ännu + var först med att lägga till en anteckning för den här platsen. + stäng + lägg till en anteckning för den här platsen + Bluetooth rekommenderas + Mesh körs — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + Öppna Om + kolla om anteckningar lämnats här + 1 anteckning lämnad här — tryck för att läsa + %d anteckningar lämnade här — tryck för att läsa + Språk + Appspråk + Systemstandard + Välj språk + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml new file mode 100644 index 00000000..243f2112 --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,393 @@ + + + + Skip + Bluetooth Recommended + மெஷ் இயங்குகிறது — %1$d பியர்ஸ் + + சரிபார் + என்னை சரிபார்க்க ஸ்கேன் செய்யவும் + மற்றவரின் QR குறியீட்டை ஸ்கேன் செய்யவும் + மற்றவரின் QR குறியீட்டை ஸ்கேன் செய்யவும் + எனது QR குறியீட்டைக் காட்டு + சரிபார்ப்பை அகற்று + QR குறியீடு கிடைக்கவில்லை + QR குறியீடுகளை ஸ்கேன் செய்ய கேமரா அனுமதி தேவை + கேமராவை இயக்கு + சரிபார்ப்பு இணைப்பை ஒட்டவும் + சரிபார் + சரிபார்ப்பு கோரப்பட்டது + பாதுகாப்பு சரிபார்ப்பு + அவர்களின் கைரேகை + உங்கள் கைரேகை + கைகுலுக்கல் நிலுவையில் உள்ளது + கைரேகைகளைப் பார்க்க ஒரு தனிப்பட்ட உரையாடலைத் திறக்கவும் + குறியாக்கம் செய்யப்பட்டு சரிபார்க்கப்பட்டது + குறியாக்கம் செய்யப்பட்டது + கைகுலுக்கல் நடைபெறுகிறது + கைகுலுக்கல் தோல்வியடைந்தது + குறியாக்கம் செய்யப்படவில்லை + சரிபார்க்கப்பட்டது + இந்த நபரின் அடையாளத்தை நீங்கள் சரிபார்த்துவிட்டீர்கள். + சரிபார்க்கப்படவில்லை + பாதுகாப்பான சேனலைப் பயன்படுத்தி இந்த கைரேகைகளை %1$s உடன் ஒப்பிடவும். + சரிபார்க்கப்பட்டதாகக் குறி + கைகுலுக்கலைத் தொடங்கு + நகலெடு + பரஸ்பர சரிபார்ப்பு + நீங்களும் %1$s உம் ஒருவரையொருவர் சரிபார்த்துக்கொண்டீர்கள் + %1$s உடன் பரஸ்பர சரிபார்ப்பு + சரிபார்க்கப்பட்டது + நீங்கள் %1$s ஐ சரிபார்த்துவிட்டீர்கள் + %1$s சரிபார்க்கப்பட்டது + + bitchat + இணையம் இல்லாமல் பியர்-டு-பியர் செய்திகள் அனுப்ப Bluetooth அனுமதி தேவை. + Bluetooth வழியாக அருகிலுள்ள சாதனங்களைக் கண்டறிய இருப்பிட அனுமதி தேவை. + புதிய செய்திகளை உங்களுக்குத் தெரிவிக்க அறிவிப்பு அனுமதி தேவை. + புனைப்பெயர் + செய்தியை உள்ளிடவும்… + கடவுச்சொல் + சேனலில் சேரவும் + வெளியேறு + அனுப்பு + கட்டளைகளைக் காட்டு + பின் + நபர்கள் + சேனல்கள் + ஆன்லைன் பயனர்கள் + யாரும் இணைக்கப்படவில்லை + அனைத்து தரவையும் அழிக்க மூன்று முறை தட்டவும் + நெட்வொர்க் + பேட்டரி ஆப்டிமைசேஷன் கண்டறியப்பட்டது + பேட்டரி ஆப்டிமைசேஷன் முடக்கப்பட்டது + பேட்டரி ஆப்டிமைசேஷன் தேவையில்லை + பேட்டரி ஆப்டிமைசேஷனைச் சரிபார்க்கிறது + பேட்டரி ஆப்டிமைசேஷனை ஏன் முடக்க வேண்டும்? + அருகிலுள்ள சாதனங்களுடன் மெஷ் நெட்வொர்க் இணைப்புகளைப் பராமரிக்க bitchat பின்னணியில் இயங்குகிறது. பேட்டரி ஆப்டிமைசேஷன் இந்த இணைப்புகளை தடுக்கலாம், இதனால் செய்திகள் தாமதமாகலாம் அல்லது தவறவிடப்படலாம்.\n\nபேட்டரி ஆப்டிமைசேஷனை முடக்குவது நம்பகமான பியர்-டு-பியர் செய்தி அனுப்புதலை உறுதி செய்கிறது. + பேட்டரி ஆப்டிமைசேஷனை முடக்கு + குறிப்பு: Android அமைப்புகள் > ஆப்ஸ் > bitchat > பேட்டரி என்பதில் இந்த அமைப்பை பின்னர் மாற்றலாம் + உங்கள் சாதனத்திற்கு பேட்டரி ஆப்டிமைசேஷன் அமைப்புகள் தேவையில்லை. bitchat சாதாரணமாக இயங்கும். + உங்கள் சாதனத்திற்கு பேட்டரி ஆப்டிமைசேஷன் அமைப்புகள் தேவையில்லை. bitchat சாதாரணமாக இயங்கும். + bitchat பின்னணியில் நம்பகமாக இயங்க முடியும் + • நம்பகமான செய்தி வழங்கலை உறுதி செய்கிறது\n• மெஷ் நெட்வொர்க் இணைப்பைப் பராமரிக்கிறது\n• பின்னணி செய்தி அனுப்புதலை அனுமதிக்கிறது\n• இணைப்பு துண்டிப்புகளைத் தடுக்கிறது + மீண்டும் சரிபார்க்கவும் + இப்போதைக்கு தவிர் + தொடரவும் + மீண்டும் முயற்சி + மற்றும் %1$d மேலும் + %2$d நபர்களிடமிருந்து %1$d செய்திகள் + மற்றும் %1$d மேலும் உரையாடல்கள் + 👥 அருகில் bitchatters! + 1 நபர் அருகில் உள்ளார் + %1$d நபர்கள் அருகில் உள்ளனர் + புதிய செய்திகள் + புதிய இருப்பிட செய்திகள் + %1$s இல் குறிப்பிடப்பட்டது + %1$s இல் குறிப்பிடப்பட்டது (+%2$d மேலும்) + %2$s இல் %1$d குறிப்புகள் + %1$s இல் புதிய செயல்பாடு + %1$s இல் செய்திகள் + %1$s உரையாடலில் சேர்ந்தார் + bitchat - %1$d குறிப்புகள் + bitchat - இருப்பிட அரட்டைகள் + %2$d இருப்பிடங்களிலிருந்து %1$d செய்திகள் + மெஷ் அரட்டையில் குறிப்பிடப்பட்டது + மெஷ் அரட்டையில் %1$d குறிப்புகள் + மற்றும் %1$d மேலும் இருப்பிடங்கள் + + மற்றும் %d மேலும் + மற்றும் %d மேலும் + + + 1 நபர் + %d நபர்கள் + + மெஷ் பின்னணி சேவை + Bluetooth மெஷை பின்னணியில் இயங்க வைக்கிறது + பிடித்தவைகளில் சேர் + பிடித்தவைகளிலிருந்து அகற்று + புக்மார்க் சேர் + Nostr அணுகக்கூடியது + படிக்காத தனிப்பட்ட செய்திகள் + இருப்பிட குறிப்புகள் + டெலிபோர்ட் செய்யப்பட்டது + இருப்பிடம் மற்றும் சேனல் அமைப்புகளைத் திற + இணைக்கப்பட்ட பியர்ஸ் + ஜியோஹாஷ் பங்கேற்பாளர்கள் + ஹேண்ட்ஷேக்கிற்கு தயார் + ஹேண்ட்ஷேக் நடைபெறுகிறது + எண்ட்-டு-எண்ட் என்க்ரிப்ட் செய்யப்பட்டது + ஹேண்ட்ஷேக் தோல்வியடைந்தது + 📎 கோப்பு பெறப்பட்டது + 📄 %1$s + 📏 அளவு: %1$s + 🏷️ வகை: %1$s + 📂 திற / சேமி + ❌ மூடு + படத்தைத் தேர்ந்தெடு + தற்போதைய படத்தைச் சேமி + மூடு + படம் பதிவிறக்கங்களில் சேமிக்கப்பட்டது + படத்தைச் சேமிக்க முடியவில்லை + படம் %1$d / %2$d + கோப்பைத் தேர்ந்தெடு + எண்ட்-டு-எண்ட் என்க்ரிப்ஷனுடன் கூடிய பரவலாக்கப்பட்ட மெஷ் செய்தியிடல் + ஆஃப்லைன் மெஷ் அரட்டை + இணையம் அல்லது சர்வர்கள் இல்லாமல் Bluetooth LE வழியாக நேரடியாக தொடர்பு கொள்ளுங்கள். வரம்பை நீட்டிக்க செய்திகள் அருகிலுள்ள சாதனங்கள் வழியாக அனுப்பப்படுகின்றன. + ஆன்லைன் ஜியோஹாஷ் சேனல்கள் + ஜியோஹாஷ் அடிப்படையிலான சேனல்களைப் பயன்படுத்தி உங்கள் பகுதியிலுள்ள மக்களுடன் இணையுங்கள். பொது இணைய ரிலேகளைப் பயன்படுத்தி மெஷை நீட்டிக்கவும். + எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் + தனிப்பட்ட செய்திகள் என்க்ரிப்ட் செய்யப்பட்டவை. சேனல் செய்திகள் பொதுவானவை. + சிஸ்டம் + ஒளி + இருள் + ப்ரூஃப் ஆஃப் வொர்க் + pow ஆஃப் + pow ஆன் + ஸ்பேம் தடுப்புக்காக ஜியோஹாஷ் செய்திகளுக்கு ப்ரூஃப் ஆஃப் வொர்க் சேர்க்கவும். + சிரமம்: %1$d பிட்ஸ் (~%2$s) + சிரமம் %1$d க்கு ~%2$s ஹாஷ் முயற்சிகள் தேவை + ப்ரூஃப் ஆஃப் வொர்க் தேவையில்லை + மிகக் குறைவு - குறைந்தபட்ச ஸ்பேம் பாதுகாப்பு + குறைவு - அடிப்படை ஸ்பேம் பாதுகாப்பு + நடுத்தரம் - நல்ல ஸ்பேம் பாதுகாப்பு + அதிகம் - வலுவான ஸ்பேம் பாதுகாப்பு + மிக அதிகம் - தாமதங்களை ஏற்படுத்தலாம் + தீவிரம் - கணிசமான கணக்கீடு தேவை + நெட்வொர்க் + tor ஆஃப் + tor ஆன் + மேம்பட்ட தனியுரிமைக்காக இணையத்தை Tor வழியாக அனுப்பவும். + tor நிலை: %1$s, பூட்ஸ்ட்ராப் %2$d%% + கடைசி: %1$s + அவசர தரவு நீக்கம் + பிழைதிருத்த அமைப்புகள் + ஓபன் சோர்ஸ் • தனியுரிமை முதலில் • பரவலாக்கப்பட்டது + மூடு + தனியுரிமை பாதுகாக்கப்பட்டது + ரத்து செய் + எச்சரிக்கை + இருப்பிட சேவைகள் + தனியுரிமை + பிழை + பேட்டரி ஆப்டிமைசேஷன் + நன்மைகள் + பேட்டரி ஆப்டிமைசேஷனைச் சரிபார்க்கிறது + பேட்டரி ஆப்டிமைசேஷன் ஆதரிக்கப்படவில்லை + Bluetooth + படிக்காத செய்தி + வரைபடத்தைத் திற + புக்மார்க்கை அகற்று + டெலிபோர்ட் + bitchat ஐ வெளியேறு + பின்னணியில் இயக்கு + ஆப் மூடப்பட்டாலும் மெஷை செயலில் வைத்திரு (முன்னணி சேவை) + சேனலை விட்டு வெளியேறு + Nostr வழியாக அணுகக்கூடியது + ஆஃப்லைன் பிடித்தவை + துல்லியத்தைக் குறை + துல்லியத்தை அதிகரி + ஜியோஹாஷைத் தேர்ந்தெடு + கீழே ஸ்க்ரோல் செய் + கோப்பு + படம் + ரத்து செய் + இணைப்பு + குரல் குறிப்பை பதிவு செய் + மீடியாவைத் தேர்ந்தெடு + ஆஃப்லைன் மெஷ் அரட்டை + ஆன்லைன் ஜியோஹாஷ் சேனல்கள் + எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் + #bluetooth • %1$s + படம் %1$d / %2$d + படம் கிடைக்கவில்லை + படம் பதிவிறக்கங்களில் சேமிக்கப்பட்டது + படத்தைச் சேமிக்க முடியவில்லை + கோப்பைத் தேர்ந்தெடு + [கோப்பு கிடைக்கவில்லை] + தெரியாதது + இந்த செய்தி அல்லது பயனருக்கான செயலைத் தேர்ந்தெடு + இந்த பயனருக்கான செயலைத் தேர்ந்தெடு + செய்தியை நகலெடு + இந்த செய்தியை கிளிப்போர்டுக்கு நகலெடு + %1$s ஐ அறை + ஒரு விளையாட்டான அறை செய்தியை அனுப்பு + %1$s ஐ கட்டியணை + ஒரு நட்பான கட்டியணைப்பு செய்தியை அனுப்பு + %1$s ஐ தடு + இந்த பயனரிடமிருந்து அனைத்து செய்திகளையும் தடு + %1$s க்கு செய்தி அனுப்பு + ஒரு தனிப்பட்ட செய்தியை அனுப்பு + #இருப்பிட சேனல்கள் + ஜியோஹாஷ் சேனல்களைப் பயன்படுத்தி உங்களுக்கு அருகிலுள்ளவர்களுடன் அரட்டையடியுங்கள். ஒரு தோராயமான ஜியோஹாஷ் மட்டுமே பகிரப்படும், துல்லியமான ஜிபிஎஸ் ஒருபோதும் இல்லை. உங்கள் தனியுரிமையைப் பாதுகாக்க இந்த திரையை ஸ்க்ரீன்ஷாட் எடுக்காதீர்கள் அல்லது பகிர வேண்டாம். + இருப்பிட அனுமதி வழங்கு + இருப்பிட அனுமதி மறுக்கப்பட்டது. இருப்பிட சேனல்களைப் பயன்படுத்த அமைப்புகளில் இயக்கவும். + ✓ இருப்பிட அனுமதி வழங்கப்பட்டது + அனுமதிகளைச் சரிபார்க்கிறது… + அருகிலுள்ள சேனல்களைக் கண்டறிகிறது… + புக்மார்க் செய்யப்பட்டது + ஜியோஹாஷ் + தவறான ஜியோஹாஷ் + டெலிபோர்ட் + இருப்பிட சேவைகளை முடக்கு + இருப்பிட சேவைகளை இயக்கு + மெஷ் + பிளாக் + அக்கம் + நகரம் + மாகாணம் + பிராந்தியம் + + #%1$s ± 1 • %2$d குறிப்பு + #%1$s ± 1 • %2$d குறிப்புகள் + + இந்த இருப்பிடத்திற்கு மற்ற பார்வையாளர்கள் காணும்படி குறுகிய நிரந்தர குறிப்புகளைச் சேர்க்கவும். + ஜியோ ரிலேகள் கிடைக்கவில்லை; குறிப்புகள் இடைநிறுத்தப்பட்டன + அருகில் ஜியோ ரிலேகள் இல்லை + குறிப்புகள் ஜியோ ரிலேகளை நம்பியுள்ளன. இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும். + குறிப்புகளை ஏற்றுகிறது… + இதுவரை குறிப்புகள் இல்லை + இந்த இடத்திற்கு முதலில் ஒன்றைச் சேர்க்கவும். + நிராகரி + இந்த இடத்திற்கான குறிப்பைச் சேர்க்கவும் + பிழைதிருத்த கருவிகள் + கண்டறிதல் மற்றும் கட்டுப்பாட்டிற்கான டெவலப்பர் கருவிகள் + விரிவான பதிவு + பியர் சேர்க்கை/வெளியேறுதல், இணைப்பு திசை, பாக்கெட் ரூட்டிங் மற்றும் ரிலேகளை பதிவு செய்கிறது + Bluetooth பாத்திரங்கள் + GATT சர்வர் + இணைப்புகள்: %1$d / %2$d + அதிகபட்ச சர்வர் + GATT கிளையன்ட் + அதிகபட்ச கிளையன்ட் + இணைப்புகள்: %1$d / %2$d + மொத்த அதிகபட்சம் + பாக்கெட் ரிலே + தொடங்கியதிலிருந்து: %1$d + பாத்திரங்களை ஆன்/ஆஃப் செய்து, முடக்கப்படும்போது அனைத்து இணைப்புகளையும் மூடு + ஒத்திசைவு அமைப்புகள் + ஒத்திசைவுக்கு அதிகபட்ச பாக்கெட்டுகள்: %1$d + அதிகபட்ச GCS ஃபில்டர் அளவு: %1$d பைட்டுகள் (128–1024) + இலக்கு FPR: %1$.2f%% + இணைக்கப்பட்ட சாதனங்கள் + எங்கள் சாதன ஐடி: %1$s + எதுவுமில்லை + துண்டி + சமீபத்திய ஸ்கேன் முடிவுகள் + இணை + பிழைதிருத்த கன்சோல் + அழி + கடைசி 10வி: %1$d • 1நி: %2$d • 15நி: %3$d + பெறப்பட்ட P: %1$s • மதிப்பிடப்பட்ட அதிகபட்ச உறுப்புகள்: %2$s + • நேரடி + RSSI: %1$s + ? + சர்வராக (நாங்கள் ஹோஸ்ட் செய்கிறோம்) + கிளையன்டாக (நாங்கள் இணைக்கிறோம்) + இருப்பிட சேவைகள் தேவை + தனியுரிமை முதலில் + bitchat உங்கள் இருப்பிடத்தை கண்காணிக்காது.\n\nBluetooth ஸ்கேனிங் மற்றும் ஜியோஹாஷ் அரட்டை அம்சத்திற்கு இருப்பிட சேவைகள் தேவை. + bitchat க்கு இருப்பிட சேவைகள் தேவைப்படுவது: + • Bluetooth சாதன ஸ்கேனிங்\n• மெஷ் நெட்வொர்க்கில் அருகிலுள்ள பயனர்களைக் கண்டறிதல்\n• ஜியோஹாஷ் அரட்டை அம்சம்\n• கண்காணிப்பு அல்லது இருப்பிட சேகரிப்பு இல்லை + பின்னணி இருப்பிடம் பரிந்துரைக்கப்படுகிறது + விருப்பத்தேர்வு, மெஷ் நம்பகத்தன்மையை மேம்படுத்துகிறது + ஆப் திறக்கப்படாதபோதும் அருகிலுள்ள சாதனங்களை ஸ்கேன் செய்ய bitchat க்கு பின்னணி இருப்பிடத்தை Android பரிந்துரைக்கிறது. இது ரீபூட்டிற்குப் பிறகும் மெஷை உயிருடன் வைத்திருக்கும். + அமைப்புகள் திறக்கும்போது, "எப்போதும் அனுமதிக்கவும்" என்பதைத் தேர்ந்தெடுக்கவும். + bitchat பின்னணி இருப்பிடத்தைப் பயன்படுத்துவது: + - ஆப் மூடப்பட்டிருக்கும்போது அருகிலுள்ள சாதனங்களை ஸ்கேன் செய்ய\n- ரீபூட்டிற்குப் பிறகு மீண்டும் இணைக்க\n- மெஷை பின்னணியில் இயங்க வைக்க + உங்கள் இருப்பிடத்தை நாங்கள் ஒருபோதும் சேகரிக்கவோ சேமிக்கவோ மாட்டோம். உங்கள் தனியுரிமை பாதுகாப்பானது. + பின்னணி இருப்பிடத்தை அனுமதி + பின்னணி இருப்பிடம் இல்லாமல் தொடரவும் + இருப்பிட அமைப்புகளைத் திற + மீண்டும் சரிபார்க்கவும் + இருப்பிட சேவைகள் கிடைக்கவில்லை + இந்த சாதனத்தில் இருப்பிட சேவைகள் கிடைக்கவில்லை. Android சாதனங்களில் இருப்பிட சேவைகள் இயல்பாக இருப்பதால் இது அசாதாரணமானது.\n\nBluetooth ஸ்கேனிங் சரியாக செயல்பட bitchat க்கு இருப்பிட சேவைகள் தேவை (Android தேவை). இது இல்லாமல், அருகிலுள்ள பயனர்களை ஆப் கண்டறிய முடியாது. + இருப்பிட சேவைகளைச் சரிபார்க்கிறது… + Bluetooth தேவை + bitchat க்கு Bluetooth தேவைப்படுவது: + • அருகிலுள்ள பயனர்களைக் கண்டறிய\n• மெஷ் நெட்வொர்க் இணைப்புகளை உருவாக்க\n• செய்திகளை அனுப்ப மற்றும் பெற\n• இணையம் அல்லது சர்வர்கள் இல்லாமல் செயல்பட + Bluetooth ஐ இயக்கு + Bluetooth ஆதரிக்கப்படவில்லை + bitchat செயல்பட தேவையான Bluetooth Low Energy (BLE) ஐ இந்த சாதனம் ஆதரிக்காது.\n\nஇணையம் இல்லாமல் மெஷ் நெட்வொர்க்குகளை உருவாக்கவும் அருகிலுள்ள சாதனங்களுடன் தொடர்பு கொள்ளவும் bitchat க்கு BLE தேவை. + Bluetooth நிலையைச் சரிபார்க்கிறது… + பேட்டரி ஆப்டிமைசேஷன் கண்டறியப்பட்டது + பேட்டரி ஆப்டிமைசேஷன் இயக்கப்பட்டது + மெஷ் இணைப்புகளைப் பராமரிக்க bitchat பின்னணியில் இயங்க வேண்டும். பேட்டரி ஆப்டிமைசேஷன் இந்த இணைப்புகளை தடுக்கலாம். + முடக்குவதன் நன்மைகள் + • நம்பகமான செய்தி வழங்கல்\n• மெஷ் இணைப்பைப் பராமரிக்கிறது\n• இணைப்பு துண்டிப்புகளைத் தடுக்கிறது + பேட்டரி ஆப்டிமைசேஷனை முடக்கு + பேட்டரி ஆப்டிமைசேஷன் முடக்கப்பட்டது + தொடரவும் + மெஷ் நெட்வொர்க்கைத் தொடங்குகிறது + . + Bluetooth மெஷ் நெட்வொர்க்கிங்கை அமைக்கிறது… + இதற்கு சில வினாடிகள் மட்டுமே ஆகும் + ⚠️ + அமைப்பு முடிக்கப்படவில்லை + மீண்டும் முயற்சி + அமைப்புகளைத் திற + உங்கள் தனியுரிமை பாதுகாக்கப்படுகிறது + • கண்காணிப்பு அல்லது தரவு சேகரிப்பு இல்லை\n• Bluetooth மெஷ் அரட்டைகள் முழுவதுமாக ஆஃப்லைனில் உள்ளன\n• ஜியோஹாஷ் அரட்டைகள் இணையத்தைப் பயன்படுத்துகின்றன + அனுமதிகள் + அனுமதிகளை வழங்கு + bitchat உங்கள் இருப்பிடத்தை கண்காணிக்காது + @ + · ⧉ + யாரும் அருகில் இல்லை… + (நீங்கள்) + ஜியோஹாஷைத் தேர்ந்தெடுக்க பான் செய்து ஜூம் செய்யவும் + தேர்ந்தெடு + செய்தியை உள்ளிடவும்… + @%1$s + குறிப்பிடு + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + படம் + கோப்பு + 📷 ஒரு படத்தை அனுப்பியது + 🎤 ஒரு குரல் செய்தியை அனுப்பியது + 📎 ஒரு கோப்பை அனுப்பியது + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + இயக்கு + இடைநிறுத்து + Bluetooth வழியாக bitchat பயனர்களைக் கண்டறிய தேவை + அருகிலுள்ள சாதனங்களுடன் இணைக்க bitchat ஐ அனுமதிக்கவும் + Bluetooth வழியாக அருகிலுள்ள bitchat பயனர்களைக் கண்டறிய Android இதை கட்டாயப்படுத்துகிறது + அருகிலுள்ள சாதனங்களை ஸ்கேன் செய்ய bitchat க்கு இது தேவை + ஆப் பின்னணியில் இருக்கும்போது அருகிலுள்ள சாதனங்களை ஸ்கேன் செய்ய பரிந்துரைக்கப்படுகிறது + பின்னணி இருப்பிட அணுகலை அனுமதிக்கவும் + நீங்கள் தனிப்பட்ட செய்திகளைப் பெறும்போது அறிவிப்புகளைப் பெறுங்கள் + உங்களுக்கு அறிவிப்புகளை அனுப்ப bitchat ஐ அனுமதிக்கவும் + bitchat பின்னணியில் நம்பகமாக இயங்கவும் மெஷ் நெட்வொர்க் இணைப்புகளைப் பராமரிக்கவும் பேட்டரி ஆப்டிமைசேஷனை முடக்கவும் + பேட்டரி கட்டுப்பாடுகள் இல்லாமல் இயங்க bitchat ஐ அனுமதிக்கவும் + அருகிலுள்ள சாதனங்கள் + துல்லியமான இருப்பிடம் + பின்னணி இருப்பிடம் + மைக்ரோஃபோன் + அறிவிப்புகள் + பேட்டரி ஆப்டிமைசேஷன் + மற்றவை + சேனல் கடவுச்சொல்லை உள்ளிடவும் + %1$s சேனல் கடவுச்சொல் பாதுகாக்கப்பட்டுள்ளது. சேர கடவுச்சொல்லை உள்ளிடவும். + கடவுச்சொல் + சேர் + ரத்து செய் + இந்த பதிப்பில் Tor கிடைக்கவில்லை + மொழி + பயன்பாட்டு மொழி + கணினி இயல்புநிலை + மொழியைத் தேர்ந்தெடுக்கவும் + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml new file mode 100644 index 00000000..b21049c1 --- /dev/null +++ b/app/src/main/res/values-th/strings.xml @@ -0,0 +1,383 @@ + + + bitchat + + + จำเป็นต้องมีสิทธิ์บลูทูธเพื่อแชทโดยตรงกับผู้อื่นโดยไม่ใช้อินเทอร์เน็ต + จำเป็นต้องมีสิทธิ์ตำแหน่งเพื่อค้นหาอุปกรณ์ใกล้เคียงผ่านบลูทูธ + จำเป็นต้องมีสิทธิ์การแจ้งเตือนเพื่อแจ้งเตือนคุณเมื่อมีข้อความใหม่ + + + ชื่อเล่น + พิมพ์ข้อความ… + รหัสผ่าน + เข้าร่วมช่อง + ออก + ส่ง + แสดงคำสั่ง + กลับ + ผู้คน + ช่อง + ผู้ใช้ที่ออนไลน์ + ไม่มีการเชื่อมต่อ + แตะสามครั้งเพื่อลบข้อมูลทั้งหมด + เครือข่ายของคุณ + + + ตรวจพบการเพิ่มประสิทธิภาพแบตเตอรี่ + ปิดการเพิ่มประสิทธิภาพแบตเตอรี่แล้ว + ไม่จำเป็นต้องเพิ่มประสิทธิภาพแบตเตอรี่ + กำลังตรวจสอบการเพิ่มประสิทธิภาพแบตเตอรี่ + ทำไมต้องปิดการเพิ่มประสิทธิภาพแบตเตอรี่? + bitchat ทำงานเบื้องหลังเพื่อคงการเชื่อมต่อแบบเมชกับอุปกรณ์ใกล้เคียง การเพิ่มประสิทธิภาพแบตเตอรี่อาจขัดจังหวะการเชื่อมต่อเหล่านี้ ทำให้ข้อความล่าช้าหรือสูญหาย\n\nการปิดการเพิ่มประสิทธิภาพแบตเตอรี่ช่วยให้การสื่อสารแบบเพียร์ทูเพียร์เชื่อถือได้ + ปิดการเพิ่มประสิทธิภาพแบตเตอรี่ + หมายเหตุ: คุณสามารถเปลี่ยนได้ภายหลังที่ การตั้งค่า Android > แอป > bitchat > แบตเตอรี่ + อุปกรณ์ของคุณไม่ต้องการการตั้งค่าการเพิ่มประสิทธิภาพแบตเตอรี่ bitchat จะทำงานตามปกติ + อุปกรณ์ของคุณไม่ต้องการการตั้งค่าการเพิ่มประสิทธิภาพแบตเตอรี่ bitchat จะทำงานตามปกติ + bitchat สามารถทำงานเบื้องหลังได้อย่างเชื่อถือได้ + • ส่งข้อความได้อย่างเชื่อถือ\n• รักษาการเชื่อมต่อแบบเมช\n• ส่งต่อข้อความเบื้องหลัง\n• ป้องกันการหลุดการเชื่อมต่อ + ตรวจสอบอีกครั้ง + ข้ามตอนนี้ + ดำเนินการต่อ + ลองอีกครั้ง + ข้าม + + + และอีก %1$d + ข้อความ %1$d จาก %2$d คน + และการสนทนาอีก %1$d รายการ + มีผู้ใช้ใกล้คุณ! + มี 1 คนใกล้คุณ + มี %1$d คนใกล้คุณ + ข้อความใหม่ + ข้อความตำแหน่งใหม่ + ถูกกล่าวถึงใน %1$s + ถูกกล่าวถึงใน %1$s (+%2$d) + มี %1$d การกล่าวถึงใน %2$s + กิจกรรมใหม่ใน %1$s + ข้อความใน %1$s + %1$s ได้เข้าร่วมการสนทนา + bitchat – %1$d การกล่าวถึง + bitchat – แชทตามตำแหน่ง + %1$d ข้อความจาก %2$d พื้นที่ + ถูกกล่าวถึงในแชทเมช + ถูกกล่าวถึง %1$d ครั้งในแชทเมช + และอีก %1$d พื้นที่ + และอีก %1$d + + + เพิ่มในรายการโปรด + เอาออกจากรายการโปรด + เพิ่มที่คั่นหน้า + + + ติดต่อได้ผ่าน Nostr + ข้อความส่วนตัวยังไม่ได้อ่าน + เทเลพอร์ตแล้ว + เปิดการตั้งค่าตำแหน่งและช่อง + เพียร์ที่เชื่อมต่อ + ผู้เข้าร่วม geohash + พร้อมสำหรับการจับมือ + กำลังจับมือ + เข้ารหัสจากปลายทางถึงปลายทาง + การจับมือล้มเหลว + + + 📎 รับไฟล์แล้ว + 📄 %1$s + 📏 ขนาด: %1$s + 🏷️ ประเภท: %1$s + 📂 เปิด / บันทึก + ❌ ปิด + เลือกภาพ + + + แชทเมชออฟไลน์ + สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ + ช่อง geohash ออนไลน์ + เชื่อมต่อกับผู้คนใกล้คุณผ่านช่องที่อ้างอิง geohash ขยายเมชผ่านรีเลย์อินเทอร์เน็ตสาธารณะ + การเข้ารหัสแบบปลายทางถึงปลายทาง + ข้อความส่วนตัวถูกเข้ารหัส ข้อความในช่องเป็นสาธารณะ + ระบบ + สว่าง + มืด + พิสูจน์ผลงาน (Proof of Work) + ปิด PoW + เปิด PoW + เพิ่ม Proof of Work ให้ข้อความ geohash เพื่อยากต่อการสแปม + ความยาก: %1$d บิต (~%2$s) + ความยาก %1$d ต้องใช้การลองแฮชประมาณ %2$s ครั้ง + ไม่ต้องใช้ Proof of Work + ต่ำมาก – ป้องกันสแปมน้อยที่สุด + ต่ำ – ป้องกันสแปมพื้นฐาน + ปานกลาง – ป้องกันสแปมได้ดี + สูง – ป้องกันสแปมอย่างแข็งแรง + สูงมาก – อาจทำให้เกิดความล่าช้า + สูงสุด – ต้องใช้ทรัพยากรคำนวณมาก + เครือข่าย + ปิด Tor + เปิด Tor + กำหนดเส้นทางทราฟฟิกอินเทอร์เน็ตผ่าน Tor เพื่อเพิ่มความเป็นส่วนตัว + สถานะ Tor: %1$s, การบูต %2$d%% + ล่าสุด: %1$s + ลบข้อมูลฉุกเฉิน + การตั้งค่าดีบัก + โอเพนซอร์ส • ความเป็นส่วนตัวมาก่อน • กระจายศูนย์ + ปิด + ยกเลิก + + + คำเตือน + บริการตำแหน่ง + ความเป็นส่วนตัว + ข้อผิดพลาด + การเพิ่มประสิทธิภาพแบตเตอรี่ + ประโยชน์ + กำลังตรวจสอบการเพิ่มประสิทธิภาพแบตเตอรี่ + ไม่รองรับการเพิ่มประสิทธิภาพแบตเตอรี่ + Bluetooth + ข้อความที่ยังไม่ได้อ่าน + เปิดแผนที่ + เอาที่คั่นหน้าออก + เทเลพอร์ต + ออกจากช่อง + ติดต่อได้ผ่าน Nostr + รายการโปรด (ออฟไลน์) + ลดความแม่นยำ + เพิ่มความแม่นยำ + เลือก geohash + เลื่อนไปด้านล่างสุด + ไฟล์ + รูปภาพ + ยกเลิก + ลิงก์ + เลือกมีเดีย + เลือกไฟล์ + บันทึกรูปภาพปัจจุบัน + ปิด + รูปภาพ %1$d จาก %2$d + คุ้มครองความเป็นส่วนตัว + บันทึกบันทึกเสียง + แชทเมชออฟไลน์ + ช่อง geohash ออนไลน์ + การเข้ารหัสแบบปลายทางถึงปลายทาง + + + รูปภาพ %1$d จาก %2$d + ไม่สามารถใช้รูปภาพได้ + บันทึกรูปภาพไว้ใน "ดาวน์โหลด" แล้ว + ไม่สามารถบันทึกรูปภาพได้ + บันทึกรูปภาพไว้ใน "ดาวน์โหลด" แล้ว + ไม่สามารถบันทึกรูปภาพได้ + เลือกไฟล์ + [ไม่สามารถใช้ไฟล์ได้] + ไม่ทราบ + + + เลือกการทำงานสำหรับข้อความนี้หรือผู้ใช้นี้ + เลือกการทำงานสำหรับผู้ใช้นี้ + คัดลอกข้อความ + คัดลอกข้อความนี้ไปยังคลิปบอร์ด + ตบ %1$s + ส่งตบแบบขำ ๆ + กอด %1$s + ส่งกอดแบบเป็นมิตร + บล็อก %1$s + บล็อกข้อความทั้งหมดจากผู้ใช้นี้ + + + #ช่องตามตำแหน่ง + แชทกับผู้คนใกล้คุณผ่านช่อง geohash จะแชร์เฉพาะ geohash โดยคร่าว ๆ เท่านั้น ไม่ใช่ข้อมูล GPS ที่แม่นยำ เพื่อคุ้มครองความเป็นส่วนตัวของคุณ โปรดอย่าถ่ายภาพหน้าจอหรือแชร์หน้าจอนี้ + ให้สิทธิ์ตำแหน่ง + ปฏิเสธสิทธิ์ตำแหน่ง เปิดใช้งานในการตั้งค่าเพื่อใช้ช่องตามตำแหน่ง + เปิดการตั้งค่า + \u2713 อนุญาตตำแหน่งแล้ว + กำลังตรวจสอบสิทธิ์… + กำลังค้นหาช่องใกล้เคียง… + มีที่คั่นหน้า + Geohash + geohash ไม่ถูกต้อง + เทเลพอร์ต + ปิดบริการตำแหน่ง + เปิดบริการตำแหน่ง + mesh + บล็อก + ละแวกใกล้เคียง + เมือง + จังหวัด + ภูมิภาค + #bluetooth • %1$s + + + เครื่องมือดีบัก + เครื่องมือสำหรับนักพัฒนาสำหรับการวินิจฉัยและควบคุม + การบันทึกแบบละเอียด + บันทึกการเข้าร่วม/ออกของเพียร์ ทิศทางการเชื่อมต่อ การส่งต่อแพ็กเก็ต และรีเลย์ + บทบาทบลูทูธ + เซิร์ฟเวอร์ GATT + การเชื่อมต่อ: %1$d / %2$d + สูงสุดของเซิร์ฟเวอร์ + ไคลเอนต์ GATT + สูงสุดของไคลเอนต์ + การเชื่อมต่อ: %1$d / %2$d + รวมสูงสุด + การส่งต่อแพ็กเก็ต + ตั้งแต่เริ่มต้น: %1$d + สลับบทบาทและปิดการเชื่อมต่อทั้งหมดเมื่อปิดใช้งาน + การตั้งค่าการซิงค์ + แพ็กเก็ตสูงสุดต่อการซิงค์: %1$d + ขนาดตัวกรอง GCS สูงสุด: %1$d ไบต์ (128–1024) + อัตราบวกเทียมเป้าหมาย: %1$.2f%% + อุปกรณ์ที่เชื่อมต่อ + รหัสอุปกรณ์ของเรา: %1$s + ไม่มี + ตัดการเชื่อมต่อ + อุปกรณ์ที่สแกนล่าสุด + เชื่อมต่อ + คอนโซลดีบัก + ล้าง + 10 วิ ล่าสุด: %1$d • 1 นาที: %2$d • 15 นาที: %3$d + P ที่ได้มา: %1$s • จำนวนรายการสูงสุดโดยประมาณ: %2$s + • โดยตรง + ในบทบาทเซิร์ฟเวอร์ (เราเป็นโฮสต์) + ในบทบาทไคลเอนต์ (เราเชื่อมต่อ) + + + ต้องใช้บริการตำแหน่ง + ความเป็นส่วนตัวมาก่อน + bitchat ไม่ติดตามตำแหน่งของคุณ\n\nต้องใช้บริการตำแหน่งสำหรับการสแกนบลูทูธและคุณสมบัติแชท geohash + bitchat ต้องใช้บริการตำแหน่งสำหรับ: + • สแกนอุปกรณ์บลูทูธ\n• ค้นพบผู้ใช้ใกล้เคียงในเครือข่ายเมช\n• คุณสมบัติแชท geohash\n• ไม่มีการติดตามหรือเก็บตำแหน่ง + เปิดการตั้งค่าตำแหน่ง + ตรวจสอบอีกครั้ง + ไม่สามารถใช้บริการตำแหน่งได้ + บริการตำแหน่งไม่สามารถใช้ได้ในอุปกรณ์นี้ ซึ่งผิดปกติเนื่องจากโดยทั่วไปแล้วอุปกรณ์ Android จะมีบริการตำแหน่ง\n\nต้องใช้บริการตำแหน่งเพื่อให้การสแกนบลูทูธทำงานได้อย่างถูกต้อง (ข้อกำหนดของ Android) หากไม่มี แอปจะไม่สามารถค้นพบผู้ใช้ใกล้เคียงได้ + กำลังตรวจสอบบริการตำแหน่ง… + ต้องใช้บลูทูธ + bitchat ต้องใช้บลูทูธสำหรับ: + • ค้นพบผู้ใช้ใกล้เคียง\n• สร้างการเชื่อมต่อเครือข่ายเมช\n• ส่งและรับข้อความ\n• ทำงานโดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ + เปิดบลูทูธ + ไม่รองรับบลูทูธ + อุปกรณ์นี้ไม่รองรับ Bluetooth Low Energy (BLE) ซึ่งจำเป็นสำหรับการทำงานของ bitchat\n\nต้องใช้ BLE เพื่อสร้างเครือข่ายเมชและสื่อสารกับอุปกรณ์ใกล้เคียงโดยไม่ใช้อินเทอร์เน็ต + กำลังตรวจสอบสถานะบลูทูธ… + ตรวจพบการเพิ่มประสิทธิภาพแบตเตอรี่ + เปิดใช้การเพิ่มประสิทธิภาพแบตเตอรี่ + bitchat ต้องทำงานเบื้องหลังเพื่อคงการเชื่อมต่อแบบเมช การเพิ่มประสิทธิภาพแบตเตอรี่อาจขัดจังหวะการเชื่อมต่อเหล่านี้ + ประโยชน์ของการปิดใช้งาน + • ส่งข้อความได้อย่างเชื่อถือ\n• รักษาการเชื่อมต่อแบบเมช\n• ป้องกันการหลุดการเชื่อมต่อ + ปิดการเพิ่มประสิทธิภาพแบตเตอรี่ + ปิดการเพิ่มประสิทธิภาพแบตเตอรี่แล้ว + ดำเนินการต่อ + กำลังเริ่มต้นเครือข่ายเมช + กำลังตั้งค่าเครือข่ายเมช Bluetooth… + ควรใช้เวลาเพียงไม่กี่วินาที + ตั้งค่าไม่เสร็จสมบูรณ์ + ลองอีกครั้ง + ความเป็นส่วนตัวของคุณได้รับการคุ้มครอง + • ไม่มีการติดตามหรือเก็บข้อมูล\n• แชทเมชบลูทูธทำงานแบบออฟไลน์ทั้งหมด\n• แชท geohash ใช้อินเทอร์เน็ต + สิทธิ์ + ให้สิทธิ์ + bitchat ไม่ติดตามตำแหน่งของคุณ + ไม่มีใครอยู่ใกล้… + (คุณ) + ลาก/ซูมเพื่อเลือก geohash + เลือก + พิมพ์ข้อความ… + กล่าวถึง + v%1$s + image/* + รูปภาพ + ไฟล์ + + + 📷 ส่งรูปภาพแล้ว + 🎤 ส่งบันทึกเสียงแล้ว + 📎 ส่งไฟล์แล้ว + + + เล่น + หยุดชั่วคราว + + + จำเป็นสำหรับการค้นหาผู้ใช้ bitchat ผ่านบลูทูธ + อนุญาตให้ bitchat เชื่อมต่อกับอุปกรณ์ใกล้เคียง + ข้อกำหนดของ Android สำหรับการค้นหาผู้ใช้ bitchat ใกล้เคียงผ่านบลูทูธ + bitchat ต้องใช้สิ่งนี้เพื่อสแกนอุปกรณ์ใกล้เคียง + รับการแจ้งเตือนเมื่อคุณได้รับข้อความส่วนตัว + อนุญาตให้ bitchat ส่งการแจ้งเตือนถึงคุณ + ปิดการเพิ่มประสิทธิภาพแบตเตอรี่เพื่อให้ bitchat ทำงานได้อย่างเชื่อถือในเบื้องหลังและคงการเชื่อมต่อแบบเมช + อนุญาตให้ bitchat ทำงานโดยไม่มีข้อจำกัดของแบตเตอรี่ + + + อุปกรณ์ใกล้เคียง + ตำแหน่งที่แม่นยำ + ไมโครโฟน + การแจ้งเตือน + การเพิ่มประสิทธิภาพแบตเตอรี่ + อื่น ๆ + + + ป้อนรหัสผ่านของช่อง + ช่อง %1$s มีการป้องกันด้วยรหัสผ่าน ป้อนรหัสผ่านเพื่อเข้าร่วม + รหัสผ่าน + เข้าร่วม + ยกเลิก + + + + และอีก %d + และอีก %d + + + + 1 คน + %d คน + + + Bluetooth Recommended + Mesh กำลังทำงาน — %1$d เพื่อน + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + เปิดเกี่ยวกับ + ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่ + มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน + มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน + ภาษา + ภาษาของแอป + ค่าเริ่มต้นของระบบ + เลือกภาษา + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..251d6a46 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,381 @@ + + + bitchat + İnternetsiz eşler arası mesajlaşma için Bluetooth izni gerekir. + Bluetooth ile yakın cihazları bulmak için konum izni gerekir. + Yeni mesajlarda bildirim gönderebilmek için izin gerekir. + takma ad + mesaj yaz… + Parola + Kanala katıl + Ayrıl + Gönder + Komutları göster + Geri + Kişiler + Kanallar + Çevrimiçi kullanıcılar + Bağlı kimse yok + Tüm verileri silmek için üç kez dokun + Ağ + + Pil optimizasyonu algılandı + Pil optimizasyonu devre dışı + Pil optimizasyonu gerekmiyor + Pil optimizasyonu kontrol ediliyor + Neden devre dışı bırakmalı? + bitchat yakındaki cihazlarla mesh bağlantısını sürdürmek için arka planda çalışır. Pil optimizasyonu bu bağlantıları kesebilir ve mesajlar gecikebilir/ kaybolabilir.\n\nDevre dışı bırakmak güvenilir eşler arası mesajlaşmayı sağlar. + Optimizasyonu kapat + Not: Daha sonra Ayarlar > Uygulamalar > bitchat > Pil üzerinden değiştirebilirsin. + Cihazının pil optimizasyonu ayarlarına ihtiyacı yok. bitchat normal çalışır. + Cihazının pil optimizasyonu ayarlarına ihtiyacı yok. bitchat normal çalışır. + bitchat arka planda güvenle çalışabilir + • Güvenilir mesaj teslimi\n• Mesh bağlantısını korur\n• Arka planda iletim\n• Kopmaları önler + Tekrar kontrol et + Şimdilik atla + Devam + Yeniden dene + Atla + + ve %1$d daha + %2$d kişiden %1$d mesaj + ve %1$d sohbet daha + yakında bitchatter’lar! + Yakında 1 kişi + Yakında %1$d kişi + Yeni mesajlar + Yeni konum mesajları + %1$s içinde bahsedildi + %1$s içinde bahsedildi (+%2$d) + %2$s içinde %1$d bahsetme + %1$s içinde yeni etkinlik + %1$s içindeki mesajlar + %1$s sohbete katıldı + bitchat – %1$d bahsetme + bitchat – konum sohbetleri + %2$d konumdan %1$d mesaj + Mesh sohbette bahsedildi + Mesh sohbette %1$d bahsetme + ve %1$d konum daha + ve %1$d daha + + Sık kullanılanlara ekle + Sık kullanılandan kaldır + Yer imi ekle + + Nostr üzerinden ulaşılabilir + Okunmamış özel mesaj + Teleport edildi + Konum ve kanal ayarlarını aç + Bağlı eşler + Geohash katılımcıları + El sıkışmaya hazır + El sıkışma sürüyor + Uçtan uca şifreli + El sıkışma başarısız + + 📎 Dosya alındı + 📄 %1$s + 📏 Boyut: %1$s + 🏷️ Tür: %1$s + 📂 Aç / Kaydet + ❌ Kapat + Görsel seç + + Çevrimdışı Mesh Sohbeti + İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır. + Çevrimiçi Geohash Kanalları + Geohash tabanlı kanallarla yakınındaki insanlarla bağlantı kur. Herkese açık internet röleleriyle mesh’i genişlet. + Uçtan Uca Şifreleme + Özel mesajlar şifrelidir. Kanal mesajları herkese açıktır. + sistem + açık + koyu + proof of work + pow kapalı + pow açık + geohash mesajlarına spam’e karşı proof of work ekle. + zorluk: %1$d bit (~%2$s) + %1$d zorluğu ~%2$s hash denemesi gerektirir + proof of work gerekmez + çok düşük – en az koruma + düşük – temel koruma + orta – iyi koruma + yüksek – güçlü koruma + çok yüksek – gecikme olabilir + aşırı – ciddi hesaplama gerekir + ağ + tor kapalı + tor açık + Daha fazla gizlilik için interneti Tor üzerinden yönlendir. + Tor durumu: %1$s, önyükleme %2$d%% + Son: %1$s + Acil veri silme + Hata ayıklama ayarları + Open Source • Privacy First • Merkezsiz + Kapat + iptal + + Uyarı + Konum servisleri + Gizlilik + Hata + Pil optimizasyonu + Faydalar + Optimizasyon kontrol ediliyor + Desteklenmiyor + Bluetooth + Okunmamış mesaj + Haritayı aç + Yer imini kaldır + Teleport + Kanaldan ayrıl + Nostr üzerinden erişilebilir + Favori (çevrimdışı) + Hassasiyeti azalt + Hassasiyeti artır + Geohash seç + Alta kaydır + Dosya + Görsel + İptal + Bağlantı + Medya seç + Ses notu kaydet + Çevrimdışı Mesh Sohbeti + Çevrimiçi Geohash Kanalları + Uçtan uca şifreleme + + Görsel %1$d / %2$d + Görsel kullanılamıyor + Görsel "İndirilenler"e kaydedildi + Görsel kaydedilemedi + Dosya seç + [dosya kullanılamıyor] + bilinmiyor + + bu mesaj veya kullanıcı için bir işlem seç + bu kullanıcı için bir işlem seç + mesajı kopyala + mesajı panoya kopyala + %1$s kişisine şaplak + şakacı bir şaplak gönder + %1$s kişisine sarıl + samimi bir sarılma gönder + %1$s kişisini engelle + bu kullanıcıdan gelen tüm mesajları engelle + + #konum kanalları + yakındaki insanlarla geohash kanallarıyla sohbet et. yalnızca kabaca geohash paylaşılır, kesin gps asla değil. gizliliğin için ekran görüntüsü alma ve paylaşma. + konum izni ver + konum izni reddedildi. konum kanalları için ayarlardan etkinleştir. + Ayarları aç + \u2713 konum izni verildi + izinler kontrol ediliyor… + yakın kanallar aranıyor… + yer imli + geohash + geçersiz geohash + teleport + konum servislerini kapat + konum servislerini aç + #bluetooth • %1$s + mesh + blok + mahalle + şehir + eyalet + bölge + + hata ayıklama araçları + ayrıntılı günlük + Bluetooth rolleri + GATT sunucu + bağlantı: %1$d / %2$d + maks sunucu + GATT istemci + maks istemci + bağlantı: %1$d / %2$d + maks toplam + paket rölesi + başlangıçtan beri: %1$d + senk. ayarları + senk. başına maks paket: %1$d + maks GCS filtre boyutu: %1$d bayt (128–1024) + hedef FPR: %1$.2f%% + bağlı cihazlar + cihaz kimliğimiz: %1$s + yok + bağlantıyı kes + son tarama sonuçları + bağlan + hata ayıklama konsolu + temizle + son 10 sn: %1$d • 1 dk: %2$d • 15 dk: %3$d + türetilen P: %1$s • tahm. maks öge: %2$s + • doğrudan + ? + sunucu olarak (barındırıyoruz) + istemci olarak (bağlanıyoruz) + + Konum servisleri gerekli + Önce gizlilik + bitchat KONUMUNU takip etmez.\n\nBluetooth tarama ve Geohash sohbet için konum servisleri gerekir. + bitchat konum servislerini şunlar için kullanır: + • Bluetooth cihaz tarama\n• Mesh ağında yakın kullanıcıları bulma\n• Geohash sohbet özelliği\n• Takip veya konum toplama yok + Konum ayarlarını aç + Tekrar kontrol et + Konum servisleri kullanılamıyor + Bu cihazda konum servisleri yok. Bu olağandışıdır.\n\nBluetooth taramasının düzgün çalışması (Android gereksinimi) için gerekir. Aksi halde yakın kullanıcılar bulunamaz. + Konum servisleri kontrol ediliyor… + Bluetooth gerekli + bitchat Bluetooth’u şunlar için kullanır: + • Yakın kullanıcıları bulma\n• Mesh bağlantıları kurma\n• Mesaj gönderme/alma\n• İnternetsiz çalışma + Bluetooth’u aç + Bluetooth desteklenmiyor + Bu cihaz Bluetooth Low Energy (BLE) desteklemiyor, bitchat için gereklidir.\n\nBLE, internetsiz yakın cihazlarla iletişim ve mesh için gereklidir. + Bluetooth durumu kontrol ediliyor… + pil optimizasyonu algılandı + Pil optimizasyonu açık + mesh için arka planda çalışmalı; optimizasyon kesintiye yol açabilir. + Devre dışı bırakmanın faydaları + • güvenilir teslimat\n• bağlantıyı korur\n• kopmayı önler + Optimizasyonu kapat + optimizasyon kapalı + Devam + Mesh ağı başlatılıyor + . + Bluetooth mesh ayarlanıyor… + Yalnızca birkaç saniye sürecek + ⚠️ + Kurulum tamamlanmadı + Yeniden dene + Gizliliğin korunuyor + • takip veya veri toplama yok\n• Bluetooth mesh sohbet çevrimdışı\n• geohash sohbet internete ihtiyaç duyar + izinler + İzin ver + bitchat konumunu takip etmez + @ + · ⧉ + yakında kimse yok… + (sen) + geohash seçmek için kaydır/zoom yap + seç + mesaj yaz… + @%1$s + bahset + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Görsel + Dosya + + 📷 görsel gönderdi + 🎤 sesli mesaj gönderdi + 📎 dosya gönderdi + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + Oynat + Duraklat + + Bluetooth ile bitchat kullanıcılarını bulmak için gereklidir + bitchat’in yakın cihazlara bağlanmasına izin ver + Android gereği Bluetooth ile yakın kullanıcıları bulmak için gerekli + yakın cihazları taramak için gereklidir + özel mesajlarda bildirim al + bitchat’in bildirim göndermesine izin ver + arka planda güvenilir çalışmak ve mesh’i korumak için pil optimizasyonunu kapat + pil kısıtlaması olmadan çalışmaya izin ver + + Yakın cihazlar + Hassas konum + Mikrofon + Bildirimler + Pil optimizasyonu + Diğer + + Kanal parolasını gir + %1$s kanalı parolalıdır. Katılmak için parola gir. + Parola + Katıl + İptal + + + ve %d daha + ve %d daha + + + 1 kişi + %d kişi + + + #%1$s ± 1 • %2$d not + #%1$s ± 1 • %2$d not + + Diğer ziyaretçilerin bulabilmesi için bu konuma kısa kalıcı notlar ekleyin. + coğrafi röleler kullanılamıyor; notlar duraklatıldı + yakınlarda coğrafi röle yok + notlar coğrafi rölelere bağlıdır. bağlantıyı kontrol edip tekrar deneyin. + notlar yükleniyor… + henüz not yok + bu yer için ilk notu ekleyen siz olun. + kapat + bu yer için bir not ekleyin + Bluetooth Önerilir + Mesh çalışıyor — %1$d eş + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + Hakkında’yı aç + buraya bırakılan notlara bak + buraya 1 not bırakıldı — okumak için dokun + buraya %d not bırakıldı — okumak için dokun + Dil + Uygulama dili + Sistem varsayılanı + Dil seçin + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 00000000..f248ff71 --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,403 @@ + + + + Пропустити + Рекомендується Bluetooth + Mesh працює — %1$d пірів + + перевірити + скануйте, щоб підтвердити мене + скануйте QR-код іншої особи + скануйте QR-код іншої особи + показати мій QR-код + видалити підтвердження + QR-код недоступний + потрібен дозвіл на камеру для сканування QR-кодів + увімкнути камеру + вставити посилання для підтвердження + підтвердити + запит на підтвердження надіслано + перевірка безпеки + їхній відбиток + ваш відбиток + очікування рукостискання + відкрийте приватний чат, щоб переглянути відбитки + зашифровано і підтверджено + зашифровано + рукостискання + рукостискання не вдалося + не зашифровано + підтверджено + ви підтвердили особу цієї людини. + не підтверджено + порівняйте ці відбитки з %1$s, використовуючи безпечний канал. + позначити як підтверджено + почати рукостискання + копіювати + Взаємне підтвердження + Ви та %1$s підтвердили одне одного + взаємне підтвердження з %1$s + Підтверджено + Ви підтвердили %1$s + підтверджено %1$s + + + bitchat + Дозвіл на використання Bluetooth потрібен для обміну повідомленнями напряму, без інтернету. + Дозвіл на геолокацію потрібен для виявлення пристроїв поруч через Bluetooth. + Дозвіл на сповіщення потрібен, щоб повідомляти вас про нові повідомлення. + нікнейм + введіть повідомлення… + Пароль + Приєднатися до каналу + Вийти + Надіслати + Показати команди + Назад + Люди + Канали + Користувачі онлайн + Немає підключених + Потрійний дотик, щоб очистити всі дані + Мережа + Виявлено оптимізацію батареї + Оптимізацію батареї вимкнено + Оптимізація батареї не потрібна + Перевірка оптимізації батареї + Навіщо вимикати оптимізацію батареї? + bitchat працює у фоновому режимі, щоб підтримувати з’єднання mesh-мережі з пристроями поруч. Оптимізація батареї може переривати ці з’єднання, через що повідомлення затримуються або губляться.\n\nВимкнення оптимізації батареї забезпечує надійний обмін повідомленнями напряму. + Вимкнути оптимізацію батареї + Примітка: ви можете змінити це налаштування пізніше в Налаштуваннях Android > Додатки > bitchat > Батарея + Ваш пристрій не потребує налаштувань оптимізації батареї. bitchat працюватиме у звичайному режимі. + Ваш пристрій не потребує налаштувань оптимізації батареї. bitchat працюватиме у звичайному режимі. + bitchat може надійно працювати у фоновому режимі + • Забезпечує надійну доставку повідомлень\n• Підтримує зв’язок у mesh-мережі\n• Дозволяє передавати повідомлення у фоні\n• Запобігає розривам з’єднання + Перевірити знову + Пропустити зараз + Продовжити + Повторити + і ще %1$d + %1$d повідомлень від %2$d людей + і ще %1$d розмов + 👥 bitchat-користувачі поруч! + 1 людина поруч + %1$d людей поруч + Нові повідомлення + Нові повідомлення в геолокації + Згадка в %1$s + Згадка в %1$s (+%2$d ще) + %1$d згадок у %2$s + Нова активність у %1$s + Повідомлення в %1$s + %1$s приєднався(-лася) до розмови + bitchat - %1$d згадок + bitchat - геочати + %1$d повідомлень з %2$d місць + Згадка в Mesh-чаті + %1$d згадок у Mesh-чаті + і ще %1$d місць + Фонова служба Mesh + Підтримує роботу Bluetooth mesh-мережі у фоновому режимі + Додати в обране + Прибрати з обраного + Додати закладку + Доступний через Nostr + Непрочитані приватні повідомлення + Нотатки про місце + Телепортовано + Відкрити налаштування розташування та каналів + Підключені піри + Учасники геохешу + Готовий до рукостискання + Рукостискання триває + Наскрізне шифрування + Рукостискання не вдалося + 📎 Файл отримано + 📄 %1$s + 📏 Розмір: %1$s + 🏷️ Тип: %1$s + 📂 Відкрити / Зберегти + ❌ Закрити + Вибрати зображення + Зберегти поточне зображення + Закрити + Зображення збережено в Завантаження + Не вдалося зберегти зображення + Зображення %1$d з %2$d + Вибрати файл + децентралізований mesh-обмін повідомленнями з наскрізним шифруванням + Офлайн Mesh-чат + Спілкуйтеся напряму через Bluetooth LE без інтернету чи серверів. Повідомлення передаються через пристрої поруч, щоб розширити радіус дії. + Онлайн-канали за геохешем + Спілкуйтеся з людьми у вашому районі за допомогою каналів на основі геохешу. Розширюйте mesh-мережу за допомогою публічних інтернет-ретрансляторів. + Наскрізне шифрування + Приватні повідомлення шифруються. Повідомлення в каналах є публічними. + системний + світлий + темний + підтвердження роботи + pow вимкнено + pow увімкнено + додає підтвердження роботи до повідомлень геохешу для захисту від спаму. + складність: %1$d бітів (~%2$s) + складність %1$d вимагає ~%2$s спроб хешування + підтвердження роботи не потрібне + дуже низька - мінімальний захист від спаму + низька - базовий захист від спаму + середня - хороший захист від спаму + висока - надійний захист від спаму + дуже висока - можливі затримки + екстремальна - потрібні значні обчислення + мережа + tor вимкнено + tor увімкнено + маршрутизувати інтернет через tor для посиленої приватності. + статус tor: %1$s, завантаження %2$d%% + Востаннє: %1$s + Екстрене видалення даних + Налаштування для розробників + Відкритий код • Приватність понад усе • Децентралізовано + Закрити + Приватність захищено + скасувати + Попередження + Служби геолокації + Приватність + Помилка + Оптимізація батареї + Переваги + Перевірка оптимізації батареї + Оптимізація батареї не підтримується + Bluetooth + Непрочитане повідомлення + Відкрити карту + Прибрати закладку + Телепортуватися + Закрити bitchat + працювати у фоновому режимі + підтримувати mesh активним, коли додаток закрито (фонова служба) + Вийти з каналу + Доступний через Nostr + Офлайн обране + Зменшити точність + Збільшити точність + Вибрати геохеш + Прогорнути донизу + Файл + Зображення + Скасувати + Посилання + Записати голосове повідомлення + Вибрати медіа + Офлайн Mesh-чат + Онлайн-канали за геохешем + Наскрізне шифрування + #bluetooth • %1$s + Зображення %1$d з %2$d + Зображення недоступне + Зображення збережено в Завантаження + Не вдалося зберегти зображення + Вибрати файл + [файл недоступний] + невідомо + виберіть дію для цього повідомлення або користувача + виберіть дію для цього користувача + скопіювати повідомлення + скопіювати це повідомлення в буфер обміну + ляснути %1$s + надіслати жартівливе повідомлення про ляпас + обійняти %1$s + надіслати дружнє повідомлення з обіймами + заблокувати %1$s + заблокувати всі повідомлення від цього користувача + написати %1$s + надіслати приватне повідомлення + #канали за геолокацією + спілкуйтеся з людьми поруч, використовуючи канали за геохешем. надається лише приблизний геохеш, ніколи точні gps-координати. не робіть скріншотів і не діліться цим екраном, щоб захистити свою приватність. + надати дозвіл на геолокацію + у дозволі на геолокацію відмовлено. увімкніть його в налаштуваннях, щоб користуватися каналами за геолокацією. + ✓ дозвіл на геолокацію надано + перевірка дозволів... + пошук каналів поруч… + додано в закладки + геохеш + недійсний геохеш + телепорт + вимкнути служби геолокації + увімкнути служби геолокації + mesh + квартал + район + місто + область + регіон + додайте короткі постійні нотатки до цього місця, щоб їх могли знайти інші відвідувачі. + гео-ретранслятори недоступні; нотатки призупинено + поруч немає гео-ретрансляторів + нотатки залежать від гео-ретрансляторів. перевірте з’єднання і спробуйте ще раз. + завантаження нотаток… + поки що немає нотаток + будьте першим, хто додасть нотатку для цього місця. + закрити + додати нотатку для цього місця + інструменти розробника + утиліти розробника для діагностики та контролю + детальне логування + логує приєднання/вихід пірів, напрямок з’єднання, маршрутизацію та ретрансляцію пакетів + ролі bluetooth + gatt-сервер + з’єднання: %1$d / %2$d + макс. сервер + gatt-клієнт + макс. клієнт + з’єднання: %1$d / %2$d + макс. загалом + ретрансляція пакетів + з початку: %1$d + вмикайте/вимикайте ролі та закривайте всі з’єднання, коли вимкнено + налаштування синхронізації + макс. пакетів на синхронізацію: %1$d + макс. розмір GCS-фільтра: %1$d байт (128–1024) + цільовий FPR: %1$.2f%% + підключені пристрої + id нашого пристрою: %1$s + немає + від’єднати + останні результати сканування + підключити + консоль налагодження + очистити + останні 10с: %1$d • 1хв: %2$d • 15хв: %3$d + обчислене P: %1$s • оцінка макс. елементів: %2$s + • напряму + RSSI: %1$s + ? + як сервер (ми хостимо) + як клієнт (ми підключаємось) + Потрібні служби геолокації + Приватність понад усе + bitchat НЕ відстежує вашу локацію.\n\nСлужби геолокації потрібні для сканування Bluetooth та для функції геочатів. + bitchat потребує служб геолокації для: + • Сканування пристроїв Bluetooth\n• Виявлення користувачів поруч у mesh-мережі\n• Функції геочатів\n• Без відстеження чи збору геоданих + Рекомендовано фонову геолокацію + необов’язково, покращує надійність mesh-мережі + Android рекомендує фонову геолокацію, щоб bitchat міг сканувати пристрої поруч, коли додаток не відкрито. Це підтримує mesh-мережу активною після перезавантаження. + Коли відкриються налаштування, виберіть «Дозволити завжди». + bitchat використовує фонову геолокацію для: + - сканування пристроїв поруч, коли додаток закрито\n- повторного підключення після перезавантаження\n- підтримки роботи mesh-мережі у фоновому режимі + Ми НІКОЛИ не збираємо і не зберігаємо вашу локацію. Ваша приватність у безпеці. + Дозволити фонову геолокацію + Продовжити без фонової геолокації + Відкрити налаштування геолокації + Перевірити знову + Служби геолокації недоступні + Служби геолокації недоступні на цьому пристрої. Це незвично, оскільки служби геолокації є стандартними для пристроїв Android.\n\nbitchat потребує служб геолокації для коректної роботи сканування Bluetooth (вимога Android). Без цього додаток не може виявляти користувачів поруч. + Перевірка служб геолокації... + Потрібен Bluetooth + bitchat потребує Bluetooth для: + • Виявлення користувачів поруч\n• Створення з’єднань mesh-мережі\n• Надсилання й отримання повідомлень\n• Роботи без інтернету чи серверів + Увімкнути Bluetooth + Bluetooth не підтримується + Цей пристрій не підтримує Bluetooth Low Energy (BLE), який потрібен для роботи bitchat.\n\nbitchat потребує BLE для створення mesh-мереж і зв’язку з пристроями поруч без інтернету. + Перевірка статусу Bluetooth... + виявлено оптимізацію батареї + Оптимізацію батареї увімкнено + bitchat потрібно працювати у фоновому режимі, щоб підтримувати mesh-з’єднання. оптимізація батареї може переривати ці з’єднання. + Переваги вимкнення + • надійна доставка повідомлень\n• підтримка зв’язку в mesh-мережі\n• запобігання розривам з’єднання + Вимкнути оптимізацію батареї + оптимізацію батареї вимкнено + Продовжити + Ініціалізація mesh-мережі + . + Налаштування Bluetooth mesh-мережі... + Це має зайняти лише кілька секунд + ⚠️ + Налаштування не завершено + Спробувати знову + Відкрити налаштування + Вашу приватність захищено + • без відстеження чи збору даних\n• Bluetooth mesh-чати повністю офлайн\n• Геочати використовують інтернет + дозволи + Надати дозволи + bitchat НЕ відстежує вашу локацію + @ + · ⧉ + нікого немає поруч... + (ви) + панорамуйте та масштабуйте, щоб вибрати геохеш + вибрати + введіть повідомлення... + @%1$s + згадка + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Зображення + Файл + 📷 надіслав(-ла) зображення + 🎤 надіслав(-ла) голосове повідомлення + 📎 надіслав(-ла) файл + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + Відтворити + Пауза + Потрібно для виявлення користувачів bitchat через Bluetooth + Дозволити bitchat підключатися до пристроїв поруч + Потрібно Android для виявлення користувачів bitchat поруч через Bluetooth + bitchat потребує цього для сканування пристроїв поруч + Рекомендовано для сканування пристроїв поруч, коли додаток у фоновому режимі + Дозволити доступ до фонової геолокації + Отримуйте сповіщення, коли надходять приватні повідомлення + Дозволити bitchat надсилати вам сповіщення + Вимкніть оптимізацію батареї, щоб bitchat надійно працював у фоновому режимі та підтримував з’єднання mesh-мережі + Дозволити bitchat працювати без обмежень батареї + Пристрої поруч + Точна геолокація + Фонова геолокація + Мікрофон + Сповіщення + Оптимізація батареї + Інше + Введіть пароль каналу + Канал %1$s захищений паролем. Введіть пароль, щоб приєднатися. + Пароль + Приєднатися + Скасувати + Tor недоступний у цій збірці + + + #%1$s ± 1 • %2$d нотатка + #%1$s ± 1 • %2$d нотатки + #%1$s ± 1 • %2$d нотаток + #%1$s ± 1 • %2$d нотатки + + + + і ще %1$d + і ще %1$d + і ще %1$d + і ще %1$d + + + + %1$d людина + %1$d людини + %1$d людей + %1$d людини + + Мова + Мова застосунку + Системна мова + Вибрати мову + diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml new file mode 100644 index 00000000..785338fb --- /dev/null +++ b/app/src/main/res/values-ur/strings.xml @@ -0,0 +1,396 @@ + + + bitchat + + + بلوٹوتھ کی اجازت انٹرنیٹ کے بغیر دوسروں سے براہ راست چیٹ کرنے کے لیے ضروری ہے۔ + مقام کی اجازت بلوٹوتھ کے ذریعے قریبی آلات تلاش کرنے کے لیے ضروری ہے۔ + نوٹیفیکیشن کی اجازت آپ کو نئے پیغامات کے بارے میں بتانے کے لیے ضروری ہے۔ + + + عرفی نام + پیغام لکھیں… + پاس ورڈ + چینل میں شامل ہوں + چھوڑیں + بھیجیں + کمانڈز دکھائیں + واپس + لوگ + چینلز + آن لائن صارفین + کوئی منسلک نہیں + تمام ڈیٹا صاف کرنے کے لیے تین بار ٹیپ کریں + آپ کا نیٹ ورک + + + بیٹری کی اصلاح کا پتہ چلا + بیٹری کی اصلاح بند ہے + بیٹری کی اصلاح ضروری نہیں + بیٹری کی اصلاح چیک کر رہے ہیں + بیٹری کی اصلاح کیوں بند کریں؟ + bitchat قریبی آلات کے ساتھ میش کنیکشن برقرار رکھنے کے لیے پس منظر میں چلتا ہے۔ بیٹری کی اصلاح ان کنیکشنز کو خراب کر سکتی ہے، جس سے پیغامات دیر سے پہنچیں یا کھو جائیں۔\n\nبیٹری کی اصلاح بند کرنا قابل اعتماد پیئر‑ٹو‑پیئر کمیونیکیشن کو یقینی بناتا ہے۔ + بیٹری کی اصلاح بند کریں + نوٹ: آپ بعد میں Android سیٹنگز > ایپس > bitchat > بیٹری میں اسے تبدیل کر سکتے ہیں۔ + آپ کے آلے کو بیٹری کی اصلاح کی سیٹنگز کی ضرورت نہیں۔ bitchat عام طور پر چلے گا۔ + آپ کے آلے کو بیٹری کی اصلاح کی سیٹنگز کی ضرورت نہیں۔ bitchat عام طور پر چلے گا۔ + bitchat پس منظر میں قابل اعتماد طور پر چل سکتا ہے + • پیغامات کی قابل اعتماد ڈیلیوری\n• میش کنیکشن برقرار رکھنا\n• پس منظری پیغام فارورڈنگ\n• منطقی کنیکشن سے بچاؤ + دوبارہ چیک کریں + ابھی چھوڑیں + جاری رکھیں + دوبارہ کوشش کریں + چھوڑیں + + + اور %1$d مزید + %2$d لوگوں سے %1$d پیغامات + اور %1$d مزید گفتگو + قریب میں bitchatter! + قریب میں 1 شخص + قریب میں %1$d لوگ + نئے پیغامات + نئے مقام کے پیغامات + %1$s میں ذکر + %1$s میں ذکر (+%2$d) + %2$s میں %1$d ذکر + %1$s میں نئی سرگرمی + %1$s میں پیغامات + %1$s گفتگو میں شامل ہوا + bitchat – %1$d ذکر + bitchat – مقام چیٹس + %2$d مقامات سے %1$d پیغامات + میش چیٹ میں ذکر + میش چیٹ میں %1$d ذکر + اور %1$d مزید مقامات + اور %1$d مزید + + + پسندیدہ میں شامل کریں + پسندیدہ سے ہٹائیں + بک مارک شامل کریں + + + Nostr کے ذریعے رسائی + غیر پڑھے ہوئے نجی پیغامات + ٹیلی پورٹ + مقام اور چینل کی ترتیبات کھولیں + منسلک پیئرز + geohash شرکاء + ہینڈ شیک کے لیے تیار + ہینڈ شیک جاری ہے + اینڈ‑ٹو‑اینڈ انکرپٹڈ + ہینڈ شیک ناکام + + + 📎 فائل موصولہ + 📄 %1$s + 📏 سائز: %1$s + 🏷️ قسم: %1$s + 📂 کھولیں / محفوظ کریں + ❌ بند کریں + تصویر منتخب کریں + + + آف لائن میش چیٹ + انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔ + آن لائن geohash چینلز + geohash پر مبنی چینلز کے ذریعے اپنے قریب کے لوگوں سے جڑیں۔ عوامی انٹرنیٹ ریلے کے ذریعے میش کو پھیلائیں۔ + اینڈ‑ٹو‑اینڈ انکرپشن + نجی پیغامات انکرپٹڈ ہیں۔ چینل پیغامات عوامی ہیں۔ + سسٹم + ہلکا + گہرا + پروف آف ورک + PoW بند + PoW چالو + اسپام کو مشکل بنانے کے لیے geohash پیغامات میں Proof of Work شامل کریں۔ + مشکل: %1$d بٹ (~%2$s) + مشکل %1$d کے لیے ~%2$s ہیش کوششیں + Proof of Work ضروری نہیں + بہت کم – کم سے کم اسپام تحفظ + کم – بنیادی اسپام تحفظ + درمیانہ – اچھا اسپام تحفظ + اعلیٰ – مضبوط اسپام تحفظ + بہت اعلیٰ – تاخیر ہو سکتی ہے + انتہائی – اہم کمپیوٹیشنل طاقت درکار + نیٹ ورک + Tor بند + Tor چالو + زیادہ پرائیویسی کے لیے انٹرنیٹ ٹریفک کو Tor کے ذریعے بھیجیں۔ + Tor کی کیفیت: %1$s، بوٹ اسٹریپ %2$d%% + آخری: %1$s + ہنگامی ڈیٹا صافی + ڈیبگ سیٹنگز + اوپن سورس • پرائیویسی پہلے • غیر مرکزی + بند کریں + منسوخ کریں + + + خبردار + مقام کی خدمات + پرائیویسی + خرابی + بیٹری کی اصلاح + فوائد + بیٹری کی اصلاح چیک کر رہے ہیں + بیٹری کی اصلاح تعاون یافتہ نہیں + Bluetooth + غیر پڑھا پیغام + نقشہ کھولیں + بک مارک ہٹائیں + ٹیلی پورٹ + چینل چھوڑیں + Nostr کے ذریعے رسائی + پسندیدہ (آف لائن) + درستگی کم کریں + درستگی بڑھائیں + geohash منتخب کریں + نیچے اسکرول کریں + فائل + تصویر + منسوخ کریں + لنک + میڈیا منتخب کریں + فائل منتخب کریں + موجودہ تصویر محفوظ کریں + بند کریں + تصویر %1$d / %2$d + پرائیویسی محفوظ + وائس نوٹ ریکارڈ کریں + آف لائن میش چیٹ + آن لائن geohash چینلز + اینڈ‑ٹو‑اینڈ انکرپشن + + + تصویر %1$d / %2$d + تصویر دستیاب نہیں + تصویر "Downloads" میں محفوظ ہوئی + تصویر محفوظ نہیں ہو سکی + تصویر "Downloads" میں محفوظ ہوئی + تصویر محفوظ نہیں ہو سکی + فائل منتخب کریں + [فائل دستیاب نہیں] + نامعلوم + + + اس پیغام یا صارف کے لیے ایکشن منتخب کریں + اس صارف کے لیے ایکشن منتخب کریں + پیغام کاپی کریں + اس پیغام کو کلپ بورڈ میں کاپی کریں + %1$s کو تھپڑ + مزاحیہ تھپڑ بھیجیں + %1$s کو گلے لگائیں + دوستانہ آلنگن بھیجیں + %1$s کو بلاک کریں + اس صارف کے تمام پیغامات بلاک کریں + + + #مقام چینلز + geohash چینلز کے ذریعے اپنے قریب کے لوگوں سے چیٹ کریں۔ صرف ایک مبہم geohash شیئر کیا جاتا ہے، کبھی درست GPS ڈیٹا نہیں۔ اپنی پرائیویسی کے لیے، اس اسکرین کا اسکرین شاٹ نہ لیں اور اسے شیئر نہ کریں۔ + مقام کی اجازت دیں + مقام کی اجازت منع کی گئی۔ مقام چینلز استعمال کرنے کے لیے سیٹنگز میں فعال کریں۔ + سیٹنگز کھولیں + \u2713 مقام کی اجازت دی گئی + اجازات چیک کر رہے ہیں… + قریبی چینلز تلاش کر رہے ہیں… + بک مارکڈ + Geohash + غلط geohash + ٹیلی پورٹ + مقام کی خدمات بند کریں + مقام کی خدمات چالو کریں + mesh + بلاک + محلہ + شہر + صوبہ + علاقہ + #bluetooth • %1$s + + + ڈیبگ ٹولز + تشخیص اور کنٹرول کے لیے ڈیولپر ٹولز + تفصیلی لاگنگ + پیئر جوائن/لیو، کنیکشن ڈائریکشن، پیکٹ ریلے اور ریلے لاگ کرتا ہے + Bluetooth کردار + GATT سرور + کنیکشنز: %1$d / %2$d + زیادہ سے زیادہ سرور + GATT کلائنٹ + زیادہ سے زیادہ کلائنٹ + کنیکشنز: %1$d / %2$d + زیادہ سے زیادہ کل + پیکٹ ریلے + شروع سے: %1$d + کردار آن/آف ٹوگل کریں اور بند کرتے وقت تمام کنیکشنز بند کریں + سنک سیٹنگز + فی سنک زیادہ سے زیادہ پیکٹس: %1$d + زیادہ سے زیادہ GCS فلٹر سائز: %1$d بائٹس (128–1024) + ہدف FPR: %1$.2f%% + منسلک آلات + ہمارا آلہ ID: %1$s + کوئی نہیں + منقطع کریں + حال ہی میں اسکین شدہ آلات + کنیکٹ + ڈیبگ کنسول + صاف کریں + آخری 10 سیکنڈ: %1$d • 1 منٹ: %2$d • 15 منٹ: %3$d + ماخوذ P: %1$s • تخمینی زیادہ سے زیادہ آئٹمز: %2$s + • براہ راست + سرور کے طور پر (ہم ہوسٹ کرتے ہیں) + کلائنٹ کے طور پر (ہم کنیکٹ کرتے ہیں) + + + مقام کی خدمات ضروری + پرائیویسی پہلے + bitchat آپ کا مقام ٹریک نہیں کرتا۔\n\nBluetooth اسکیننگ اور geohash چیٹ فیچر کے لیے مقام کی خدمات ضروری ہیں۔ + bitchat کو مقام کی خدمات کی ضرورت: + • Bluetooth آلات اسکیننگ\n• میش نیٹ ورک میں قریبی صارفین کی دریافت\n• geohash چیٹ فیچر\n• کوئی ٹریکنگ یا مقام کا اجماع نہیں + مقام سیٹنگز کھولیں + دوبارہ چیک کریں + مقام کی خدمات دستیاب نہیں + اس آلے پر مقام کی خدمات دستیاب نہیں ہیں۔ یہ غیر معمولی ہے کیونکہ مقام کی خدمات Android آلات پر معیاری ہیں۔\n\nBluetooth اسکیننگ (Android کی ضرورت) کے ٹھیک سے کام کرنے کے لیے مقام کی خدمات ضروری ہیں۔ اس کے بغیر ایپ قریبی صارفین کو دریافت نہیں کر سکتی۔ + مقام کی خدمات چیک کر رہے ہیں… + Bluetooth ضروری + bitchat کو Bluetooth کی ضرورت: + • قریبی صارفین کی دریافت\n• میش نیٹ ورک کنیکشنز بنانا\n• پیغامات بھیجنا اور وصول کرنا\n• انٹرنیٹ یا سرورز کے بغیر کام کرنا + Bluetooth فعال کریں + Bluetooth تعاون یافتہ نہیں + یہ آلہ Bluetooth Low Energy (BLE) کو سپورٹ نہیں کرتا، جو bitchat کے کام کے لیے ضروری ہے۔\n\nBLE میش نیٹ ورکس بنانے اور انٹرنیٹ کے بغیر قریبی آلات کے ساتھ بات چیت کرنے کے لیے ضروری ہے۔ + Bluetooth کی کیفیت چیک کر رہے ہیں… + بیٹری کی اصلاح کا پتہ چلا + بیٹری کی اصلاح فعال + میش کنیکشنز برقرار رکھنے کے لیے bitchat کو پس منظر میں چلنا ضروری ہے۔ بیٹری کی اصلاح ان کنیکشنز کو خراب کر سکتی ہے۔ + بند کرنے کے فوائد + • قابل اعتماد پیغام کی ڈیلیوری\n• میش کنیکٹویٹی برقرار رکھنا\n• کنیکشن منقطع ہونے سے بچنا + بیٹری کی اصلاح بند کریں + بیٹری کی اصلاح بند + جاری رکھیں + میش نیٹ ورک شروع کر رہے ہیں + Bluetooth میش نیٹ ورک سیٹ کر رہے ہیں… + اس میں صرف چند سیکنڈ لگنے چاہیے + سیٹ اپ مکمل نہیں + دوبارہ کوشش کریں + آپ کی پرائیویسی محفوظ ہے + • کوئی ٹریکنگ یا ڈیٹا اجماع نہیں\n• Bluetooth میش چیٹس مکمل آف لائن\n• geohash چیٹس انٹرنیٹ استعمال کرتے ہیں + اجازات + اجازات دیں + bitchat آپ کا مقام ٹریک نہیں کرتا + یہاں کوئی نہیں… + (آپ) + geohash منتخب کرنے کے لیے پین/زوم کریں + منتخب کریں + پیغام لکھیں… + ذکر + v%1$s + image/* + تصویر + فائل + + + 📷 تصویر بھیجی + 🎤 وائس نوٹ بھیجا + 📎 فائل بھیجی + + + چلائیں + روکیں + + + بلوٹوتھ کے ذریعے bitchat صارفین کی دریافت کے لیے ضروری + bitchat کو قریبی آلات سے جڑنے کی اجازت دیں + Android کی ضرورت: بلوٹوتھ کے ذریعے قریبی bitchat صارفین کی دریافت + قریبی آلات اسکین کرنے کے لیے bitchat کو یہ چاہیے + نجی پیغامات ملنے پر نوٹیفیکیشن حاصل کریں + bitchat کو آپ کو نوٹیفیکیشن بھیجنے کی اجازت دیں + یقینی بنانے کے لیے کہ bitchat پس منظر میں قابل اعتماد طور پر چلے اور میش کنیکشنز برقرار رکھے، بیٹری کی اصلاح بند کریں + bitchat کو بیٹری کی پابندیوں کے بغیر چلنے کی اجازت دیں + + + قریبی آلات + درست مقام + مائیکروفون + نوٹیفیکیشنز + بیٹری کی اصلاح + دوسرے + + + چینل پاس ورڈ داخل کریں + چینل %1$s پاس ورڈ سے محفوظ ہے۔ شامل ہونے کے لیے پاس ورڈ داخل کریں۔ + پاس ورڈ + شامل ہوں + منسوخ کریں + + + + اور %d مزید + اور %d مزید + + + + 1 شخص + %d لوگ + + + + #%1$s ± 1 • %2$d نوٹ + #%1$s ± 1 • %2$d نوٹس + + دیگر زائرین کے لیے اس جگہ پر مختصر مستقل نوٹس شامل کریں۔ + جغرافیائی ریلے دستیاب نہیں؛ نوٹس معطل + قریب کوئی جغرافیائی ریلے نہیں + نوٹس جغرافیائی ریلے پر منحصر ہیں۔ کنکشن چیک کریں اور دوبارہ کوشش کریں۔ + نوٹس لوڈ ہو رہے ہیں… + ابھی تک کوئی نوٹس نہیں + اس جگہ کے لیے پہلا نوٹ شامل کریں۔ + بند کریں + اس جگہ کے لیے ایک نوٹ شامل کریں + Bluetooth Recommended + میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + تعارف کھولیں + دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں + یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں + یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں + زبان + ایپ کی زبان + سسٹم ڈیفالٹ + زبان منتخب کریں + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 00000000..b97c3fc5 --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,383 @@ + + + bitchat + + + Quyền Bluetooth là cần thiết để trò chuyện trực tiếp với những người khác mà không cần Internet. + Quyền vị trí là cần thiết để tìm các thiết bị gần đó qua Bluetooth. + Quyền thông báo là cần thiết để thông báo cho bạn về tin nhắn mới. + + + Biệt danh + Nhập tin nhắn… + Mật khẩu + Tham gia kênh + Rời khỏi + Gửi + Hiển thị lệnh + Quay lại + Mọi người + Kênh + Người dùng trực tuyến + Không có ai kết nối + Nhấn ba lần để xóa tất cả dữ liệu + Mạng lưới + + + Đã phát hiện tối ưu hóa pin + Tối ưu hóa pin đã tắt + Không cần tối ưu hóa pin + Đang kiểm tra tối ưu hóa pin + Tại sao nên tắt tối ưu hóa pin? + bitchat chạy nền để duy trì kết nối mesh với các thiết bị gần đó. Tối ưu hóa pin có thể làm gián đoạn các kết nối này, gây ra tin nhắn bị trễ hoặc mất.\n\nTắt tối ưu hóa pin đảm bảo liên lạc ngang hàng đáng tin cậy. + Tắt tối ưu hóa pin + Lưu ý: Bạn có thể thay đổi điều này sau trong Android Settings > Apps > bitchat > Battery. + Thiết bị của bạn không cần cài đặt tối ưu hóa pin. bitchat sẽ chạy bình thường. + Thiết bị của bạn không cần cài đặt tối ưu hóa pin. bitchat sẽ chạy bình thường. + bitchat có thể chạy đáng tin cậy trong nền + • Gửi tin nhắn đáng tin cậy\n• Duy trì kết nối mesh\n• Chuyển tiếp tin nhắn nền\n• Ngăn ngừa mất kết nối + Kiểm tra lại + Bỏ qua ngay + Tiếp tục + Thử lại + Bỏ qua + + + và %1$d khác + %1$d tin nhắn từ %2$d người + và %1$d cuộc trò chuyện khác + bitchatter gần đó! + 1 người gần đó + %1$d người gần đó + Tin nhắn mới + Tin nhắn vị trí mới + Được nhắc đến trong %1$s + Được nhắc đến trong %1$s (+%2$d khác) + %1$d lần nhắc đến trong %2$s + Hoạt động mới trong %1$s + Tin nhắn trong %1$s + %1$s đã tham gia cuộc trò chuyện + bitchat – %1$d lần nhắc đến + bitchat – Trò chuyện vị trí + %1$d tin nhắn từ %2$d vị trí + Được nhắc đến trong Mesh Chat + %1$d lần nhắc đến trong Mesh Chat + và %1$d vị trí khác + và %1$d khác + + + Thêm vào yêu thích + Xóa khỏi yêu thích + Thêm bookmark + + + Có thể tiếp cận qua Nostr + Tin nhắn riêng chưa đọc + Đã dịch chuyển + Mở cài đặt vị trí và kênh + Peers đã kết nối + Người tham gia geohash + Sẵn sàng cho handshake + Handshake đang tiến hành + Mã hóa đầu cuối + Handshake thất bại + + + 📎 File đã nhận + 📄 %1$s + 📏 Kích thước: %1$s + 🏷️ Loại: %1$s + 📂 Mở / Lưu + ❌ Đóng + Chọn hình ảnh + + + Trò chuyện Mesh ngoại tuyến + Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi. + Kênh Geohash trực tuyến + Kết nối với những người trong khu vực của bạn qua các kênh dựa trên geohash. Mở rộng mesh qua các relay Internet công cộng. + Mã hóa đầu cuối + Tin nhắn riêng được mã hóa. Tin nhắn kênh là công khai. + Hệ thống + Sáng + Tối + Proof of Work + PoW tắt + PoW bật + Thêm proof of work vào tin nhắn geohash để làm cho spam khó hơn. + Độ khó: %1$d bit (~%2$s) + Độ khó %1$d cần ~%2$s lần thử hash + không cần proof of work + rất thấp – bảo vệ spam tối thiểu + thấp – bảo vệ spam cơ bản + trung bình – bảo vệ spam tốt + cao – bảo vệ spam mạnh + rất cao – có thể gây độ trễ + cực cao – cần sức mạnh tính toán đáng kể + Mạng lưới + Tor tắt + Tor bật + Định tuyến lưu lượng Internet qua Tor để có thêm quyền riêng tư. + Trạng thái Tor: %1$s, Bootstrap %2$d%% + Cuối cùng: %1$s + Xóa dữ liệu khẩn cấp + Cài đặt debug + Mã nguồn mở • Quyền riêng tư trước tiên • Phi tập trung + Đóng + hủy + + + Cảnh báo + Dịch vụ vị trí + Quyền riêng tư + Lỗi + Tối ưu hóa pin + Lợi ích + Đang kiểm tra tối ưu hóa pin + Tối ưu hóa pin không được hỗ trợ + Bluetooth + Tin nhắn chưa đọc + Mở bản đồ + Xóa bookmark + Dịch chuyển + Rời khỏi kênh + Có thể tiếp cận qua Nostr + Yêu thích (ngoại tuyến) + Giảm độ chính xác + Tăng độ chính xác + Chọn geohash + Cuộn xuống dưới + File + Hình ảnh + Hủy + Liên kết + Chọn media + Chọn file + Lưu hình ảnh hiện tại + Đóng + Hình ảnh %1$d trong %2$d + Quyền riêng tư được bảo vệ + Ghi tin nhắn thoại + Trò chuyện Mesh ngoại tuyến + Kênh Geohash trực tuyến + Mã hóa đầu cuối + + + Hình ảnh %1$d trong %2$d + Hình ảnh không có sẵn + Hình ảnh đã lưu vào \"Downloads\" + Không thể lưu hình ảnh + Hình ảnh đã lưu vào \"Downloads\" + Không thể lưu hình ảnh + Chọn file + [File không có sẵn] + không rõ + + + Chọn hành động cho tin nhắn hoặc người dùng này + Chọn hành động cho người dùng này + Sao chép tin nhắn + Sao chép tin nhắn này vào clipboard + Tát %1$s + Gửi một cái tát vui nhộn + Ôm %1$s + Gửi một cái ôm thân thiện + Chặn %1$s + Chặn tất cả tin nhắn từ người dùng này + + + #Kênh vị trí + Trò chuyện với những người gần bạn qua các kênh geohash. Chỉ geohash thô được chia sẻ, không phải dữ liệu GPS chính xác. Vui lòng không chụp màn hình hoặc chia sẻ chế độ xem này để bảo vệ quyền riêng tư. + Cấp quyền vị trí + Quyền vị trí bị từ chối. Hãy bật trong Cài đặt để sử dụng kênh vị trí. + Mở Cài đặt + \u2713 Đã cấp quyền vị trí + Đang kiểm tra quyền … + Đang tìm kênh gần đó … + Đã bookmark + Geohash + Geohash không hợp lệ + dịch chuyển + Tắt dịch vụ vị trí + Bật dịch vụ vị trí + mesh + Khối + Khu phố + Thành phố + Tỉnh + Khu vực + #bluetooth • %1$s + + + Công cụ debug + Công cụ nhà phát triển cho chẩn đoán và điều khiển + ghi log chi tiết + Ghi log peer join/leave, hướng kết nối, chuyển tiếp gói và relay + Vai trò Bluetooth + GATT Server + Kết nối: %1$d / %2$d + server tối đa + GATT Client + client tối đa + Kết nối: %1$d / %2$d + tối đa tổng + Packet Relay + từ lúc bắt đầu: %1$d + Bật/tắt vai trò và đóng tất cả kết nối khi tắt + Cài đặt sync + gói tối đa mỗi sync: %1$d + kích thước bộ lọc GCS tối đa: %1$d Byte (128–1024) + FPR mục tiêu: %1$.2f%% + thiết bị đã kết nối + ID thiết bị của chúng tôi: %1$s + không có + ngắt kết nối + thiết bị được quét gần đây + kết nối + Console debug + xóa + 10s cuối: %1$d • 1m: %2$d • 15m: %3$d + P dẫn xuất: %1$s • phần tử tối đa ước tính: %2$s + • trực tiếp + làm server (chúng tôi host) + làm client (chúng tôi kết nối) + + + Yêu cầu dịch vụ vị trí + Quyền riêng tư trước tiên + bitchat KHÔNG theo dõi vị trí của bạn.\n\nDịch vụ vị trí cần thiết cho quét Bluetooth và tính năng geohash chat. + bitchat cần dịch vụ vị trí cho: + • Quét thiết bị Bluetooth\n• Khám phá người dùng gần đó trong mạng mesh\n• Tính năng geohash chat\n• Không theo dõi hoặc thu thập vị trí + Mở cài đặt vị trí + Kiểm tra lại + Dịch vụ vị trí không có sẵn + Dịch vụ vị trí không có sẵn trên thiết bị này. Điều này bất thường vì dịch vụ vị trí là tiêu chuẩn trên thiết bị Android.\n\nbitchat cần dịch vụ vị trí để quét Bluetooth hoạt động đúng (yêu cầu Android). Không có nó, ứng dụng không thể khám phá người dùng gần đó. + Đang kiểm tra dịch vụ vị trí … + Yêu cầu Bluetooth + bitchat cần Bluetooth cho: + • Khám phá người dùng gần đó\n• Tạo kết nối mạng mesh\n• Gửi và nhận tin nhắn\n• Hoạt động mà không cần Internet hoặc máy chủ + Bật Bluetooth + Bluetooth không được hỗ trợ + Thiết bị này không hỗ trợ Bluetooth Low Energy (BLE), cần thiết để bitchat hoạt động.\n\nbitchat cần BLE để tạo mạng mesh và liên lạc với thiết bị gần đó mà không cần Internet. + Đang kiểm tra trạng thái Bluetooth … + Đã phát hiện tối ưu hóa pin + Tối ưu hóa pin đã bật + bitchat phải chạy nền để duy trì kết nối mesh. Tối ưu hóa pin có thể làm gián đoạn các kết nối này. + Lợi ích của việc tắt + • gửi tin nhắn đáng tin cậy\n• duy trì kết nối mesh\n• ngăn ngừa mất kết nối + Tắt tối ưu hóa pin + Tối ưu hóa pin đã tắt + Tiếp tục + Đang khởi tạo mạng mesh + Đang thiết lập mạng Bluetooth mesh … + Điều này chỉ mất vài giây + Thiết lập chưa hoàn thành + Thử lại + Quyền riêng tư của bạn được bảo vệ + • không theo dõi hoặc thu thập dữ liệu\n• Bluetooth mesh chat hoàn toàn ngoại tuyến\n• Geohash chat sử dụng Internet + Quyền + Cấp quyền + bitchat KHÔNG theo dõi vị trí của bạn + không có ai xung quanh … + (bạn) + di chuyển và zoom để chọn geohash + chọn + Nhập tin nhắn … + nhắc đến + v%1$s + image/* + Hình ảnh + File + + + 📷 đã gửi hình ảnh + 🎤 đã gửi tin nhắn thoại + 📎 đã gửi file + + + Phát + Tạm dừng + + + Cần thiết để khám phá người dùng bitchat qua Bluetooth + Cho phép bitchat kết nối với thiết bị gần đó + Android yêu cầu để khám phá người dùng bitchat gần đó qua Bluetooth + bitchat cần điều này để quét thiết bị gần đó + Nhận thông báo khi bạn nhận tin nhắn riêng + Cho phép bitchat gửi thông báo cho bạn + Tắt tối ưu hóa pin để đảm bảo bitchat chạy đáng tin cậy trong nền và duy trì kết nối mạng mesh + Cho phép bitchat chạy mà không bị giới hạn pin + + + Thiết bị gần đó + Vị trí chính xác + Microphone + Thông báo + Tối ưu hóa pin + Khác + + + Nhập mật khẩu kênh + Kênh %1$s được bảo vệ bằng mật khẩu. Nhập mật khẩu để tham gia. + Mật khẩu + Tham gia + Hủy + + + + và %d khác + và %d khác + + + + 1 người + %d người + + + Khuyên dùng Bluetooth + Mesh đang chạy — %1$d ngang hàng + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + Mở phần Giới thiệu + kiểm tra ghi chú để lại ở đây + có 1 ghi chú để lại ở đây — chạm để đọc + có %d ghi chú để lại ở đây — chạm để đọc + Ngôn ngữ + Ngôn ngữ ứng dụng + Mặc định hệ thống + Chọn ngôn ngữ + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000..fca6b07b --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,391 @@ + + + + + + #%1$s ± 1 • %2$d 条笔记 + #%1$s ± 1 • %2$d 条笔记 + + 为此位置添加简短的永久备注,方便其他访客查看。 + 地理中继不可用;笔记已暂停 + 附近没有地理中继 + 笔记依赖地理中继。请检查连接后重试。 + 正在加载笔记… + 还没有笔记 + 成为第一个为此地点添加笔记的人。 + 关闭 + 为此地点添加一条笔记 + 跳过 + 建议开启蓝牙 + Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s + + bitchat + 需要蓝牙权限才能在无网络的情况下进行点对点消息传输。 + 需要位置权限才能通过蓝牙发现附近的设备。 + 需要通知权限才能提醒你收到新消息。 + 昵称 + 输入消息… + 密码 + 加入频道 + 离开 + 发送 + 显示命令 + 返回 + 人员 + 频道 + 在线用户 + 暂无人连接 + 连续三次轻触可清除所有数据 + 网络 + 检测到电池优化 + 电池优化已关闭 + 无需电池优化设置 + 正在检查电池优化设置 + 为什么要关闭电池优化? + bitchat 需要在后台运行,以保持与附近设备的 Mesh 网络连接。电池优化可能会中断这些连接,导致消息延迟或丢失。\n\n关闭电池优化可确保点对点消息传输的可靠性。 + 关闭电池优化 + 提示:你可以稍后在 Android 设置 > 应用 > bitchat > 电池 中更改此设置 + 你的设备不需要电池优化设置。bitchat 将正常运行。 + 你的设备不需要电池优化设置。bitchat 将正常运行。 + bitchat 现在可以在后台稳定运行 + • 确保消息可靠送达\n• 保持 Mesh 网络连接\n• 支持后台消息中继\n• 防止连接中断 + 再次检查 + 暂时跳过 + 继续 + 重试 + 还有 %1$d 条 + 来自 %2$d 人的 %1$d 条消息 + 还有 %1$d 个对话 + 👥 附近有 bitchat 用户! + 附近有 1 人 + 附近有 %1$d 人 + 新消息 + 新的位置消息 + 在 %1$s 中被提及 + 在 %1$s 中被提及(还有 %2$d 条) + 在 %2$s 中被提及 %1$d 次 + %1$s 中有新动态 + %1$s 中的消息 + %1$s 加入了对话 + bitchat - %1$d 条提及 + bitchat - 位置聊天 + 来自 %2$d 个地点的 %1$d 条消息 + 在 Mesh 聊天中被提及 + 在 Mesh 聊天中被提及 %1$d 次 + 还有 %1$d 个地点 + + 还有 %1$d 条 + + Mesh 后台服务 + 使蓝牙 Mesh 网络在后台持续运行 + 添加到收藏 + 从收藏中移除 + 添加书签 + 可通过 Nostr 联系 + 未读私信 + 位置笔记 + 已传送 + 已连接的节点 + Geohash 参与者 + 准备握手 + 握手进行中 + 端到端加密 + 握手失败 + 📎 已接收文件 + 📄 %1$s + 📏 大小:%1$s + 🏷️ 类型:%1$s + 📂 打开/保存 + ❌ 关闭 + 选择图片 + 保存当前图片 + 关闭 + 图片已保存到下载文件夹 + 图片保存失败 + 第 %1$d 张,共 %2$d 张图片 + 选择文件 + 具备端到端加密的去中心化 Mesh 消息应用 + 离线 Mesh 聊天 + 无需互联网或服务器,直接通过蓝牙 LE 通信。消息会通过附近的设备中继以扩大覆盖范围。 + 在线 Geohash 频道 + 使用基于 Geohash 的频道与你所在地区的人联系。通过公共互联网中继扩展 Mesh 网络。 + 端到端加密 + 私信是加密的。频道消息是公开的。 + 跟随系统 + 浅色 + 深色 + 工作量证明 + 工作量证明已关闭 + 工作量证明已开启 + 为 Geohash 消息添加工作量证明以遏制垃圾信息。 + 难度:%1$d 位(约 %2$s) + 难度 %1$d 需要约 %2$s 次哈希尝试 + 无需工作量证明 + 极低 - 最基础的垃圾信息防护 + 低 - 基础的垃圾信息防护 + 中 - 较好的垃圾信息防护 + 高 - 强力的垃圾信息防护 + 极高 - 可能导致延迟 + 极限 - 需要大量计算 + 网络 + Tor 已关闭 + Tor 已开启 + 通过 Tor 转发网络流量以增强隐私保护。 + Tor 状态:%1$s,引导进度 %2$d%% + 最近一次:%1$s + 紧急数据删除 + 调试设置 + 开源 • 隐私优先 • 去中心化 + 关闭 + 隐私已受保护 + 取消 + 警告 + 位置服务 + 隐私 + 错误 + 电池优化 + 优势 + 正在检查电池优化设置 + 不支持电池优化设置 + 蓝牙 + 未读消息 + 打开地图 + 移除书签 + 传送 + 退出 bitchat + 后台运行 + 应用关闭时保持 Mesh 处于活动状态(前台服务) + 离开频道 + 可通过 Nostr 联系 + 离线收藏 + 降低精度 + 提高精度 + 选择 Geohash + 滚动到底部 + 文件 + 图片 + 取消 + 链接 + 录制语音消息 + 选择媒体 + 离线 Mesh 聊天 + 在线 Geohash 频道 + 端到端加密 + #bluetooth • %1$s + 第 %1$d 张,共 %2$d 张图片 + 图片不可用 + 图片已保存到下载文件夹 + 图片保存失败 + 选择文件 + [文件不可用] + 未知 + 为此消息或用户选择一个操作 + 为此用户选择一个操作 + 复制消息 + 将此消息复制到剪贴板 + 拍打 %1$s + 发送一条俏皮的拍打消息 + 拥抱 %1$s + 发送一条友好的拥抱消息 + 屏蔽 %1$s + 屏蔽此用户的所有消息 + 私信 %1$s + 发送一条私信 + #位置频道 + 使用 Geohash 频道与你附近的人聊天。仅分享粗略的 Geohash,绝不分享精确的 GPS 位置。请勿截图或分享此屏幕,以保护你的隐私。 + 授予位置权限 + 位置权限已被拒绝。请在设置中启用以使用位置频道。 + ✓ 位置权限已授予 + 正在检查权限… + 正在查找附近的频道… + 已加书签 + geohash + 无效的 geohash + 传送 + 关闭位置服务 + 开启位置服务 + mesh + 街区 + 社区 + 城市 + 省份 + 地区 + 调试工具 + 用于诊断和控制的开发者工具 + 详细日志 + 记录节点加入/离开、连接方向、数据包路由与中继情况 + 蓝牙角色 + GATT 服务端 + 连接数:%1$d / %2$d + 最大服务端数 + GATT 客户端 + 最大客户端数 + 连接数:%1$d / %2$d + 最大总连接数 + 数据包中继 + 启动以来:%1$d + 开启/关闭角色,禁用时将关闭所有连接 + 同步设置 + 每次同步最大数据包数:%1$d + 最大 GCS 过滤器大小:%1$d 字节(128–1024) + 目标 FPR:%1$.2f%% + 已连接设备 + 本机设备 ID:%1$s + 无 + 断开连接 + 最近的扫描结果 + 连接 + 调试控制台 + 清除 + 最近 10 秒:%1$d • 1 分钟:%2$d • 15 分钟:%3$d + 推导 P:%1$s • 预估最大元素数:%2$s + • 直连 + RSSI:%1$s + ? + 作为服务端(我方托管) + 作为客户端(我方连接) + 需要位置服务 + 隐私优先 + bitchat 不会追踪你的位置。\n\n蓝牙扫描以及 Geohash 聊天功能需要位置服务的支持。 + bitchat 需要位置服务以实现: + • 蓝牙设备扫描\n• 发现 Mesh 网络上的附近用户\n• Geohash 聊天功能\n• 不进行任何追踪或位置收集 + 建议开启后台位置 + 可选,可提升 Mesh 可靠性 + Android 建议开启后台位置权限,以便 bitchat 在应用未打开时也能扫描附近设备。这能让 Mesh 网络在重启后保持活跃。 + 设置页面打开后,请选择"始终允许"。 + bitchat 使用后台位置以实现: + - 应用关闭时扫描附近设备\n- 重启后重新连接\n- 使 Mesh 网络在后台持续运行 + 我们绝不会收集或存储你的位置信息。你的隐私是安全的。 + 允许后台位置访问 + 不开启后台位置,继续使用 + 打开位置设置 + 再次检查 + 位置服务不可用 + 此设备上的位置服务不可用。这种情况并不常见,因为位置服务通常是 Android 设备的标准功能。\n\nbitchat 需要位置服务才能正常使用蓝牙扫描(这是 Android 的要求)。缺少此权限,应用将无法发现附近的用户。 + 正在检查位置服务… + 需要蓝牙 + bitchat 需要蓝牙以实现: + • 发现附近的用户\n• 建立 Mesh 网络连接\n• 发送和接收消息\n• 在无网络或服务器的情况下工作 + 开启蓝牙 + 不支持蓝牙 + 此设备不支持 bitchat 所需的低功耗蓝牙(BLE)。\n\nbitchat 需要 BLE 来创建 Mesh 网络并在无网络的情况下与附近的设备通信。 + 正在检查蓝牙状态… + 检测到电池优化 + 电池优化已启用 + bitchat 需要在后台运行以维持 Mesh 连接。电池优化可能会中断这些连接。 + 关闭后的好处 + • 消息可靠送达\n• 保持 Mesh 网络连接\n• 防止连接中断 + 关闭电池优化 + 电池优化已关闭 + 继续 + 正在初始化 Mesh 网络 + 。 + 正在设置蓝牙 Mesh 网络… + 这只需要几秒钟 + ⚠️ + 设置未完成 + 重试 + 打开设置 + 你的隐私已受保护 + • 不进行任何追踪或数据收集\n• 蓝牙 Mesh 聊天完全离线\n• Geohash 聊天使用互联网 + 权限 + 授予权限 + bitchat 不会追踪你的位置 + @ + · ⧉ + 附近没有人… + (你) + 拖动并缩放以选择一个 geohash + 选择 + 输入消息… + @%1$s + 提及 + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + 图片 + 文件 + 📷 发送了一张图片 + 🎤 发送了一条语音消息 + 📎 发送了一个文件 + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + 播放 + 暂停 + 需要此权限才能通过蓝牙发现 bitchat 用户 + 允许 bitchat 连接附近的设备 + Android 要求此权限以通过蓝牙发现附近的 bitchat 用户 + bitchat 需要此权限以扫描附近的设备 + 建议开启,以便在应用处于后台时扫描附近的设备 + 允许后台位置访问 + 在你收到私信时接收通知 + 允许 bitchat 向你发送通知 + 关闭电池优化以确保 bitchat 在后台稳定运行并保持 Mesh 网络连接 + 允许 bitchat 在不受电池限制的情况下运行 + 附近的设备 + 精确位置 + 后台位置 + 麦克风 + 通知 + 电池优化 + 其他 + 输入频道密码 + 频道 %1$s 受密码保护。请输入密码以加入。 + 密码 + 加入 + 取消 + 此版本不支持 Tor + + %1$d 人 + + 语言 + 应用语言 + 跟随系统 + 选择语言 + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 00000000..f427dbaf --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,392 @@ + + + + + + #%1$s ± 1 • %2$d 則筆記 + #%1$s ± 1 • %2$d 則筆記 + + 為此位置新增簡短永久備註,供其他訪客查看。 + 地理中繼無法使用;筆記已暫停 + 附近沒有地理中繼 + 筆記依賴地理中繼。請檢查連線後重試。 + 正在載入筆記… + 尚無筆記 + 成為第一個為此地點新增筆記的人。 + 關閉 + 為此地點新增一則筆記 + 跳過 + 建議開啟藍牙 + Mesh 運行中 — %1$d 個節點 + + 驗證 + 掃描以驗證我 + 掃描他人的QR碼 + 掃描他人的QR碼 + 顯示我的QR碼 + 移除驗證 + QR碼不可用 + 需要相機權限掃描QR碼 + 啟用相機 + 貼上驗證連結 + 驗證 + 已請求驗證 + 安全驗證 + 對方指紋 + 你的指紋 + 握手中 + 打開私聊查看指紋 + 已加密且已驗證 + 已加密 + 握手中 + 握手失敗 + 未加密 + 已驗證 + 你已驗證此人身份。 + 未驗證 + 請通過安全渠道與 %1$s 比對指紋。 + 標記為已驗證 + 開始握手 + 複製 + 相互驗證 + 你和 %1$s 已相互驗證 + 與 %1$s 相互驗證 + 已驗證 + 你已驗證 %1$s + 已驗證 %1$s + + bitchat + 點對點傳訊需要藍牙權限,無需網際網路。 + 需要位置權限才能透過藍牙探索附近的裝置。 + 需要通知權限才能提醒你有新訊息。 + 暱稱 + 輸入訊息… + 密碼 + 加入頻道 + 離開 + 傳送 + 顯示指令 + 返回 + 使用者 + 頻道 + 線上使用者 + 目前沒有人連線 + 連續點三下以清除所有資料 + 網路 + 偵測到電池最佳化 + 已停用電池最佳化 + 不需要電池最佳化 + 正在檢查電池最佳化 + 為什麼要停用電池最佳化? + bitchat 需要在背景執行,以維持與附近裝置的 mesh 網路連線。電池最佳化可能會中斷這些連線,導致訊息延遲或遺失。\n\n停用電池最佳化可確保可靠的點對點傳訊。 + 停用電池最佳化 + 注意:你之後可以在 Android 設定 > 應用程式 > bitchat > 電池 中變更此設定 + 你的裝置不需要電池最佳化設定。bitchat 將正常運作。 + 你的裝置不需要電池最佳化設定。bitchat 將正常運作。 + bitchat 可以在背景可靠地運作 + • 確保訊息可靠送達\n• 維持 mesh 網路連線\n• 允許背景訊息中繼\n• 防止連線中斷 + 再次檢查 + 暫時略過 + 繼續 + 重試 + 還有 %1$d 則 + 來自 %2$d 位使用者的 %1$d 則訊息 + 還有 %1$d 個對話 + 👥 附近有 bitchat 使用者! + 附近有 1 人 + 附近有 %1$d 人 + 新訊息 + 新位置訊息 + 在 %1$s 中被提及 + 在 %1$s 中被提及(還有 %2$d 則) + 在 %2$s 中有 %1$d 則提及 + %1$s 中有新動態 + %1$s 中的訊息 + %1$s 加入了對話 + bitchat - %1$d 則提及 + bitchat - 位置聊天 + 來自 %2$d 個位置的 %1$d 則訊息 + 在 Mesh 聊天中被提及 + 在 Mesh 聊天中有 %1$d 則提及 + 還有 %1$d 個位置 + + 還有 %1$d 則 + + Mesh 背景服務 + 讓藍牙 mesh 網路在背景持續運作 + 加入最愛 + 從最愛移除 + 新增書籤 + 可透過 Nostr 連線 + 未讀私訊 + 位置筆記 + 已傳送 + 已連線的節點 + 地理雜湊參與者 + 準備好進行握手 + 握手進行中 + 端對端加密 + 握手失敗 + 📎 已收到檔案 + 📄 %1$s + 📏 大小:%1$s + 🏷️ 類型:%1$s + 📂 開啟/儲存 + ❌ 關閉 + 選擇圖片 + 儲存目前的圖片 + 關閉 + 圖片已儲存至下載資料夾 + 圖片儲存失敗 + 圖片 %1$d/%2$d + 選擇檔案 + 具端對端加密的去中心化 mesh 傳訊 + 離線 Mesh 聊天 + 透過藍牙 LE 直接通訊,無需網際網路或伺服器。訊息會透過附近的裝置中繼以擴大範圍。 + 線上地理雜湊頻道 + 使用基於地理雜湊的頻道與你所在地區的人連線。透過公共網際網路中繼擴充 mesh 網路。 + 端對端加密 + 私訊經過加密。頻道訊息是公開的。 + 系統 + 淺色 + 深色 + 工作量證明 + 工作量證明已關閉 + 工作量證明已開啟 + 為地理雜湊訊息新增工作量證明以遏止垃圾訊息。 + 難度:%1$d 位元(約 %2$s) + 難度 %1$d 需要約 %2$s 次雜湊嘗試 + 不需要工作量證明 + 極低 - 最低限度的垃圾訊息防護 + 低 - 基本垃圾訊息防護 + 中 - 良好的垃圾訊息防護 + 高 - 強力垃圾訊息防護 + 極高 - 可能造成延遲 + 極端 - 需要大量運算 + 網路 + Tor 已關閉 + Tor 已開啟 + 透過 Tor 路由網際網路流量以加強隱私。 + Tor 狀態:%1$s,啟動進度 %2$d%% + 上次:%1$s + 緊急資料刪除 + 除錯設定 + 開放原始碼 • 隱私優先 • 去中心化 + 關閉 + 隱私已受保護 + 取消 + 警告 + 位置服務 + 隱私 + 錯誤 + 電池最佳化 + 優點 + 正在檢查電池最佳化 + 不支援電池最佳化 + 藍牙 + 未讀訊息 + 開啟地圖 + 移除書籤 + 傳送 + 結束 bitchat + 在背景執行 + 應用程式關閉時保持 mesh 網路運作中(前景服務) + 離開頻道 + 可透過 Nostr 連線 + 離線最愛 + 降低精確度 + 提高精確度 + 選擇地理雜湊 + 捲動到底部 + 檔案 + 圖片 + 取消 + 連結 + 錄製語音訊息 + 選擇媒體 + 離線 Mesh 聊天 + 線上地理雜湊頻道 + 端對端加密 + #藍牙 • %1$s + 圖片 %1$d/%2$d + 圖片無法使用 + 圖片已儲存至下載資料夾 + 圖片儲存失敗 + 選擇檔案 + [檔案無法使用] + 未知 + 為此訊息或使用者選擇一項操作 + 為此使用者選擇一項操作 + 複製訊息 + 將此訊息複製到剪貼簿 + 拍打 %1$s + 傳送一則有趣的拍打訊息 + 擁抱 %1$s + 傳送一則友善的擁抱訊息 + 封鎖 %1$s + 封鎖來自此使用者的所有訊息 + 傳訊給 %1$s + 傳送一則私訊 + #位置頻道 + 使用地理雜湊頻道與你附近的人聊天。僅分享粗略的地理雜湊,絕不分享精確的 GPS 位置。請勿對此畫面截圖或分享,以保護你的隱私。 + 授予位置權限 + 位置權限已遭拒。請至設定中啟用以使用位置頻道。 + ✓ 位置權限已授予 + 正在檢查權限… + 正在尋找附近的頻道… + 已加入書籤 + 地理雜湊 + 無效的地理雜湊 + 傳送 + 停用位置服務 + 啟用位置服務 + mesh + 街區 + 鄰里 + 城市 + 省份 + 地區 + 除錯工具 + 用於診斷與控制的開發者工具 + 詳細記錄 + 記錄節點加入/離開、連線方向、封包路由與中繼 + 藍牙角色 + GATT 伺服器 + 連線數:%1$d/%2$d + 最大伺服器數 + GATT 客戶端 + 最大客戶端數 + 連線數:%1$d/%2$d + 最大總數 + 封包中繼 + 自啟動以來:%1$d + 開啟/關閉角色,停用時會關閉所有連線 + 同步設定 + 每次同步最大封包數:%1$d + 最大 GCS 篩選器大小:%1$d 位元組(128–1024) + 目標 FPR:%1$.2f%% + 已連線的裝置 + 我們的裝置 ID:%1$s + 無 + 中斷連線 + 最近的掃描結果 + 連線 + 除錯主控台 + 清除 + 過去 10 秒:%1$d • 1 分鐘:%2$d • 15 分鐘:%3$d + 推導的 P:%1$s • 估計最大元素數:%2$s + • 直連 + RSSI:%1$s + ? + 作為伺服器(我們主機) + 作為客戶端(我們連線) + 需要位置服務 + 隱私優先 + bitchat 不會追蹤你的位置。\n\n藍牙掃描與地理雜湊聊天功能都需要位置服務。 + bitchat 需要位置服務以進行: + • 藍牙裝置掃描\n• 在 mesh 網路上探索附近的使用者\n• 地理雜湊聊天功能\n• 不會追蹤或收集位置資訊 + 建議開啟背景位置 + 選用,可提升 mesh 網路可靠性 + Android 建議開啟背景位置,讓 bitchat 在應用程式未開啟時也能掃描附近的裝置。這能讓 mesh 網路在重新開機後持續運作。 + 開啟設定後,請選擇「一律允許」。 + bitchat 使用背景位置以進行: + - 在應用程式關閉時掃描附近的裝置\n- 重新開機後重新連線\n- 讓 mesh 網路在背景持續運作 + 我們絕不會收集或儲存你的位置。你的隱私安全無虞。 + 允許背景位置 + 不使用背景位置繼續 + 開啟位置設定 + 再次檢查 + 位置服務無法使用 + 此裝置上無法使用位置服務。這種情況並不常見,因為位置服務在 Android 裝置上是標準功能。\n\nbitchat 需要位置服務才能讓藍牙掃描正常運作(Android 的要求)。若沒有此功能,應用程式將無法探索附近的使用者。 + 正在檢查位置服務… + 需要藍牙 + bitchat 需要藍牙以進行: + • 探索附近的使用者\n• 建立 mesh 網路連線\n• 傳送與接收訊息\n• 無需網際網路或伺服器即可運作 + 啟用藍牙 + 不支援藍牙 + 此裝置不支援 bitchat 運作所需的藍牙低功耗(BLE)。\n\nbitchat 需要 BLE 才能建立 mesh 網路,並在無網際網路的情況下與附近裝置通訊。 + 正在檢查藍牙狀態… + 偵測到電池最佳化 + 電池最佳化已啟用 + bitchat 需要在背景執行以維持 mesh 連線。電池最佳化可能會中斷這些連線。 + 停用的好處 + • 可靠的訊息送達\n• 維持 mesh 連線\n• 防止連線中斷 + 停用電池最佳化 + 電池最佳化已停用 + 繼續 + 正在初始化 mesh 網路 + 。 + 正在設定藍牙 mesh 網路… + 這應該只需要幾秒鐘 + ⚠️ + 設定尚未完成 + 再試一次 + 開啟設定 + 你的隱私已受保護 + • 不追蹤或收集資料\n• 藍牙 mesh 聊天完全離線\n• 地理雜湊聊天使用網際網路 + 權限 + 授予權限 + bitchat 不會追蹤你的位置 + @ + · ⧉ + 附近沒有人… + (你) + 平移並縮放以選擇地理雜湊 + 選擇 + 輸入訊息… + @%1$s + 提及 + %1$d/%2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + 圖片 + 檔案 + 📷 傳送了一張圖片 + 🎤 傳送了一則語音訊息 + 📎 傳送了一個檔案 + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + 播放 + 暫停 + 需要此權限才能透過藍牙探索 bitchat 使用者 + 允許 bitchat 連線至附近的裝置 + Android 要求此權限,才能透過藍牙探索附近的 bitchat 使用者 + bitchat 需要此權限才能掃描附近的裝置 + 建議開啟,讓應用程式在背景時也能掃描附近的裝置 + 允許背景位置存取 + 在你收到私訊時接收通知 + 允許 bitchat 傳送通知給你 + 停用電池最佳化,以確保 bitchat 能在背景可靠地運作並維持 mesh 網路連線 + 允許 bitchat 在不受電池限制的情況下運作 + 附近的裝置 + 精確位置 + 背景位置 + 麥克風 + 通知 + 電池最佳化 + 其他 + 輸入頻道密碼 + 頻道 %1$s 受密碼保護。請輸入密碼以加入。 + 密碼 + 加入 + 取消 + 此版本不支援 Tor + + + %1$d 人 + + 語言 + 應用程式語言 + 跟隨系統 + 選擇語言 + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f7097776..35f7477a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1,20 +1,407 @@ bitchat - 无需网络连接即可进行点对点消息传递,需要蓝牙权限。 - 需要位置权限才能通过蓝牙发现附近的设备。 - 需要通知权限才能提醒您有新消息。 + + + 需要蓝牙权限以在无网络时进行点对点聊天。 + 需要位置信息权限以通过蓝牙发现附近设备。 + 需要通知权限以提醒你有新消息。 + + 昵称 输入消息… 密码 加入频道 离开 发送 + 显示命令 返回 - 人员 + 成员 频道 在线用户 无人连接 - 连按三次清除所有数据 - 您的网络 + 三击以清除所有数据 + 网络 + + + 检测到电池优化 + 电池优化已禁用 + 无需电池优化 + 正在检查电池优化 + 为何禁用电池优化? + bitchat 在后台运行以保持与附近设备的 mesh 连接。电池优化可能中断连接,导致消息延迟或丢失。\n\n禁用可确保可靠的点对点消息。 + 禁用电池优化 + 备注:稍后可在 设置 > 应用 > bitchat > 电池 中更改。 + 你的设备不需要电池优化设置。bitchat 将正常运行。 + 你的设备不需要电池优化设置。bitchat 将正常运行。 + bitchat 可在后台可靠运行 + • 可靠投递消息\n• 维持 mesh 连接\n• 后台转发\n• 避免断连 + 重新检查 + 暂时跳过 + 继续 + 重试 + 跳过 + + + 以及另外 %1$d 条 + 来自 %2$d 位的 %1$d 条消息 + 以及另外 %1$d 个会话 + 附近的 bitchatter! + 附近 1 人 + 附近 %1$d 人 + 新消息 + 新的地点消息 + 在 %1$s 中被提及 + 在 %1$s 中被提及(另有 %2$d 条) + 在 %2$s 中有 %1$d 次提及 + %1$s 中有新动态 + %1$s 中的消息 + %1$s 加入了会话 + bitchat – %1$d 次提及 + bitchat – 地点聊天 + 来自 %2$d 个地点的 %1$d 条消息 + 在 Mesh 聊天中被提及 + 在 Mesh 聊天中有 %1$d 次提及 + 以及另外 %1$d 个地点 + 以及另外 %1$d 条 + + + 加入收藏 + 移出收藏 + 添加书签 + + + 可通过 Nostr 联系 + 未读私信 + 已传送 + 打开位置和频道设置 + 已连接节点 + Geohash 参与者 + 准备握手 + 握手中 + 端到端加密 + 握手失败 + + + 📎 收到文件 + 📄 %1$s + 📏 大小:%1$s + 🏷️ 类型:%1$s + 📂 打开 / 保存 + ❌ 关闭 + 选择图片 + + + 离线 Mesh 聊天 + 无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。 + 在线 Geohash 频道 + 使用基于 geohash 的频道与附近的人交流。通过公共互联网中继扩展 mesh。 + 端到端加密 + 私信加密;频道消息为公开。 + 系统 + 浅色 + 深色 + 工作量证明 + pow 关 + pow 开 + 为 geohash 消息添加工作量证明以抑制垃圾信息。 + 难度:%1$d 位 (~%2$s) + 难度 %1$d 约需 ~%2$s 次哈希尝试 + 无需工作量证明 + 很低——最低防护 + 较低——基础防护 + 中等——良好防护 + 高——强防护 + 很高——可能延迟 + 极高——需要大量计算 + 网络 + tor 关 + tor 开 + 通过 Tor 路由互联网以提升隐私。 + Tor 状态:%1$s,引导 %2$d%% + 最近:%1$s + 紧急数据删除 + 调试设置 + Open Source • Privacy First • 去中心化 + 关闭 + 取消 + + + 警告 + 定位服务 + 隐私 + 错误 + 电池优化 + 好处 + 检查电池优化 + 不支持 + Bluetooth + 未读消息 + 打开地图 + 移除书签 + 传送 + 离开频道 + 可通过 Nostr 联系 + 收藏(离线) + 降低精度 + 提高精度 + 选择 geohash + 滚动到底部 + 文件 + 图片 + 取消 + 链接 + 选择媒体 + 录制语音 + 离线 Mesh 聊天 + 在线 Geohash 频道 + 端到端加密 + + + 图片 %1$d / %2$d + 图片不可用 + 图片已保存到“下载” + 图片保存失败 + 选择文件 + [文件不可用] + 未知 + + + 为此消息或用户选择操作 + 为此用户选择操作 + 复制消息 + 将此消息复制到剪贴板 + 扇 %1$s 一下 + 发送一个打趣的“扇巴掌” + 拥抱 %1$s + 发送一个友好的拥抱 + 屏蔽 %1$s + 屏蔽该用户的所有消息 + + + #地点频道 + 使用 geohash 频道与附近的人聊天。仅共享粗略的 geohash,从不共享精确 GPS。为保护隐私,请勿截图或分享此页面。 + 授予定位权限 + 定位权限被拒。请在设置中启用以使用地点频道。 + 打开设置 + \u2713 已授予定位权限 + 正在检查权限… + 正在查找附近频道… + 已收藏 + geohash + 无效的 geohash + 传送 + 禁用定位服务 + 启用定位服务 + #bluetooth • %1$s + mesh + 街区 + 社区 + 城市 + 省/州 + 地区 + + + 调试工具 + 详细日志 + Bluetooth 角色 + GATT 服务器 + 连接:%1$d / %2$d + 最大服务器 + GATT 客户端 + 最大客户端 + 连接:%1$d / %2$d + 最大总计 + 数据包中继 + 自启动以来:%1$d + 同步设置 + 每次同步的最大包数:%1$d + GCS 过滤器最大大小:%1$d 字节(128–1024) + 目标 FPR:%1$.2f%% + 已连接设备 + 我们的设备 ID:%1$s + 无 + 断开 + 最近扫描结果 + 连接 + 调试控制台 + 清除 + 最近 10 秒:%1$d • 1 分钟:%2$d • 15 分钟:%3$d + 导出 P:%1$s • 估算最大元素:%2$s + • 直连 + ? + 作为服务器(我们托管) + 作为客户端(我们连接) + + + 需要定位服务 + 隐私优先 + bitchat 不会跟踪你的位置。\n\n蓝牙扫描和 geohash 聊天功能需要定位服务。 + bitchat 需要定位服务用于: + • 蓝牙设备扫描\n• 在 mesh 网络发现附近用户\n• Geohash 聊天\n• 不跟踪或收集位置 + 打开定位设置 + 再次检查 + 定位服务不可用 + 此设备上定位服务不可用。这不常见,因为 Android 设备通常具备此功能。\n\n为使蓝牙扫描正常工作(Android 要求),需要定位服务。否则无法发现附近用户。 + 正在检查定位服务… + 需要 Bluetooth + bitchat 需要 Bluetooth 以: + • 发现附近用户\n• 建立 mesh 连接\n• 发送和接收消息\n• 在无网络和服务器时工作 + 启用 Bluetooth + 不支持 Bluetooth + 此设备不支持 Bluetooth Low Energy (BLE),而 bitchat 需要它。\n\nbitchat 需要 BLE 以建立 mesh 并在无网络时与附近设备通信。 + 正在检查 Bluetooth 状态… + 检测到电池优化 + 电池优化已启用 + bitchat 需要在后台运行以维持连接;电池优化可能会中断。 + 禁用的好处 + • 可靠投递\n• 维持连接\n• 避免中断 + 禁用优化 + 已禁用优化 + 继续 + 正在初始化 mesh 网络 + . + 正在设置 Bluetooth mesh… + 只需几秒钟 + ⚠️ + 设置未完成 + 重试 + 你的隐私已受保护 + • 不跟踪不收集\n• 蓝牙 mesh 聊天完全离线\n• geohash 聊天使用互联网 + 权限 + 授予权限 + bitchat 不会跟踪你的位置 + @ + · ⧉ + 附近无人… + (你) + 拖动/缩放以选择 geohash + 选择 + 输入消息… + @%1$s + 提及 + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + 图片 + 文件 + + + + + 📷 发送了图片 + 🎤 发送了语音消息 + 📎 发送了文件 + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + 播放 + 暂停 + + + 需要通过 Bluetooth 发现 bitchat 用户 + 允许 bitchat 连接附近设备 + Android 要求以通过 Bluetooth 发现附近用户 + bitchat 需要此权限以扫描附近设备 + 在收到私信时接收通知 + 允许 bitchat 向你发送通知 + 禁用电池优化以确保后台可靠运行并维持连接 + 允许 bitchat 在无电池限制下运行 + + + 附近设备 + 精确位置 + 麦克风 + 通知 + 电池优化 + 其他 + + + 输入频道密码 + 频道 %1$s 已受密码保护。请输入密码以加入。 + 密码 + 加入 + 取消 + + + + 以及另外 %d 条 + 以及另外 %d 条 + + + + 1 人 + %d 人 + + + + #%1$s ± 1 • %2$d 条笔记 + #%1$s ± 1 • %2$d 条笔记 + + 为此位置添加简短的永久备注,方便其他访客查看。 + 地理中继不可用;笔记已暂停 + 附近没有地理中继 + 笔记依赖地理中继。请检查连接后重试。 + 正在加载笔记… + 还没有笔记 + 成为第一个为此地点添加笔记的人。 + 关闭 + 为此地点添加一条笔记 + 建议开启蓝牙 + Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s + 打开“关于” + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 + 语言 + 应用语言 + 跟随系统 + 选择语言 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 783120f2..a46d596f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,11 +1,16 @@ - + + pay via Cashu + Cashu payment + Copy token + Redeem in wallet + Redeem on web bitchat Bluetooth permission is required for peer-to-peer messaging without internet. Location permission is required to discover nearby devices via Bluetooth. Notification permission is required to alert you of new messages. - nickname - type a message… + Nickname + Type a message… Password Join Channel Leave @@ -36,5 +41,617 @@ Skip for Now Continue Retry - Skip + Skip + + + and %1$d more + %1$d messages from %2$d people + and %1$d more conversations + bitchatters nearby! + 1 person around + %1$d people around + New Messages + New Location Messages + Mentioned in %1$s + Mentioned in %1$s (+%2$d more) + %1$d mentions in %2$s + New activity in %1$s + Messages in %1$s + %1$s joined the conversation + bitchat - %1$d mentions + bitchat - Location chats + %1$d messages from %2$d locations + Mentioned in Mesh Chat + %1$d mentions in Mesh Chat + and %1$d more locations + and %1$d more + + + Mesh Background Service + Keeps the Bluetooth mesh running in the background + Mesh running — %1$d peers + + + Add to favorites + Remove from favorites + Favorite + Favorited you + Add bookmark + + + Nostr reachable + Messages + Conversations + Offline · not in mesh + Offline from mesh · Nostr available + Delete + Delete conversation + Delete conversation? + Delete your conversation with %1$s from this device? + Search conversations + Clear + Loading private conversations… + Private conversations could not be loaded + Private conversations will appear here + No matching conversations + ONLINE + OFFLINE + Mark as read + Mark as unread + Pin conversation + Unpin conversation + Mute conversation + Unmute conversation + Conversation actions + %1$d unread messages + Read + Draft: %1$s + You: %1$s + · %1$s + Conversation could not be deleted + Deleted conversation with %1$s + Undo + Conversation could not be restored + Reply + Mark read + New private message + Open bitchat to view it + Direct Messages + Notifications for private messages from other users + Geohash Chats + Notifications for mentions and messages in geohash location channels + You + Location notes + Teleported + Open location and channel settings + Connected peers + Geohash participants + Ready for handshake + Handshake in progress + End-to-end encrypted + Handshake failed + + + Send without end-to-end encryption? + %1$s cannot be sent encrypted to %2$s because their client is older. %3$s + Send this file once + + + 📎 File Received + 📄 %1$s + 📏 Size: %1$s + 🏷️ Type: %1$s + 📂 Open / Save + ❌ Close + Pick image + Save current image + Close + Image saved to Downloads + Failed to save image + Image %1$d of %2$d + Pick file + + + The sidegroup chat + + + Info + Settings + + + How To Use + Set your nickname by tapping it + Tap the channel name to change channels + Tap the people icon to see who\'s in the channel + Bookmark a channel to keep it in your list + Type @ to mention someone + Type / to see the available commands + Triple-tap the logo to wipe all data + + + About + Theme + Settings + + + No Tracking + No servers, accounts, or data collection. + Ephemeral Identity + A new peer ID is generated regularly. + Triple-tap the logo to instantly clear all data. + + + Tor Network + Difficulty + %1$d bits • %2$s + Connected (%1$d%%) + Disconnected + + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. + End-to-End Encryption + Private messages are encrypted. Channel messages are public. + System + Light + Dark + Matrix + Bubbles + Proof of Work + PoW Off + PoW On + Add proof of work to geohash messages for spam deterrence. + Difficulty: %1$d bits (~%2$s) + Difficulty %1$d requires ~%2$s hash attempts + No proof of work required + Very low – minimal spam protection + Low – basic spam protection + Medium – good spam protection + High – strong spam protection + Very high – may cause delays + Extreme – significant computation required + Network + Tor Off + Tor On + Route internet over Tor for enhanced privacy. + Tor Status: %1$s, bootstrap %2$d%% + Last: %1$s + Panic Mode + Debug Settings + Open Source • Privacy First • Decentralized + Close + Privacy Protected + Cancel + + + Share BitChat + Share installation file for offline distribution + Share BitChat App + This will share the BitChat installation file(s) so others can install the app without internet access. Perfect for mesh network expansion! + The receiver will need to:\n• Enable \"Install from unknown sources\" in Android settings\n• Uninstall BitChat first if already installed (signatures differ) + Share App + Share BitChat via… + Failed to prepare app for sharing. Please try again. + + + Prepare App for Sharing + App Ready for Offline Sharing + Download universal APK for offline sharing + Not ready • Tap to download + Ready to share + + Ready to share • %1$s • %2$d MB\n%3$s + %1$s • %2$d%% downloaded + Sharing source: this installed APK + Sharing source: this installed APK • ARM64 devices only + Sharing source: verified downloaded universal APK + Version %1$s is available. You can keep sharing this APK or download the update. + New version available + + Download universal APK + Retry download + Downloading… %1$d%% + + Waiting for network… + Retrying… + Selecting download source… + Waiting for Tor… + Downloading… + Verifying signature… + Stop download + Prepare + Delete + Version %1$s • %2$d MB + Download Universal APK? + Download Newer Universal APK? + This will download a verified universal APK from a configured source. You only need to do this once. + Version %1$s is available from GitHub (%2$d MB). Your current APK remains shareable during the download. + Download + Downloading Universal APK + Downloading %1$d MB… + Universal APK ready! + Network error. Check your connection. + Not enough storage space. + + %1$s is temporarily rate limited. Try again later. + %1$s does not currently have a universal APK. + %1$s download failed: HTTP %2$s %3$s + Download failed. Please try again. + Not enough storage: %1$s MB needed, %2$s MB free. + No APK download sources are configured. + Tor is still connecting. Try again when Tor is ready. + %1$s has no usable APK URL. + %1$s could not be reached. + %1$s redirected to an insecure URL. + %1$s rejected the saved download position. The next attempt will restart the download. + %1$s download ended before all bytes arrived. It can be resumed. + %1$s returned an invalid resume response. The next attempt will restart the download. + The APK from %1$s is not signed by a trusted BitChat release key. + %1$s returned an architecture-specific APK, not the required universal APK. + The downloaded APK could not be read. + The downloaded file is not a BitChat APK. + The downloaded APK has no version information. + %1$s download failed. + All configured APK sources failed. + Delete cached APK? + This will free up ~%1$d MB of storage. + Please prepare the app for sharing first. + Download interrupted + Download cancelled + Downloading universal APK + APK downloads + + + Share via Hotspot + Create Wi-Fi hotspot to share offline + Share via Quick Share + Use standard Android sharing + Disconnect current Wi-Fi Direct connection? + Starting the hotspot will disconnect the current Wi-Fi Direct connection. This may interrupt Cast, Android Auto, Quick Share, or another nearby connection. + Disconnect and start + + + Install Received APK + Install BitChat from received files + Install BitChat Update + Install BitChat from the received APK file(s)? This allows offline app distribution in mesh networks. + Permission Required + BitChat needs permission to install packages for self-distribution. Please enable \"Install unknown apps\" in the next screen. + Install + Grant Permission + Select APK Files + Failed to install APK. Please try again. + No APK files selected. + + + Warning + Location Services + Privacy + Error + Battery Optimization + Benefits + Checking Battery Optimization + Battery Optimization Not Supported + Bluetooth + Direct Wi-Fi Aware + Direct Bluetooth + Routed mesh + Unread message + Open map + Remove bookmark + Teleport + Quit bitchat + Run in Background + Keep mesh active when app is closed (foreground service) + Leave channel + Reachable via Nostr + Offline favorite + Decrease precision + Increase precision + Select geohash + Scroll to bottom + File + Image + Cancel + Link + Record voice note + Pick media + Offline Mesh Chat + Online Geohash Channels + End-to-End Encryption + Bluetooth • %1$s + Bluetooth • Wifi • %1$s + + Image %1$d of %2$d + Image unavailable + Image saved to Downloads + Failed to save image + Pick file + [file unavailable] + Unknown + + + Choose an action for this message or user + Choose an action for this user + Copy message + Copy this message to clipboard + Slap %1$s + Send a playful slap message + Hug %1$s + Send a friendly hug message + Block %1$s + Block all messages from this user + Message %1$s + Send a private message + + + + Channels + Location Channels + Chat by coarse location. Never shares exact GPS. + Nearby + Offline mesh. No internet required. + + + Nearby + Tor Routing + Hides your IP for location channels. + Recommended + + + People (%1$d) + People + On location + Teleported in + + Grant location permission + Location permission denied. Enable in settings to use location channels. + + \u2713 Location permission granted + Checking permissions… + Finding nearby channels… + Bookmarked + geohash + Invalid geohash + Teleport + Remove location access + Enable location services + mesh + Mesh + Block + Neighborhood + City + Province + Region + + + check for notes left here + 1 note left here — tap to read + + %d notes left here — tap to read + + #%1$s ± 1 • %2$d note + #%1$s ± 1 • %2$d notes + + Add short permanent notes to this location for other visitors to find. + Geo relays unavailable; notes paused + No geo relays nearby + Notes rely on geo relays. Check connection and try again. + Loading notes… + No notes yet + Be the first to add one for this spot. + Dismiss + Add a note for this place + + + Debug Tools + Developer utilities for diagnostics and control + Verbose logging + Logs peer joins/leaves, connection direction, packet routing and relays + Bluetooth roles + GATT server + Connections: %1$d / %2$d + Max server + GATT client + Max client + Connections: %1$d / %2$d + Max overall + Packet relay + Since start: %1$d + Turn roles on/off and close all connections when disabled + Sync settings + Max packets per sync: %1$d + Max GCS filter size: %1$d bytes (128–1024) + Target FPR: %1$.2f%% + Connected devices + Our device ID: %1$s + None + Disconnect + Recent scan results + + Verify + Scan to verify me + Scan someone else\'s QR + Scan someone else\'s QR + Show my QR + Remove verification + QR unavailable + Camera permission is needed to scan QR codes + Enable camera + Paste verification URL + Validate + Verification requested + Security verification + Their fingerprint + Your fingerprint + Handshake pending + Open a private chat to view fingerprints + Encrypted & verified + Encrypted + Handshaking + Handshake failed + Not encrypted + Verified + You have verified this person\'s identity. + Not verified + Compare these fingerprints with %1$s using a secure channel. + Mark as verified + Start handshake + Copy + + Mutual verification + You and %1$s verified each other + Mutual verification with %1$s + Verified + You verified %1$s + Verified %1$s + + Connect + Debug console + Clear + Last 10s: %1$d • 1m: %2$d • 15m: %3$d + Derived P: %1$s • est. max elements: %2$s + • direct + RSSI: %1$s + ? + As server (we host) + As client (we connect) + + + Location Services Required + Privacy First + bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature. + bitchat needs location services for: + • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection + Background Location Recommended + Optional, improves mesh reliability + Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. + When settings opens, choose "Allow all the time". + bitchat uses background location for: + - Scan for nearby devices while the app is closed\n- Reconnect after reboot\n- Keep the mesh running in the background + We NEVER collect or store your location. Your privacy is safe. + Allow Background Location + Continue without background location + Open Location Settings + Check Again + Location Services Unavailable + Location services are not available on this device. This is unusual as location services are standard on Android devices.\n\nbitchat needs location services for Bluetooth scanning to work properly (Android requirement). Without this, the app cannot discover nearby users. + Checking location services... + Bluetooth Required + bitchat needs Bluetooth to: + • Discover nearby users\n• Create mesh network connections\n• Send and receive messages\n• Work without internet or servers + Enable Bluetooth + Bluetooth Not Supported + This device doesn\'t support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet. + Checking Bluetooth status... + Battery optimization detected + Battery Optimization Enabled + bitchat needs to run in the background to maintain mesh connections. Battery optimization can interrupt these connections. + Benefits of Disabling + • Reliable message delivery\n• Maintains mesh connectivity\n• Prevents connection drops + Disable Battery Optimization + Battery optimization disabled + Continue + Initializing mesh network + . + Setting up Bluetooth mesh networking... + This should only take a few seconds + ⚠️ + Setup Not Complete + Try Again + Open Settings + Your Privacy is Protected + • No tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet + Permissions + Grant Permissions + bitchat does NOT track your location + @ + Open About + · ⧉ + Nobody around… + (you) + Drag to spin · Pinch to zoom · Tap to focus + Select + Type a message… + @%1$s + Mention + %1$d / %2$d + @%1$s + v%1$s + # + _ + [%1$s] %2$d%% + █ + ░ + image/* + Image + File + + + 📷 sent an image + 🎤 sent a voice message + 📎 sent a file + 📄 + 🗜️ + 📄 + 📊 + 📈 + 📎 + + + Play + Pause + + + Required to discover bitchat users via Bluetooth + Allow bitchat to connect to nearby devices + Required by Android to discover nearby bitchat users via Bluetooth + bitchat needs this to scan for nearby devices + Recommended to scan for nearby devices while the app is in the background + Allow background location access + Receive notifications when you receive private messages + Allow bitchat to send you notifications + Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections + Allow bitchat to run without battery restrictions + + + Nearby Devices + Precise Location + Background Location + Microphone + Notifications + Battery Optimization + Other + + + Enter Channel Password + Channel %1$s is password protected. Enter the password to join. + Password + Join + Cancel + Tor not available in this build + Checking... + APK not ready. Please prepare it first. + + + + and %d more + and %d more + + + + 1 person + %d people + + + Bluetooth Recommended + Language + App language + System default + Select language diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..d85ee1d1 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 00000000..4bfd7251 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/bitchat/android/crypto/EncryptionServiceTest.kt b/app/src/test/java/com/bitchat/android/crypto/EncryptionServiceTest.kt new file mode 100644 index 00000000..7e610d32 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/crypto/EncryptionServiceTest.kt @@ -0,0 +1,55 @@ +package com.bitchat.android.crypto + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.noise.NoiseEncryptionService +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Arrays + +@RunWith(RobolectricTestRunner::class) +class EncryptionServiceTest { + + private lateinit var context: Context + private lateinit var encryptionService: EncryptionService + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + encryptionService = EncryptionService(context) + } + + @Test + fun `test clearPersistentIdentity changes keys`() { + // 1. Get initial keys + val initialStaticKey = encryptionService.getStaticPublicKey() + val initialSigningKey = encryptionService.getSigningPublicKey() + val initialFingerprint = encryptionService.getIdentityFingerprint() + + assertNotNull("Initial static key should not be null", initialStaticKey) + assertNotNull("Initial signing key should not be null", initialSigningKey) + + // 2. Call clearPersistentIdentity (Panic Mode) + encryptionService.clearPersistentIdentity() + + // 3. Get keys again. + val afterStaticKey = encryptionService.getStaticPublicKey() + val afterSigningKey = encryptionService.getSigningPublicKey() + val afterFingerprint = encryptionService.getIdentityFingerprint() + + // 4. Verify keys are different (Panic Mode should clear/rotate in-memory keys) + // Note: We use string comparison for byte arrays to be safe in assertion messages + assertNotEquals("Static key should change after panic", + Arrays.toString(initialStaticKey), Arrays.toString(afterStaticKey)) + + assertNotEquals("Signing key should change after panic", + Arrays.toString(initialSigningKey), Arrays.toString(afterSigningKey)) + + assertNotEquals("Fingerprint should change after panic", + initialFingerprint, afterFingerprint) + } +} diff --git a/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt b/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt new file mode 100644 index 00000000..123a0ba4 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt @@ -0,0 +1,48 @@ +package com.bitchat.android.favorites + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class FavoriteRelationshipTest { + + @Test + fun `first inbound favorite creates a received relationship`() { + val noiseKey = ByteArray(32) { it.toByte() } + + val relationship = null.withPeerFavoritedUs( + noisePublicKey = noiseKey, + theyFavoritedUs = true, + now = Date(123L) + ) + + assertFalse(relationship.isFavorite) + assertTrue(relationship.theyFavoritedUs) + assertTrue(relationship.peerNoisePublicKey.contentEquals(noiseKey)) + } + + @Test + fun `inbound favorite preserves our existing favorite`() { + val noiseKey = ByteArray(32) { it.toByte() } + val existing = FavoriteRelationship( + peerNoisePublicKey = noiseKey, + peerNostrPublicKey = null, + peerNickname = "peer", + isFavorite = true, + theyFavoritedUs = false, + favoritedAt = Date(10L), + lastUpdated = Date(20L) + ) + + val relationship = existing.withPeerFavoritedUs( + noisePublicKey = noiseKey, + theyFavoritedUs = true, + now = Date(30L) + ) + + assertTrue(relationship.isFavorite) + assertTrue(relationship.theyFavoritedUs) + assertTrue(relationship.isMutual) + } +} diff --git a/app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt b/app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt new file mode 100644 index 00000000..4c1200df --- /dev/null +++ b/app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt @@ -0,0 +1,116 @@ +package com.bitchat.android.features.voice + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class VoiceBurstPacketTest { + private val burstID = ByteArray(8) { (it + 1).toByte() } + + @Test + fun wirePacketsRoundTripWithIosLayout() { + val packets = listOf( + VoiceBurstPacket.create( + burstID, + 0, + VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO) + )!!, + VoiceBurstPacket.create( + burstID, + 7, + VoiceBurstPacket.Kind.Frames( + listOf(byteArrayOf(0xDE.toByte(), 0xAD.toByte()), ByteArray(130) { 0x42 }) + ) + )!!, + VoiceBurstPacket.create(burstID, 42, VoiceBurstPacket.Kind.End(41, 2_688))!!, + VoiceBurstPacket.create(burstID, 3, VoiceBurstPacket.Kind.Canceled)!! + ) + + packets.forEach { expected -> + val actual = VoiceBurstPacket.decode(expected.encode()) + assertNotNull(actual) + assertArrayEquals(expected.burstID, actual!!.burstID) + assertEquals(expected.sequence, actual.sequence) + assertEquals(expected.kind, actual.kind) + } + } + + @Test + fun encodedGoldenVectorsMatchIos() { + val start = VoiceBurstPacket.create( + burstID, + 0, + VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO) + )!!.encode() + assertArrayEquals( + byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 1, 1), + start + ) + + val end = VoiceBurstPacket.create( + burstID, + 42, + VoiceBurstPacket.Kind.End(41, 2_688) + )!!.encode() + assertArrayEquals( + byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 42, 2, 0, 41, 0, 0, 10, 0x80.toByte()), + end + ) + } + + @Test + fun rejectsMalformedPacketsAndConstruction() { + assertNull(VoiceBurstPacket.decode(byteArrayOf())) + assertNull(VoiceBurstPacket.decode(ByteArray(10))) + assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0xFF.toByte()))) + assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0))) + assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0, 0, 16, 0xAB.toByte()))) + assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 0, 1, 0x7F))) + assertNull(VoiceBurstPacket.create(byteArrayOf(1, 2), 0, VoiceBurstPacket.Kind.Canceled)) + assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(emptyList()))) + assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(listOf(byteArrayOf())))) + } + + @Test + fun packetizerRespectsBudgetAndCounters() { + val packetizer = VoiceBurstPacketizer(burstID, budget = 210) + val frame = ByteArray(130) { 0x55 } + + assertTrue(packetizer.add(frame).isEmpty()) + val first = packetizer.add(frame).single() + assertEquals(1, VoiceBurstPacket.decode(first)!!.sequence) + val final = packetizer.flush().single() + assertEquals(2, VoiceBurstPacket.decode(final)!!.sequence) + assertEquals(2, packetizer.dataPacketCount) + assertEquals(3, packetizer.nextSequence) + assertTrue(packetizer.flush().isEmpty()) + assertTrue(first.size + 1 + 16 <= 256) + } + + @Test + fun packetizerBatchesEightOrBudgetAndAdtsHeaderIsValid() { + val packetizer = VoiceBurstPacketizer(burstID, budget = 210) + repeat(4) { assertTrue(packetizer.add(ByteArray(40) { 0x11 }).isEmpty()) } + val decoded = VoiceBurstPacket.decode(packetizer.flush().single())!! + assertEquals(4, (decoded.kind as VoiceBurstPacket.Kind.Frames).frames.size) + + val framed = AdtsFramer.frame(byteArrayOf(1, 2, 3)) + assertEquals(10, framed.size) + assertEquals(0xFF, framed[0].toInt() and 0xFF) + assertEquals(0xF1, framed[1].toInt() and 0xFF) + assertArrayEquals(byteArrayOf(1, 2, 3), framed.copyOfRange(7, framed.size)) + } + + @Test + fun packetizerReportsFramesThatCannotFitTheWireBudget() { + val packetizer = VoiceBurstPacketizer(burstID, budget = 210) + + assertTrue(packetizer.add(ByteArray(198)).isEmpty()) + assertEquals(1, packetizer.droppedFrameCount) + assertEquals(0, packetizer.dataPacketCount) + assertEquals(1, packetizer.nextSequence) + } +} diff --git a/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt b/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt new file mode 100644 index 00000000..3c6217af --- /dev/null +++ b/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt @@ -0,0 +1,46 @@ +package com.bitchat.android.geohash + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GeohashNostrPrivacyPolicyTest { + @Test + fun `disabled live location retains only user-selected sampling targets`() { + val targets = GeohashNostrPrivacyPolicy.samplingTargets( + liveLocationGeohashes = listOf("live-city", "shared"), + userSelectedGeohashes = listOf("bookmark", "shared"), + liveLocationEnabled = false + ) + + assertEquals(setOf("bookmark", "shared"), targets) + } + + @Test + fun `enabled live location combines and deduplicates sampling targets`() { + val targets = GeohashNostrPrivacyPolicy.samplingTargets( + liveLocationGeohashes = listOf("live-city", "shared"), + userSelectedGeohashes = listOf("bookmark", "shared"), + liveLocationEnabled = true + ) + + assertEquals(setOf("live-city", "bookmark", "shared"), targets) + } + + @Test + fun `presence excludes live channels when disabled`() { + val channels = listOf( + GeohashChannel(GeohashChannelLevel.REGION, "region"), + GeohashChannel(GeohashChannelLevel.CITY, "city"), + GeohashChannel(GeohashChannelLevel.NEIGHBORHOOD, "neighborhood") + ) + + assertEquals( + emptySet(), + GeohashNostrPrivacyPolicy.livePresenceTargets(channels, false) + ) + assertEquals( + setOf("region", "city"), + GeohashNostrPrivacyPolicy.livePresenceTargets(channels, true) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt b/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt new file mode 100644 index 00000000..93cef8ca --- /dev/null +++ b/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt @@ -0,0 +1,48 @@ +package com.bitchat.android.geohash + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LiveLocationAccessPolicyTest { + @Test + fun `live location is disabled by default`() { + val policy = LiveLocationAccessPolicy() + + assertFalse(policy.isEnabled) + assertNull(policy.captureToken()) + } + + @Test + fun `disable and re-enable never revives old work`() { + val policy = LiveLocationAccessPolicy(initialEnabled = true) + val oldToken = requireNotNull(policy.captureToken()) + + policy.update(false) + assertFalse(policy.accepts(oldToken)) + + policy.update(true) + val newToken = requireNotNull(policy.captureToken()) + + assertFalse(policy.accepts(oldToken)) + assertTrue(policy.accepts(newToken)) + } + + @Test + fun `invalidation prevents a queued action from running`() { + val policy = LiveLocationAccessPolicy(initialEnabled = true) + val token = requireNotNull(policy.captureToken()) + var ran = false + + policy.invalidate() + val accepted = policy.runIfAllowed(token) { ran = true } + + assertFalse(accepted) + assertFalse(ran) + assertNull(policy.captureToken()) + + policy.resumeAccess() + assertTrue(policy.captureToken() != null) + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt new file mode 100644 index 00000000..ba4bb0cb --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt @@ -0,0 +1,135 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.BinaryProtocol +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessagePadding +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BLEPacketPaddingPolicyTest { + private val senderID = byteArrayOf(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x00) + private val timestamp = 1_709_600_000_000uL + + @Test + fun `BLE padding policy matches iOS packet type table`() { + val expected = mapOf( + MessageType.ANNOUNCE to false, + MessageType.MESSAGE to false, + MessageType.LEAVE to false, + MessageType.REQUEST_SYNC to false, + MessageType.FRAGMENT to false, + MessageType.FILE_TRANSFER to false, + MessageType.VOICE_FRAME to false, + MessageType.NOISE_ENCRYPTED to true, + MessageType.NOISE_HANDSHAKE to true + ) + + expected.forEach { (type, shouldPad) -> + assertEquals( + "${type.name} BLE padding policy must match iOS", + shouldPad, + BLEPacketPaddingPolicy.shouldPadForBLE(type.value) + ) + } + + assertFalse( + "Unknown packet types should be sent unpadded, matching iOS default", + BLEPacketPaddingPolicy.shouldPadForBLE(0x7Fu) + ) + } + + @Test + fun `public BLE packet types are encoded without PKCS7 padding tails`() { + val publicTypes = listOf( + MessageType.ANNOUNCE, + MessageType.MESSAGE, + MessageType.LEAVE, + MessageType.REQUEST_SYNC, + MessageType.FRAGMENT, + MessageType.FILE_TRANSFER, + MessageType.VOICE_FRAME + ) + + publicTypes.forEach { type -> + val packet = packet(type.value, payload = payloadEndingWithoutPaddingShape(type)) + val raw = packet.toBinaryData(padding = false)!! + val encodedForBLE = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + )!! + + assertArrayEquals( + "${type.name} BLE encoding should be the unpadded frame", + raw, + encodedForBLE + ) + assertFalse( + "${type.name} BLE encoding must not leave iOS-visible PKCS#7 tail bytes", + hasPkcs7PaddingTail(encodedForBLE) + ) + assertNotNull( + "${type.name} unpadded BLE frame must remain decodable", + BinaryProtocol.decode(encodedForBLE) + ) + } + } + + @Test + fun `Noise BLE packet types remain padded and decodable`() { + val noiseTypes = listOf(MessageType.NOISE_HANDSHAKE, MessageType.NOISE_ENCRYPTED) + + noiseTypes.forEach { type -> + val packet = packet(type.value, payload = "noise-${type.name}".toByteArray()) + val raw = packet.toBinaryData(padding = false)!! + val encodedForBLE = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + )!! + + assertTrue("${type.name} BLE frame should be padded", encodedForBLE.size > raw.size) + assertTrue( + "${type.name} BLE frame should end with valid PKCS#7 padding", + hasPkcs7PaddingTail(encodedForBLE) + ) + assertArrayEquals( + "${type.name} padding should strip back to the raw frame", + raw, + MessagePadding.unpad(encodedForBLE) + ) + assertNotNull( + "${type.name} padded BLE frame must remain decodable", + BinaryProtocol.decode(encodedForBLE) + ) + } + } + + private fun packet(type: UByte, payload: ByteArray): BitchatPacket { + val version = if (type == MessageType.FILE_TRANSFER.value) 2u.toUByte() else 1u.toUByte() + return BitchatPacket( + version = version, + type = type, + senderID = senderID, + recipientID = null, + timestamp = timestamp, + payload = payload, + signature = null, + ttl = 7u, + route = null + ) + } + + private fun payloadEndingWithoutPaddingShape(type: MessageType): ByteArray { + return "ios-ble-policy-${type.name}-z".toByteArray() + } + + private fun hasPkcs7PaddingTail(data: ByteArray): Boolean { + if (data.isEmpty()) return false + val paddingLength = data.last().toInt() and 0xFF + if (paddingLength <= 0 || paddingLength > data.size) return false + val start = data.size - paddingLength + return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength } + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt new file mode 100644 index 00000000..bf3e3d01 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt @@ -0,0 +1,255 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.model.FragmentPayload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +class FragmentManagerTest { + + private lateinit var fragmentManager: FragmentManager + private val senderID = "1122334455667788" + private val recipientID = "8877665544332211" + + @Before + fun setup() { + fragmentManager = FragmentManager() + } + + @Test + fun `test fragmentation without route`() { + // Create a large payload (e.g., 1000 bytes) + val payload = ByteArray(1000) + Random().nextBytes(payload) + + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = null + ) + + val fragments = fragmentManager.createFragments(packet) + + assertTrue("Should create multiple fragments", fragments.size > 1) + + // Verify each fragment fits in MTU (512) + for (fragment in fragments) { + val encodedSize = fragment.toBinaryData()?.size ?: 0 + assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512) + + // Inspect the payload data size + val fragmentPayload = FragmentPayload.decode(fragment.payload) + assertNotNull(fragmentPayload) + } + } + + @Test + fun `test fragmentation with route`() { + // Create a large payload + val payload = ByteArray(1000) + Random().nextBytes(payload) + + // Create a fake route (3 hops) + val route = listOf( + hexStringToByteArray("AABBCCDDEEFF0011"), + hexStringToByteArray("1100FFEEDDCCBBAA"), + hexStringToByteArray("1234567890ABCDEF") + ) + + val packet = BitchatPacket( + version = 2u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = route + ) + + val fragments = fragmentManager.createFragments(packet) + + assertTrue("Should create multiple fragments", fragments.size > 1) + + // Verify fragments retain the route and version 2 + for (fragment in fragments) { + assertEquals("Fragment version should be 2", 2u.toUByte(), fragment.version) + assertEquals("Fragment should have the route", route.size, fragment.route?.size) + + val encodedSize = fragment.toBinaryData()?.size ?: 0 + assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512) + } + } + + @Test + fun `test fragmentation size difference with and without route`() { + // This test specifically checks if the dynamic calculation logic works + // by observing that fragments with routes carry less data payload per fragment + + val payload = ByteArray(2000) // Large enough to ensure full fragments + Random().nextBytes(payload) + + // 1. Without route + val packetNoRoute = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = null + ) + val fragmentsNoRoute = fragmentManager.createFragments(packetNoRoute) + val firstFragPayloadNoRoute = FragmentPayload.decode(fragmentsNoRoute[0].payload) + val dataSizeNoRoute = firstFragPayloadNoRoute?.data?.size ?: 0 + + // 2. With large route (e.g., 5 hops) + val route = List(5) { hexStringToByteArray("000000000000000$it") } + val packetWithRoute = BitchatPacket( + version = 2u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = 7u, + route = route + ) + val fragmentsWithRoute = fragmentManager.createFragments(packetWithRoute) + val firstFragPayloadWithRoute = FragmentPayload.decode(fragmentsWithRoute[0].payload) + val dataSizeWithRoute = firstFragPayloadWithRoute?.data?.size ?: 0 + + println("Data size without route: $dataSizeNoRoute") + println("Data size with route: $dataSizeWithRoute") + + assertTrue("Data payload should be smaller with route", dataSizeWithRoute < dataSizeNoRoute) + + // Rough verification of the math: + // 5 hops * 8 bytes = 40 bytes extra. + // Plus v2 header overhead differences. + // The difference should be roughly 40+ bytes. + assertTrue("Difference should be significant", (dataSizeNoRoute - dataSizeWithRoute) >= 40) + } + + @Test + fun `test reassembly`() { + val originalPayload = ByteArray(1500) + Random().nextBytes(originalPayload) + + val originalPacket = BitchatPacket( + version = 1u, + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = System.currentTimeMillis().toULong(), + payload = originalPayload, + ttl = 7u + ) + + val fragments = fragmentManager.createFragments(originalPacket) + + var reassembledPacket: BitchatPacket? = null + + // Feed fragments back into FragmentManager + // Note: FragmentManager stores state in incomingFragments + + for (fragment in fragments) { + val result = fragmentManager.handleFragment(fragment) + if (result != null) { + reassembledPacket = result + } + } + + assertNotNull("Should have reassembled packet", reassembledPacket) + assertEquals("Type should match", originalPacket.type, reassembledPacket!!.type) + assertEquals("Payload size should match", originalPacket.payload.size, reassembledPacket.payload.size) + assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload)) + } + + @Test + fun `inbound fragment set above 256 is rejected`() { + val payload = FragmentPayload( + fragmentID = ByteArray(8) { 1 }, + index = 0, + total = 257, + originalType = MessageType.NOISE_ENCRYPTED.value, + data = byteArrayOf(1) + ).encode() + val packet = BitchatPacket( + version = 1u, + type = MessageType.FRAGMENT.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = 1u, + payload = payload, + ttl = 7u + ) + + assertNull(fragmentManager.handleFragment(packet)) + } + + @Test + fun `fragment payload refuses UInt16 truncation`() { + assertThrows(IllegalArgumentException::class.java) { + FragmentPayload( + fragmentID = ByteArray(8) { 2 }, + index = 0, + total = 65_536, + originalType = MessageType.NOISE_ENCRYPTED.value, + data = byteArrayOf(1) + ).encode() + } + } + + @Test + fun `generic public packet retains a 257 fragment outbound plan`() { + val randomPayload = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) } + + fun plan(contentSize: Int): List = fragmentManager.createFragments( + BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(senderID), + recipientID = com.bitchat.android.protocol.SpecialRecipients.BROADCAST, + timestamp = 1u, + payload = randomPayload.copyOf(contentSize), + signature = ByteArray(64) { 7 }, + ttl = 7u + ) + ) + + var low = 1 + var high = randomPayload.size + while (low < high) { + val mid = low + (high - low) / 2 + if (plan(mid).size >= 257) high = mid else low = mid + 1 + } + + assertEquals(257, plan(low).size) + } + + private fun hexStringToByteArray(hexString: String): ByteArray { + val result = ByteArray(8) + for (i in 0 until 8) { + val byteStr = hexString.substring(i * 2, i * 2 + 2) + result[i] = byteStr.toInt(16).toByte() + } + return result + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt new file mode 100644 index 00000000..c4225288 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt @@ -0,0 +1,94 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +class FragmentingPacketSenderTest { + + private val senderID = "1122334455667788" + + private fun packetWithPayload(bytes: Int): BitchatPacket { + val payload = ByteArray(bytes) + Random(42).nextBytes(payload) + return BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(senderID), + recipientID = null, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = 7u + ) + } + + @Test + fun `oversized packet exceeding receiver fragment cap is rejected with fail event`() = runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val sender = FragmentingPacketSender(scope, FragmentManager(), "test") + // ~256 * 469 bytes fit; 1 MiB clearly exceeds MAX_FRAGMENTS_PER_ID + val packet = packetWithPayload(1024 * 1024) + var sent = false + + val failed = java.util.concurrent.ConcurrentLinkedQueue() + val collectJob = launch(Dispatchers.Default) { + TransferProgressManager.events.collect { event -> + if (event.failed) failed.add(event.transferId) + } + } + kotlinx.coroutines.delay(100) // activate subscription before emitting + + val accepted = sender.send(RoutedPacket(packet, transferId = "oversize-test"), "test") { sent = true; true } + assertFalse(accepted) + assertFalse(sent) + withTimeout(5_000) { + while (!failed.contains("oversize-test")) { + kotlinx.coroutines.delay(10) + } + } + collectJob.cancel() + Unit + } + + @Test + fun `packet within fragment cap is accepted`() = runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val sender = FragmentingPacketSender(scope, FragmentManager(), "test", interFragmentDelayMs = 0L) + val packet = packetWithPayload(10_000) + var writes = 0 + + val accepted = sender.send(RoutedPacket(packet, transferId = "fits-test"), "test") { writes += 1; true } + assertTrue(accepted) + withTimeout(5_000) { + while (writes == 0) { + kotlinx.coroutines.delay(10) + } + } + assertTrue(writes > 0) + } + + @Test + fun `fragment count at cap boundary is not rejected`() { + val manager = FragmentManager() + val packet = packetWithPayload(AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID * 400) + val fragments = manager.createFragments(packet, AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) + assertTrue(fragments.isNotEmpty()) + assertTrue(fragments.size <= AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) + } +} diff --git a/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt b/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt new file mode 100644 index 00000000..a964a686 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NostrLiveSubscriptionPrivacyTest { + @Test + fun `teardown closes live subscriptions on shared relays`() { + val targets = NostrLiveSubscriptionPrivacy.closeTargets( + liveSubscriptionIds = setOf("live-a", "live-b"), + subscriptionsByRelay = mapOf( + "shared-relay" to setOf("dm", "live-a"), + "live-relay" to setOf("live-a", "live-b"), + "dm-relay" to setOf("dm"), + ), + ) + + assertEquals( + mapOf( + "shared-relay" to setOf("live-a"), + "live-relay" to setOf("live-a", "live-b"), + ), + targets, + ) + } + + @Test + fun `teardown ignores relays without live subscriptions`() { + assertEquals( + emptyMap>(), + NostrLiveSubscriptionPrivacy.closeTargets( + liveSubscriptionIds = emptySet(), + subscriptionsByRelay = mapOf("default-relay" to setOf("dm")), + ), + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt new file mode 100644 index 00000000..c927257f --- /dev/null +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -0,0 +1,1630 @@ +package com.bitchat.android.protocol + +import com.bitchat.android.model.BitchatFilePacket +import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Random +import java.util.zip.Deflater + +class BinaryProtocolTest { + + private val senderHex = "1122334455667788" + private val recipientHex = "8877665544332211" + private val fixedTimestamp = 1709600000000uL + + /** + * Minimal v1 broadcast packet (no recipient, no signature, no route) + * + * Baseline correctness test for the simplest possible packet shape. + * Encodes a v1 packet with only the mandatory fields (version, type, TTL, + * timestamp, senderID, payload) and decodes it back, verifying every field + * survives the round-trip. + * + * Catches byte-order bugs, header size miscalculations, and off-by-one + * errors in the payload length field. The payload is deliberately kept + * below the 100-byte compression threshold to ensure compression is NOT + * triggered — isolating pure encode/decode logic from the compression path. + */ + @Test + fun `minimal v1 broadcast packet round-trips correctly`() { + val original = makePacket(payload = "Hello mesh".toByteArray()) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNull("recipientID should be null", decoded.recipientID) + assertNull("signature should be null", decoded.signature) + assertNull("route should be null", decoded.route) + } + + /** + * v1 packet with recipient + * + * Adds a recipientID to the packet, which sets the HAS_RECIPIENT flag + * (bit 0) in the flags' byte. Encodes and decodes, then verifies the + * recipientID bytes survive the round-trip intact. + * + * This validates the conditional recipient field and its flag. If the + * flag or offset calculation is wrong, every subsequent field in the + * packet (payload, signature) shifts by 8 bytes, corrupting decode. + */ + @Test + fun `v1 packet with recipient round-trips correctly`() { + val original = makePacket( + payload = "Private msg".toByteArray(), + recipientID = hexToBytes(recipientHex) + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("recipientID must not be null", decoded.recipientID) + assertTrue("recipientID bytes must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + } + + /** + * v1 packet with signature + * + * Adds a 64-byte Ed25519 signature to the packet, which sets the + * HAS_SIGNATURE flag (bit 1). Encodes and decodes, then verifies + * the signature bytes survive the round-trip exactly. + * + * The signature is appended AFTER the payload in the wire format. + * If the payload length calculation is off by even one byte, the + * decoder will read wrong bytes for the signature, silently + * producing a corrupted value that fails verification downstream. + */ + @Test + fun `v1 packet with signature round-trips correctly`() { + val signature = ByteArray(64) { (it + 0xA0).toByte() } + val original = makePacket( + payload = "Signed msg".toByteArray(), + signature = signature + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("signature must not be null", decoded.signature) + assertTrue("signature bytes must match", + original.signature!!.contentEquals(decoded.signature!!)) + } + + /** + * v1 packet with recipient AND signature + * + * The most common real-world packet shape for private signed messages: + * both optional v1 fields (recipientID + signature) are present + * simultaneously. Encodes and decodes, verifying all fields. + * + * Tests that both conditional sections compose correctly. The wire + * layout is: header | senderID | recipientID | payload | signature. + * If either conditional section's size is miscalculated, it shifts + * everything after it, corrupting the remaining fields. + */ + @Test + fun `v1 packet with recipient and signature round-trips correctly`() { + val signature = ByteArray(64) { (it + 0xB0).toByte() } + val original = makePacket( + payload = "Private signed".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("recipientID must not be null", decoded.recipientID) + assertNotNull("signature must not be null", decoded.signature) + assertTrue("recipientID bytes must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + assertTrue("signature bytes must match", + original.signature!!.contentEquals(decoded.signature!!)) + } + + /** + * v2 packet with route + * + * Creates a v2 packet with a 3-hop source route, recipientID, and + * signature — the most complex packet layout the protocol supports. + * Verifies all route hops survive the round-trip, version stays 2, + * and the 4-byte payload length field is handled correctly. + * + * v2 introduces two key changes: 4-byte (instead of 2-byte) payload + * length, and a route section (1-byte hop count + N*8-byte peerIDs). + * Route encoding bugs break source-routed mesh delivery where packets + * must traverse specific intermediate nodes. + */ + @Test + fun `v2 packet with route round-trips correctly`() { + val route = listOf( + hexToBytes("AABBCCDDEEFF0011"), + hexToBytes("1100FFEEDDCCBBAA"), + hexToBytes("1234567890ABCDEF") + ) + val signature = ByteArray(64) { (it + 0xC0).toByte() } + val original = makePacket( + version = 2u, + payload = "Routed msg".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature, + route = route + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNotNull("route must not be null", decoded.route) + assertEquals("route hop count", 3, decoded.route!!.size) + for (i in route.indices) { + assertTrue("route hop $i must match", + route[i].contentEquals(decoded.route!![i])) + } + } + + /** + * v2 packet with empty/null route + * + * Creates v2 packets with route=emptyList() and route=null, then + * verifies the HAS_ROUTE flag is NOT set and both decode cleanly + * with route=null in the result. + * + * Edge case: an empty route list should behave identically to null — + * it should NOT encode a route-count byte of 0, which would waste a + * byte and could confuse decoders that treat count=0 differently. + */ + @Test + fun `v2 packet with empty route decodes as null route`() { + val emptyRoute = makePacket( + version = 2u, + payload = "No route".toByteArray(), + route = emptyList() + ) + val decodedEmpty = roundTrip(emptyRoute) + assertNull("empty route should decode as null", decodedEmpty.route) + + val nullRoute = makePacket( + version = 2u, + payload = "No route".toByteArray(), + route = null + ) + val decodedNull = roundTrip(nullRoute) + assertNull("null route should decode as null", decodedNull.route) + } + + /** + * All MessageType values round-trip + * + * Creates one packet per MessageType (ANNOUNCE, MESSAGE, LEAVE, + * NOISE_HANDSHAKE, NOISE_ENCRYPTED, FRAGMENT, REQUEST_SYNC, + * FILE_TRANSFER), encodes and decodes each, then verifies the type + * field is preserved exactly. + * + * MessageType is stored as a single UByte. If any type value gets + * mangled by signed/unsigned byte conversion (e.g., 0x80+ values + * treated as negative), that entire message class silently breaks. + * Also validates MessageType.fromValue() symmetry — every encoded + * type must map back to its enum constant. + */ + @Test + fun `all MessageType values round-trip correctly`() { + for (msgType in MessageType.entries) { + val original = makePacket( + payload = "type-test".toByteArray(), + type = msgType.value + ) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding ${msgType.name} must not return null", encoded) + + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding ${msgType.name} must not return null", decoded) + + assertEquals("type must match for ${msgType.name}", + msgType.value, decoded!!.type) + assertEquals("fromValue must resolve for ${msgType.name}", + msgType, MessageType.fromValue(decoded.type)) + } + } + + /** + * Large compressible payload triggers compression + * + * Creates a packet with a 500-byte repeating-text payload (well above + * the 100-byte compression threshold and with low entropy). Encodes it, + * verifies the encoded wire size is smaller than it would be without + * compression, then decodes and verifies the payload is restored exactly. + * + * Validates the full compression pipeline: shouldCompress() → compress() + * → IS_COMPRESSED flag set → original-size field prepended → decompress(). + * A mismatch in the original-size encoding (2 bytes for v1 vs 4 for v2) + * would cause decompression to fail or produce wrong-length output. + */ + @Test + fun `large compressible payload is compressed and decompressed correctly`() { + val repeating = "ABCDEFGH".repeat(63) // 504 bytes, highly compressible + val payload = repeating.toByteArray() + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding must not return null", encoded) + + // Verify IS_COMPRESSED flag is set in the encoded output + val unpadded = MessagePadding.unpad(encoded!!) + val flags = unpadded[11].toUByte() + assertTrue("IS_COMPRESSED flag must be set", + (flags and BinaryProtocol.Flags.IS_COMPRESSED) != 0u.toUByte()) + + // Encoded should be smaller than payload + header overhead, proving compression fired + // Header(13) + sender(8) + payload(504) + padding = would be 525+ uncompressed + // With compression the raw data before padding should be much smaller + val decoded = roundTrip(original) + assertTrue("payload must survive compression round-trip", + original.payload.contentEquals(decoded.payload)) + assertEquals("payload length must match after decompression", + original.payload.size, decoded.payload.size) + } + + /** + * Small payload skips compression + * + * Creates a packet with a 50-byte payload, below the 100-byte + * compression threshold. Verifies the IS_COMPRESSED flag (bit 2) + * is NOT set in the encoded output. + * + * Compressing small payloads wastes bytes — the zlib header overhead + * can make the compressed output larger than the input. The protocol + * must skip compression for payloads under the threshold. + */ + @Test + fun `small payload skips compression`() { + val payload = ByteArray(50) { 0x41 } // 50 bytes of 'A', below threshold + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding must not return null", encoded) + + // Unpad to inspect raw flags byte + val unpadded = MessagePadding.unpad(encoded!!) + // Flags byte is at offset 11 in v1 (version=1, type=1, ttl=1, timestamp=8) + val flags = unpadded[11].toUByte() + assertEquals("IS_COMPRESSED flag must not be set", 0u.toUByte(), + flags and BinaryProtocol.Flags.IS_COMPRESSED) + + // Verify payload still round-trips + val decoded = BinaryProtocol.decode(encoded) + assertNotNull(decoded) + assertTrue("payload must match", payload.contentEquals(decoded!!.payload)) + } + + /** + * High-entropy payload skips compression + * + * Creates a 200-byte payload of random bytes (high entropy, likely + * >90% unique byte values). Verifies the encoder either skips + * compression entirely or doesn't inflate the payload size. + * + * Tests the shouldCompress() entropy check. Compressing encrypted + * or random data typically inflates it — the protocol must detect + * high-entropy payloads and skip compression to avoid wasting bytes + * and breaking the payload-length budget. + */ + @Test + fun `high-entropy payload skips compression`() { + val random = Random(42) // fixed seed for reproducibility + val payload = ByteArray(200) + random.nextBytes(payload) + + val original = makePacket(payload = payload) + val decoded = roundTrip(original) + + assertTrue("high-entropy payload must survive round-trip", + original.payload.contentEquals(decoded.payload)) + } + + /** + * Padding round-trip at each block size + * + * Creates packets with payloads sized to land in each PKCS#7 padding + * bucket (256, 512, 1024, 2048 bytes). For each, verifies the encoded + * output size matches the expected block size, then decodes and confirms + * padding is stripped and the original packet is recovered. + * + * Padding is critical for traffic analysis resistance (all packets + * appear as standard sizes on the wire) and iOS interoperability. + * If the PKCS#7 pad-byte value or validation is wrong, unpadding + * either strips too many bytes (truncating the packet) or too few + * (leaving garbage appended to the signature). + */ + @Test + fun `padding produces correct block sizes and round-trips`() { + // Test that encoded output lands on one of the standard block sizes (256, 512, 1024, 2048) + // and that decode recovers the original payload. + val validBlockSizes = setOf(256, 512, 1024, 2048) + + // Small payload: header(13) + sender(8) + payload(10) = 31 raw → pads to 256 + val smallPayload = ByteArray(10) { (it + 1).toByte() } + val smallPacket = makePacket(payload = smallPayload) + val smallEncoded = BinaryProtocol.encode(smallPacket)!! + assertTrue("Small packet must pad to a standard block size (got ${smallEncoded.size})", + smallEncoded.size in validBlockSizes) + assertEquals("Small packet should pad to 256", 256, smallEncoded.size) + val smallDecoded = BinaryProtocol.decode(smallEncoded)!! + assertTrue("Small payload must survive padding round-trip", + smallPayload.contentEquals(smallDecoded.payload)) + + // Medium payload: header(13) + sender(8) + payload(80) = 101 raw → pads to 256 + val medPayload = ByteArray(80) { (it + 1).toByte() } + val medPacket = makePacket(payload = medPayload) + val medEncoded = BinaryProtocol.encode(medPacket)!! + assertTrue("Medium packet must pad to a standard block size (got ${medEncoded.size})", + medEncoded.size in validBlockSizes) + val medDecoded = BinaryProtocol.decode(medEncoded)!! + assertTrue("Medium payload must survive padding round-trip", + medPayload.contentEquals(medDecoded.payload)) + } + + @Test + fun `encoding with padding false returns exact unpadded frame`() { + val payload = "ios-compatible-unpadded".toByteArray() + val packet = makePacket(payload = payload) + + val unpadded = BinaryProtocol.encode(packet, padding = false)!! + val padded = BinaryProtocol.encode(packet, padding = true)!! + + assertEquals( + "v1 unpadded frame size must be header + sender + payload", + 14 + 8 + payload.size, + unpadded.size + ) + assertEquals("small padded frame should pad to 256 bytes", 256, padded.size) + assertFalse( + "padding=false must not append PKCS#7 bytes", + hasPkcs7PaddingTail(unpadded) + ) + assertArrayEquals( + "padded frame must unpad to the exact raw frame", + unpadded, + MessagePadding.unpad(padded) + ) + assertPacketEquals(packet, BinaryProtocol.decode(unpadded)!!) + assertPacketEquals(packet, BinaryProtocol.decode(padded)!!) + } + + @Test + fun `BitchatPacket toBinaryData propagates padding flag`() { + val packet = makePacket(payload = "packet-helper-padding-flag".toByteArray()) + + val defaultEncoded = packet.toBinaryData()!! + val explicitlyPadded = packet.toBinaryData(padding = true)!! + val unpadded = packet.toBinaryData(padding = false)!! + + assertArrayEquals( + "default helper must remain padded for backward compatibility", + explicitlyPadded, + defaultEncoded + ) + assertTrue("default helper should produce a padded frame", defaultEncoded.size > unpadded.size) + assertArrayEquals( + "helper padding=false must match BinaryProtocol padding=false", + BinaryProtocol.encode(packet, padding = false)!!, + unpadded + ) + } + + /** + * Oversized packet bypasses padding + * + * Creates a packet whose encoded form exceeds 2048 bytes (the largest + * PKCS#7 block). Verifies it still encodes and decodes correctly even + * though padding is skipped. + * + * PKCS#7 uses a single byte for the padding length (max 255). When + * the gap between raw size and the next block exceeds 255 bytes, or + * the raw size already exceeds the largest block, padding cannot be + * applied. The protocol must still encode/decode these packets — they + * will typically be fragmented before transmission anyway. + */ + @Test + fun `oversized packet bypasses padding and still round-trips`() { + // 2100-byte payload + 21 header = 2121 raw, exceeds 2048 block + val payload = ByteArray(2100) { (it % 13).toByte() } + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding oversized packet must not return null", encoded) + + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding oversized packet must not return null", decoded) + assertTrue("Oversized payload must survive round-trip", + original.payload.contentEquals(decoded!!.payload)) + } + + /** + * v1 header uses 2-byte payload length, v2 uses 4-byte + * + * Encodes a v1 packet, unpads it, then reads bytes 11-12 as a + * big-endian unsigned short — verifies it matches the payload length. + * Does the same for v2, reading bytes 11-14 as a big-endian int. + * + * This is the core v1/v2 wire format distinction. v1 uses a 2-byte + * (UShort) payload length supporting up to 65535 bytes; v2 uses a + * 4-byte (UInt) length for larger payloads. If the encoder writes + * the wrong width, v1 clients can't read v2 packets and vice versa, + * silently corrupting all subsequent fields. + */ + @Test + fun `v1 uses 2-byte and v2 uses 4-byte payload length`() { + val payload = "length test".toByteArray() + + // v1 header: version(1) + type(1) + ttl(1) + timestamp(8) + flags(1) = 12, then payloadLen at 12-13 + val v1 = makePacket(payload = payload) + val v1Encoded = MessagePadding.unpad(BinaryProtocol.encode(v1)!!) + val v1Len = ByteBuffer.wrap(v1Encoded, 12, 2) + .order(ByteOrder.BIG_ENDIAN).short.toUShort().toInt() + assertEquals("v1 payload length field must match", payload.size, v1Len) + + // v2 header: same 12 bytes, then payloadLen at 12-15 (4 bytes) + val v2 = makePacket(version = 2u, payload = payload) + val v2Encoded = MessagePadding.unpad(BinaryProtocol.encode(v2)!!) + val v2Len = ByteBuffer.wrap(v2Encoded, 12, 4) + .order(ByteOrder.BIG_ENDIAN).int + assertEquals("v2 payload length field must match", payload.size, v2Len) + } + + /** + * v2 route flag is ignored when decoding v1 + * + * Constructs raw v1 bytes and manually sets the HAS_ROUTE flag (bit 3) + * in the flags' byte. Verifies the decoder ignores the route flag for + * v1 packets, per the spec: "HAS_ROUTE is only valid for v2+ packets." + * + * Ensures forward compatibility — if an old v1 packet somehow has + * unexpected flags set (e.g., from a future protocol extension or + * corruption), the decoder must not misinterpret trailing bytes as + * a route section, which would corrupt payload parsing. + */ + @Test + fun `v1 decoder ignores HAS_ROUTE flag`() { + val payload = "route-flag-test".toByteArray() + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original)!! + val unpadded = MessagePadding.unpad(encoded) + + // Manually set HAS_ROUTE flag (bit 3) on the v1 packet + val tampered = unpadded.copyOf() + tampered[11] = (tampered[11].toInt() or 0x08).toByte() + + // Re-pad so decode can handle it + val repadded = MessagePadding.pad(tampered, MessagePadding.optimalBlockSize(tampered.size)) + val decoded = BinaryProtocol.decode(repadded) + + assertNotNull("v1 with HAS_ROUTE flag must still decode", decoded) + assertNull("v1 packet must have null route regardless of flag", decoded!!.route) + assertTrue("payload must still be correct", + payload.contentEquals(decoded.payload)) + } + + /** + * Empty payload + * + * Encodes a packet with payload = ByteArray(0), then decodes it and + * verifies the empty payload survives the round-trip. + * + * ANNOUNCE and LEAVE packets can have minimal or empty payloads. + * A zero-length payload must not break the payload-length field + * calculation, cause an off-by-one in buffer positioning, or + * trigger compression (which would fail on empty input). + */ + @Test + fun `empty payload round-trips correctly`() { + val original = makePacket( + payload = ByteArray(0), + type = MessageType.LEAVE.value + ) + + val decoded = roundTrip(original) + + assertEquals("payload must be empty", 0, decoded.payload.size) + assertEquals("type must match", MessageType.LEAVE.value, decoded.type) + } + + /** + * Maximum v1 payload (65535 bytes) + * + * Encodes a v1 packet at the 2-byte payload length limit (65535 bytes, + * the maximum value of a UShort). Decodes and verifies the payload + * content and length survive. + * + * v1 uses an unsigned 16-bit integer for payload length. At the boundary + * of 65535, signed/unsigned conversion bugs surface — Java's short is + * signed (-32768 to 32767), so the encoder must handle the cast to + * UShort correctly. If it wraps to negative, the decoder reads a bogus + * payload length and fails. + */ + @Test + fun `maximum v1 payload length round-trips correctly`() { + val payload = ByteArray(65535) { (it % 251).toByte() } + val original = makePacket(payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding max-size payload must not return null", encoded) + + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding max-size payload must not return null", decoded) + assertEquals("payload length must match", 65535, decoded!!.payload.size) + // Spot-check a few bytes rather than full compare (compression may or may not fire) + // The full content check is what matters + assertTrue("payload content must match", + original.payload.contentEquals(decoded.payload)) + } + + /** + * Truncated data returns null + * + * Passes progressively truncated byte arrays to decode(): first just + * 1 byte, then the minimum header size minus one, then a valid header + * with a truncated payload. Verifies decode returns null for each + * without throwing an exception. + * + * Malformed BLE packets are common — the radio can drop trailing bytes, + * fragment reassembly can fail, or a peer can send garbage. The decoder + * must fail gracefully with null, never throw BufferUnderflowException + * or ArrayIndexOutOfBoundsException, as uncaught exceptions crash the + * mesh service and disconnect all peers. + */ + @Test + fun `truncated data returns null without crashing`() { + // Work with unpadded bytes so truncation produces genuinely incomplete packets. + // Padded data can accidentally contain valid-looking sub-packets. + val valid = makePacket(payload = "truncation test".toByteArray()) + val encoded = BinaryProtocol.encode(valid)!! + val raw = MessagePadding.unpad(encoded) + // raw is the real packet without PKCS#7 padding + + // Truncation points that are definitely too short for the declared payload: + // v1 min = HEADER(13) + SENDER(8) = 21, payload declared as 15, so need 36 total + val truncationPoints = listOf(0, 1, 5, 12, 20, 25, 30) + for (len in truncationPoints) { + if (len > raw.size) continue + val truncated = raw.copyOfRange(0, len) + val result = BinaryProtocol.decode(truncated) + assertNull("Truncated to $len bytes must return null", result) + } + } + + /** + * Invalid version returns null + * + * Passes byte arrays with version=0, version=3, and version=255 to + * the decoder. Verifies each returns null. + * + * Only v1 and v2 are supported by the protocol. Unknown versions must + * be rejected immediately — attempting to parse them would use wrong + * header sizes, payload length widths, and feature flags, leading to + * garbage output or buffer overflows. + */ + @Test + fun `invalid version returns null`() { + val valid = makePacket(payload = "version test".toByteArray()) + val encoded = MessagePadding.unpad(BinaryProtocol.encode(valid)!!) + + for (badVersion in listOf(0, 3, 255)) { + val tampered = encoded.copyOf() + tampered[0] = badVersion.toByte() + val repadded = MessagePadding.pad(tampered, MessagePadding.optimalBlockSize(tampered.size)) + val result = BinaryProtocol.decode(repadded) + assertNull("Version $badVersion must return null", result) + } + } + + /** + * Garbage data returns null + * + * Passes random byte arrays of various sizes (10, 100, 512, 2048) to + * the decoder. Verifies it returns null (or at most a valid-looking + * packet if the random bytes happen to parse) without ever throwing + * an exception. + * + * A fuzz-like robustness test. In a mesh network, any device can send + * any bytes. The decoder must never crash, leak memory, or enter an + * infinite loop on arbitrary input — it should simply return null for + * anything it can't parse as a valid BitchatPacket. + */ + @Test + fun `garbage data returns null without crashing`() { + val random = Random(12345) + for (size in listOf(10, 100, 512, 2048)) { + val garbage = ByteArray(size) + random.nextBytes(garbage) + // Just verify no exception is thrown; null is the expected result + // but random data COULD accidentally form a valid packet structure + try { + BinaryProtocol.decode(garbage) + } catch (e: Exception) { + throw AssertionError("Garbage data of size $size must not throw, got: ${e.message}", e) + } + } + } + + /** + * Sender ID truncation and padding + * + * Tests senderID shorter than 8 bytes (should be zero-padded to 8) + * and longer than 8 bytes (should be truncated to 8). Verifies + * exactly 8 bytes appear in the decoded senderID. + * + * PeerIDs are always exactly 8 bytes in the wire format. If a short + * senderID isn't padded, the encoder writes fewer bytes, shifting all + * subsequent fields. If a long senderID isn't truncated, the encoder + * writes extra bytes into the recipientID or payload region, silently + * corrupting the packet. + */ + @Test + fun `sender ID is padded or truncated to exactly 8 bytes`() { + // Short sender (4 bytes) — should be zero-padded to 8 + val shortSender = byteArrayOf(0x11, 0x22, 0x33, 0x44) + val shortPacket = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = shortSender, + timestamp = fixedTimestamp, + payload = "short-sender".toByteArray(), + ttl = 3u + ) + val decodedShort = roundTrip(shortPacket) + assertEquals("decoded senderID must be 8 bytes", 8, decodedShort.senderID.size) + // First 4 bytes must match, rest should be zeros + for (i in 0 until 4) { + assertEquals("senderID byte $i must match", shortSender[i], decodedShort.senderID[i]) + } + for (i in 4 until 8) { + assertEquals("senderID byte $i must be zero-padded", 0.toByte(), decodedShort.senderID[i]) + } + + // Long sender (12 bytes) — should be truncated to 8 + val longSender = ByteArray(12) { (it + 0x50).toByte() } + val longPacket = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = longSender, + timestamp = fixedTimestamp, + payload = "long-sender".toByteArray(), + ttl = 3u + ) + val decodedLong = roundTrip(longPacket) + assertEquals("decoded senderID must be 8 bytes", 8, decodedLong.senderID.size) + for (i in 0 until 8) { + assertEquals("senderID byte $i must match first 8", longSender[i], decodedLong.senderID[i]) + } + } + + /** + * Signing data excludes signature and uses fixed TTL + * + * Creates a packet with a 64-byte signature and TTL=7, then calls + * toBinaryDataForSigning(). Decodes the result and verifies: + * (1) the signature is null (stripped for signing), and (2) the TTL + * is 0 (SYNC_TTL_HOPS), not the original 7. + * + * Packet signatures must be deterministic regardless of how many + * hops the packet has traversed (TTL decrements at each relay). + * If TTL leaks into the signed data, a packet relayed even once + * would fail signature verification at the recipient because the + * TTL it was signed with differs from the TTL it arrives with. + * The signature itself must also be excluded from the data being + * signed (you can't sign data that includes its own signature). + */ + @Test + fun `toBinaryDataForSigning excludes signature and fixes TTL`() { + val signature = ByteArray(64) { (it + 0xD0).toByte() } + val original = makePacket( + payload = "sign-me".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature, + ttl = 7u + ) + + val signingData = original.toBinaryDataForSigning() + assertNotNull("Signing data must not be null", signingData) + + val decoded = BinaryProtocol.decode(signingData!!) + assertNotNull("Signing data must decode successfully", decoded) + + assertNull("Signature must be stripped for signing", decoded!!.signature) + assertEquals("TTL must be fixed to SYNC_TTL_HOPS (0) for signing", + 0u.toUByte(), decoded.ttl) + // Other fields must be preserved + assertEquals("type must match", original.type, decoded.type) + assertEquals("timestamp must match", original.timestamp, decoded.timestamp) + assertTrue("senderID must match", + original.senderID.contentEquals(decoded.senderID)) + assertTrue("recipientID must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + assertTrue("payload must match", + original.payload.contentEquals(decoded.payload)) + } + + /** + * v2 packet without route round-trips correctly + * + * Creates a v2 packet with recipientID and signature but NO route — + * the most common real-world v2 shape for private signed messages. + * Verifies all fields survive the round-trip and route is null. + * + * This exercises the 4-byte payload length field without the route + * section. If the v2 header size (15 bytes) or payload length width + * is wrong, all subsequent fields shift, corrupting decode. + * The v2-with-route tests could pass by accident if route parsing + * compensates for a header bug — this test isolates v2 header logic. + */ + @Test + fun `v2 packet without route round-trips correctly`() { + val signature = ByteArray(64) { (it + 0xE0).toByte() } + val original = makePacket( + version = 2u, + payload = "v2 no route".toByteArray(), + recipientID = hexToBytes(recipientHex), + signature = signature, + route = null + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertEquals("version must be 2", 2u.toUByte(), decoded.version) + assertNull("route must be null", decoded.route) + assertNotNull("recipientID must not be null", decoded.recipientID) + assertNotNull("signature must not be null", decoded.signature) + assertTrue("recipientID bytes must match", + original.recipientID!!.contentEquals(decoded.recipientID!!)) + assertTrue("signature bytes must match", + original.signature!!.contentEquals(decoded.signature!!)) + } + + /** + * v2 compressed payload round-trips correctly + * + * Creates a v2 packet with a large, compressible payload that triggers + * compression. Verifies the IS_COMPRESSED flag is set, the v2 4-byte + * original-size field is written/read correctly, and the payload + * survives the round-trip intact. + * + * Covers encode lines 220, 290-292 (v2 compressed size field) and + * decode lines 420-424 (v2 compressed size read). + */ + @Test + fun `v2 compressed payload round-trips correctly`() { + val repeating = "ABCDEFGH".repeat(63) // 504 bytes, highly compressible + val payload = repeating.toByteArray() + val original = makePacket(version = 2u, payload = payload) + + val encoded = BinaryProtocol.encode(original) + assertNotNull("Encoding must not return null", encoded) + + // Verify IS_COMPRESSED flag is set + val unpadded = MessagePadding.unpad(encoded!!) + val flags = unpadded[11].toUByte() + assertTrue("IS_COMPRESSED flag must be set", + (flags and BinaryProtocol.Flags.IS_COMPRESSED) != 0u.toUByte()) + + // Verify version is 2 + assertEquals("version byte must be 2", 2.toByte(), unpadded[0]) + + val decoded = roundTrip(original) + assertEquals("version must be 2", 2u.toUByte(), decoded.version) + assertTrue("payload must survive v2 compression round-trip", + original.payload.contentEquals(decoded.payload)) + assertEquals("payload length must match after decompression", + original.payload.size, decoded.payload.size) + } + + /** + * Short recipientID is zero-padded to 8 bytes + * + * Creates a packet with a 4-byte recipientID and verifies that after + * encoding and decoding, the recipientID is exactly 8 bytes with + * trailing zeros. + * + * Covers encode lines 272-273 (recipientID padding). + */ + @Test + fun `short recipientID is zero-padded to 8 bytes`() { + val shortRecipient = byteArrayOf(0x11, 0x22, 0x33, 0x44) + val original = makePacket( + payload = "short-recipient".toByteArray(), + recipientID = shortRecipient + ) + + val decoded = roundTrip(original) + + assertNotNull("recipientID must not be null", decoded.recipientID) + assertEquals("recipientID must be 8 bytes", 8, decoded.recipientID!!.size) + for (i in 0 until 4) { + assertEquals("recipientID byte $i must match", + shortRecipient[i], decoded.recipientID[i]) + } + for (i in 4 until 8) { + assertEquals("recipientID byte $i must be zero-padded", + 0.toByte(), decoded.recipientID[i]) + } + } + + /** + * v2 packet with route but no recipient round-trips correctly + * + * Creates a v2 packet with route hops but recipientID=null. Verifies + * the route survives the round-trip and recipientID remains null. + * + * Covers encode lines 278-280, 248, 222 (route encode without recipient) + * and decode line 378 (hasRecipient=false branch in route offset calc). + */ + @Test + fun `v2 packet with route but no recipient round-trips correctly`() { + val route = listOf( + hexToBytes("AABBCCDDEEFF0011"), + hexToBytes("1100FFEEDDCCBBAA") + ) + val original = makePacket( + version = 2u, + payload = "routed broadcast".toByteArray(), + recipientID = null, + route = route + ) + + val decoded = roundTrip(original) + + assertPacketEquals(original, decoded) + assertNull("recipientID must be null", decoded.recipientID) + assertNotNull("route must not be null", decoded.route) + assertEquals("route hop count", 2, decoded.route!!.size) + for (i in route.indices) { + assertTrue("route hop $i must match", + route[i].contentEquals(decoded.route!![i])) + } + } + + /** + * v2 packet with HAS_ROUTE flag and count zero decodes route as null + * + * Manually crafts raw v2 bytes with the HAS_ROUTE flag set but the + * route count byte = 0. Verifies the decoder treats this as null + * (canonical representation). + * + * Covers decode lines 405-406 (count==0 → null). + */ + @Test + fun `v2 packet with HAS_ROUTE flag and count zero decodes route as null`() { + val payload = "route-zero".toByteArray() + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: HAS_ROUTE set + buffer.put(BinaryProtocol.Flags.HAS_ROUTE.toByte()) + + // Payload length (4 bytes for v2) + buffer.putInt(payload.size) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Route: count = 0 (1 byte) + buffer.put(0.toByte()) + + // Payload + buffer.put(payload) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val decoded = BinaryProtocol.decode(padded) + + assertNotNull("Packet with route count=0 must decode", decoded) + assertNull("Route with count=0 must decode as null", decoded!!.route) + assertTrue("Payload must match", payload.contentEquals(decoded.payload)) + } + + /** + * Re-encoding preserves a payload compressed by a different DEFLATE encoder + * + * Verification re-encodes the packet, so a re-encode must reproduce the + * originator's bytes. iOS compresses with Apple's compression_encode_buffer, + * which need not match java.util.zip.Deflater byte for byte. + * + * The payload is wrapped in a raw-DEFLATE "stored" block (BTYPE=00): valid, it + * inflates correctly, and no compressor would emit it, so it stands in for a + * foreign encoder without a second compression library. Compared unpadded so + * the result does not depend on how padding is generated. + */ + @Test + fun `re-encoding preserves a foreign encoder's compressed payload`() { + val payload = "the mesh is up near the north gate. relay running all evening. " + .repeat(4) + .toByteArray() + + // Raw DEFLATE stored block: BFINAL=1 BTYPE=00, then LEN and NLEN little-endian. + val stored = ByteArray(5 + payload.size) + stored[0] = 0x01 + stored[1] = (payload.size and 0xFF).toByte() + stored[2] = ((payload.size shr 8) and 0xFF).toByte() + stored[3] = (payload.size.inv() and 0xFF).toByte() + stored[4] = ((payload.size.inv() shr 8) and 0xFF).toByte() + payload.copyInto(stored, 5) + + val buffer = ByteBuffer.allocate(1024).apply { order(ByteOrder.BIG_ENDIAN) } + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) // flags + buffer.putInt(stored.size + 4) // payloadLength incl. originalSize + buffer.put(hexToBytes(senderHex)) // senderID (8 bytes) + buffer.putInt(payload.size) // originalSize + buffer.put(stored) // compressed payload + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + val decoded = BinaryProtocol.decode(raw) + assertNotNull("Foreign-encoded frame must decode", decoded) + assertArrayEquals("Payload must inflate to the original", payload, decoded!!.payload) + + val reEncoded = BinaryProtocol.encode(decoded, padding = false) + assertNotNull("Re-encode must succeed", reEncoded) + assertArrayEquals( + "Re-encode must reproduce the originator's bytes, or a valid signature stops verifying", + raw, + reEncoded + ) + } + + /** + * v2 compression bomb is rejected + * + * Same concept as the v1 compression bomb test but with version=2, + * which uses a 4-byte original-size field instead of 2-byte. + * + * Covers decode lines 435-439 via the v2 path (4-byte size field). + */ + @Test + fun `v2 compression bomb is rejected`() { + // Valid raw deflate final empty stored block (1 byte). + // Claim a huge original size to exceed the 50,000:1 ratio guard. + // ratio = 10,000,000 / 1 = 10,000,000:1 + val compressedData = byteArrayOf(0x03) + val declaredOriginalSize = 10_000_000 + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: IS_COMPRESSED set + buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + + // Payload length (4 bytes for v2): original-size field (4 bytes) + compressed data + val payloadFieldSize = 4 + compressedData.size + buffer.putInt(payloadFieldSize) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Compressed payload section: original size (4 bytes for v2) + compressed data + buffer.putInt(declaredOriginalSize) + buffer.put(compressedData) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val result = BinaryProtocol.decode(padded) + + assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result) + } + + @Test + fun `v2 expanded payload at exact maximum passes bound without allocating output`() { + val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + val raw = compressedPacket( + version = 2u, + originalSize = max, + compressedData = ByteArray(256) { it.toByte() } + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize -> + calls += 1 + assertEquals(max, requestedSize) + null // Prove the boundary reached this seam without allocating a 10 MiB result. + } + + assertNull("The test decompressor deliberately returns no payload", result) + assertEquals(1, calls) + } + + @Test + fun `v2 expanded payload above maximum never reaches decompressor`() { + val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + val raw = compressedPacket( + version = 2u, + originalSize = max + 1, + compressedData = ByteArray(256) { it.toByte() } + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + byteArrayOf(0x42) + } + + assertNull("An oversized expansion must be rejected before inflation", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `v2 negative expanded payload never reaches decompressor`() { + val raw = compressedPacket( + version = 2u, + originalSize = -1, + compressedData = byteArrayOf(0x03) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + byteArrayOf(0x42) + } + + assertNull("A negative expansion must be rejected before inflation", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `zero expanded payload never reaches decompressor`() { + val raw = compressedPacket( + version = 2u, + originalSize = 0, + compressedData = rawDeflate(ByteArray(0)) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + ByteArray(0) + } + + assertNull("Compressed zero-length payloads are non-canonical and must be rejected", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `empty compressed body never reaches decompressor`() { + val raw = compressedPacket( + version = 2u, + originalSize = 128, + compressedData = ByteArray(0) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, _ -> + calls += 1 + ByteArray(128) + } + + assertNull("A compressed payload must contain deflate bytes", result) + assertEquals("The decompressor must not be invoked", 0, calls) + } + + @Test + fun `v1 unsigned maximum expanded size reaches decompressor`() { + val originalSize = 0xFFFF + val raw = compressedPacket( + version = 1u, + originalSize = originalSize, + compressedData = byteArrayOf(0x01, 0x02) + ) + var calls = 0 + + val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize -> + calls += 1 + assertEquals(originalSize, requestedSize) + null + } + + assertNull("The test decompressor deliberately returns no payload", result) + assertEquals(1, calls) + } + + @Test + fun `compression utility rejects invalid expansion sizes directly`() { + val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + + assertNull(CompressionUtil.decompress(ByteArray(0), 1)) + assertNull(CompressionUtil.decompress(rawDeflate(ByteArray(0)), 0)) + assertNull(CompressionUtil.decompress(byteArrayOf(0x03), -1)) + assertNull(CompressionUtil.decompress(byteArrayOf(0x03), max + 1)) + } + + @Test + fun `raw deflate expands only when size and stream completion are exact`() { + val payload = ByteArray(4_096) { index -> (index % 17).toByte() } + val compressed = rawDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed) + ) + + assertNotNull(decoded) + assertArrayEquals(payload, decoded!!.payload) + } + + @Test + fun `zlib wrapped payload remains compatible when size and stream completion are exact`() { + val payload = ByteArray(4_096) { index -> (index % 23).toByte() } + val compressed = zlibDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed) + ) + + assertNotNull(decoded) + assertArrayEquals(payload, decoded!!.payload) + } + + @Test + fun `raw deflate with zlib-looking prefix falls back after non-exact zlib parse`() { + val payload = ByteArray(29) { index -> (index + 1).toByte() } + val compressed = byteArrayOf( + 0x08, // non-final raw stored block; also zlib CMF + 0x1d, 0x00, // LEN = 29; 0x08 0x1d passes the RFC 1950 header check + 0xe2.toByte(), 0xff.toByte() // one's complement of LEN + ) + payload + byteArrayOf(0x03, 0x00) // final empty fixed-Huffman block + + assertArrayEquals(payload, CompressionUtil.decompress(compressed, payload.size)) + } + + @Test + fun `under-declared zlib expansion is rejected by fallback`() { + val payload = ByteArray(4_096) { 0x51 } + val compressed = zlibDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 128, compressedData = compressed) + ) + + assertNull("Zlib fallback must reject output beyond the declaration", decoded) + } + + @Test + fun `over-declared zlib expansion is rejected by fallback`() { + val payload = ByteArray(128) { 0x52 } + val compressed = zlibDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 256, compressedData = compressed) + ) + + assertNull("Zlib fallback must reject output shorter than the declaration", decoded) + } + + @Test + fun `truncated zlib stream is rejected by fallback`() { + val payload = ByteArray(4_096) { index -> (index % 29).toByte() } + val compressed = zlibDeflate(payload) + val truncated = compressed.copyOf(compressed.size - 1) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated) + ) + + assertNull("Zlib fallback must require the stream end marker and checksum", decoded) + } + + @Test + fun `zlib stream with trailing bytes is rejected by fallback`() { + val payload = ByteArray(4_096) { index -> (index % 13).toByte() } + val compressedWithTrailingByte = zlibDeflate(payload) + byteArrayOf(0x00) + + val decoded = BinaryProtocol.decode( + compressedPacket( + version = 2u, + originalSize = payload.size, + compressedData = compressedWithTrailingByte + ) + ) + + assertNull("Zlib fallback must consume the complete input and nothing more", decoded) + } + + @Test + fun `under-declared raw expansion is rejected even when output buffer fills`() { + val payload = ByteArray(4_096) { 0x41 } + val compressed = rawDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 128, compressedData = compressed) + ) + + assertNull("Inflater must be finished, not merely fill the declared buffer", decoded) + } + + @Test + fun `over-declared raw expansion is rejected instead of returning a prefix`() { + val payload = ByteArray(128) { 0x42 } + val compressed = rawDeflate(payload) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = 256, compressedData = compressed) + ) + + assertNull("The expanded byte count must equal the declaration", decoded) + } + + @Test + fun `truncated raw stream is rejected even if all declared bytes were emitted`() { + val payload = ByteArray(4_096) { index -> (index % 31).toByte() } + val compressed = rawDeflate(payload) + val truncated = compressed.copyOf(compressed.size - 1) + + val decoded = BinaryProtocol.decode( + compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated) + ) + + assertNull("A stream without its end marker must not be accepted", decoded) + } + + @Test + fun `raw stream with trailing bytes is rejected`() { + val payload = ByteArray(4_096) { index -> (index % 19).toByte() } + val compressedWithTrailingByte = rawDeflate(payload) + byteArrayOf(0x00) + + val decoded = BinaryProtocol.decode( + compressedPacket( + version = 2u, + originalSize = payload.size, + compressedData = compressedWithTrailingByte + ) + ) + + assertNull("Trailing bytes after a complete stream must not be accepted", decoded) + } + + @Test + fun `decoder rejects a decompressor result shorter than its declaration`() { + val raw = compressedPacket( + version = 2u, + originalSize = 128, + compressedData = ByteArray(16) { it.toByte() } + ) + + val decoded = BinaryProtocol.decodeForTesting(raw) { _, _ -> byteArrayOf(0x01) } + + assertNull("BinaryProtocol must independently enforce the declared expanded size", decoded) + } + + @Test + fun `new sender refuses legacy 11 MiB public file transfer before transmission`() { + val content = ByteArray(11 * 1024 * 1024) { 0x41 } + val filePayload = BitchatFilePacket( + fileName = "legacy-11m.bin", + fileSize = content.size.toLong(), + mimeType = "application/octet-stream", + content = content + ).encode() + assertNotNull(filePayload) + + val encoded = BinaryProtocol.encode( + BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = hexToBytes(senderHex), + recipientID = SpecialRecipients.BROADCAST, + timestamp = fixedTimestamp, + payload = filePayload!!, + ttl = 5u + ), + padding = false + ) + assertNull( + "Sender and receiver must enforce the same expanded-payload ceiling", + encoded + ) + } + + /** + * Compression bomb is rejected + * + * Crafts a packet where the declared original size in the compressed + * payload section is absurdly large relative to the compressed data, + * exceeding the 50,000:1 ratio guard in decodeCore(). Verifies the + * decoder returns null instead of attempting decompression. + * + * Without this check, an attacker could send a tiny packet claiming + * to decompress into gigabytes, causing an OOM crash that kills the + * mesh service and disconnects all peers. The 50,000:1 threshold + * blocks this while still allowing legitimate compression ratios + * (typical text compresses ~3:1 to ~10:1). + */ + @Test + fun `compression bomb is rejected`() { + // Valid raw deflate final empty stored block (1 byte). + // v1 uses a 2-byte (UShort) original-size field, so declared size + // must fit in 0..65535. ratio = 60,000 / 1 = 60,000:1 > 50,000:1 + val tinyCompressed = byteArrayOf(0x03) + val declaredOriginalSize = 60_000 + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // Header (13 bytes for v1) + buffer.put(1.toByte()) // version = 1 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: IS_COMPRESSED set + buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + + // Payload length: original-size field (2 bytes) + compressed data (1 byte) = 3 + val payloadFieldSize = 2 + tinyCompressed.size + buffer.putShort(payloadFieldSize.toShort()) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Compressed payload section: original size (2 bytes) + compressed data + buffer.putShort(declaredOriginalSize.toShort()) + buffer.put(tinyCompressed) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + // Pad to standard block size so decode() can process it + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val result = BinaryProtocol.decode(padded) + + assertNull("Compression bomb (ratio > 50,000:1) must be rejected", result) + } + + /** + * v1 packet with route silently drops route + * + * Creates a v1 packet with route hops set, encodes and decodes it. + * Since routes are only supported in v2+, the encoder must silently + * drop the route for v1, producing a packet with route=null. + * + * Covers encode lines 222, 248 (false branch: route non-empty but + * version < 2) and decode line 279-280 (route not written for v1). + */ + @Test + fun `v1 packet with route silently drops route`() { + val route = listOf( + hexToBytes("AABBCCDDEEFF0011"), + hexToBytes("1100FFEEDDCCBBAA") + ) + val original = makePacket( + version = 1u, + payload = "v1 route drop".toByteArray(), + route = route + ) + + val decoded = roundTrip(original) + + assertEquals("version must be 1", 1u.toUByte(), decoded.version) + assertNull("route must be null for v1 packet", decoded.route) + assertTrue("payload must survive round-trip", + original.payload.contentEquals(decoded.payload)) + } + + /** + * v2 truncated packet with HAS_ROUTE flag returns null + * + * Manually crafts a v2 packet with the HAS_ROUTE flag set but + * truncates the data before the route count byte. The decoder must + * return null because raw.size < routeOffset + 1. + * + * Covers decode line 382 false branch (raw too short for route count). + */ + @Test + fun `v2 truncated packet with HAS_ROUTE flag returns null`() { + val payload = "truncated-route".toByteArray() + + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: HAS_ROUTE set + buffer.put(BinaryProtocol.Flags.HAS_ROUTE.toByte()) + + // Payload length (4 bytes for v2) + buffer.putInt(payload.size) + + // SenderID (8 bytes) — after this, the route count byte should follow + // but we truncate here, so raw.size < routeOffset + 1 + buffer.put(hexToBytes(senderHex)) + + // Do NOT write route count or payload — truncate right after senderID + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + // Don't pad — pass raw bytes directly so truncation is effective + val result = BinaryProtocol.decode(raw) + + assertNull("Truncated v2 packet with HAS_ROUTE must return null", result) + } + + /** + * v2 compressed packet with payloadLength less than size field returns null + * + * Manually crafts v2 bytes with IS_COMPRESSED flag and a payloadLength + * of 2, which is less than the 4-byte original-size field required for + * v2 compressed payloads. The decoder must return null. + * + * Covers decode line 421 true branch (payloadLength < lengthFieldBytes). + */ + @Test + fun `v2 compressed packet with payloadLength less than size field returns null`() { + val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) } + + // v2 header + buffer.put(2.toByte()) // version = 2 + buffer.put(MessageType.MESSAGE.value.toByte()) // type + buffer.put(5.toByte()) // ttl + buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes) + + // Flags: IS_COMPRESSED set + buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + + // Payload length = 2 (less than 4-byte size field for v2) + buffer.putInt(2) + + // SenderID (8 bytes) + buffer.put(hexToBytes(senderHex)) + + // Write 2 bytes of fake payload data (matching declared payloadLength) + buffer.put(byteArrayOf(0x01, 0x02)) + + val raw = ByteArray(buffer.position()) + buffer.rewind() + buffer.get(raw) + + val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) + val result = BinaryProtocol.decode(padded) + + assertNull("v2 compressed with payloadLength < 4 must return null", result) + } + + private fun hexToBytes(hex: String): ByteArray { + val result = ByteArray(hex.length / 2) + for (i in result.indices) { + result[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + return result + } + + private fun compressedPacket( + version: UByte, + originalSize: Int, + compressedData: ByteArray, + type: UByte = MessageType.MESSAGE.value + ): ByteArray { + val originalSizeFieldBytes = if (version >= 2u.toUByte()) 4 else 2 + val payloadLength = originalSizeFieldBytes + compressedData.size + val headerSize = if (version >= 2u.toUByte()) 16 else 14 + val buffer = ByteBuffer.allocate(headerSize + 8 + payloadLength).apply { + order(ByteOrder.BIG_ENDIAN) + put(version.toByte()) + put(type.toByte()) + put(5.toByte()) + putLong(fixedTimestamp.toLong()) + put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + if (version >= 2u.toUByte()) { + putInt(payloadLength) + } else { + putShort(payloadLength.toShort()) + } + put(hexToBytes(senderHex)) + if (version >= 2u.toUByte()) { + putInt(originalSize) + } else { + putShort(originalSize.toShort()) + } + put(compressedData) + } + return buffer.array() + } + + private fun rawDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = true) + + private fun zlibDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = false) + + private fun deflate(data: ByteArray, nowrap: Boolean): ByteArray { + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, nowrap) + return try { + deflater.setInput(data) + deflater.finish() + val output = ByteArrayOutputStream() + val buffer = ByteArray(1_024) + while (!deflater.finished()) { + val count = deflater.deflate(buffer) + output.write(buffer, 0, count) + } + output.toByteArray() + } finally { + deflater.end() + } + } + + private fun makePacket( + version: UByte = 1u, + type: UByte = MessageType.MESSAGE.value, + payload: ByteArray, + recipientID: ByteArray? = null, + signature: ByteArray? = null, + ttl: UByte = 5u, + route: List? = null + ) = BitchatPacket( + version = version, + type = type, + senderID = hexToBytes(senderHex), + recipientID = recipientID, + timestamp = fixedTimestamp, + payload = payload, + signature = signature, + ttl = ttl, + route = route + ) + + private fun roundTrip(packet: BitchatPacket): BitchatPacket { + val encoded = BinaryProtocol.encode(packet) + assertNotNull("Encoding must not return null", encoded) + val decoded = BinaryProtocol.decode(encoded!!) + assertNotNull("Decoding must not return null", decoded) + return decoded!! + } + + private fun hasPkcs7PaddingTail(data: ByteArray): Boolean { + if (data.isEmpty()) return false + val paddingLength = data.last().toInt() and 0xFF + if (paddingLength <= 0 || paddingLength > data.size) return false + val start = data.size - paddingLength + return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength } + } + + private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) { + assertEquals("version", expected.version, actual.version) + assertEquals("type", expected.type, actual.type) + assertEquals("ttl", expected.ttl, actual.ttl) + assertEquals("timestamp", expected.timestamp, actual.timestamp) + assertTrue("senderID", expected.senderID.contentEquals(actual.senderID)) + assertTrue("payload", expected.payload.contentEquals(actual.payload)) + } +} diff --git a/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt b/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt new file mode 100644 index 00000000..843d88a2 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class DecompressionResourcePoolTest { + @Test + fun `small reservations run concurrently and next waits only when budget is full`() { + val pool = DecompressionResourcePool( + budgetBytes = 2_000, + unitBytes = 1_000, + waitTimeoutMs = 2_000 + ) + val executor = Executors.newFixedThreadPool(3) + val entered = CountDownLatch(2) + val release = CountDownLatch(1) + val thirdEntered = CountDownLatch(1) + + try { + repeat(2) { + executor.submit { + pool.withReservation(1_000) { + entered.countDown() + release.await() + } + } + } + assertTrue(entered.await(1, TimeUnit.SECONDS)) + + executor.submit { + pool.withReservation(1_000) { + thirdEntered.countDown() + } + } + assertFalse("third reservation must wait while budget is full", thirdEntered.await(100, TimeUnit.MILLISECONDS)) + + release.countDown() + assertTrue("third reservation must proceed after release", thirdEntered.await(1, TimeUnit.SECONDS)) + } finally { + release.countDown() + executor.shutdownNow() + } + } + + @Test + fun `timed admission drops work instead of waiting indefinitely`() { + val pool = DecompressionResourcePool( + budgetBytes = 1_000, + unitBytes = 1_000, + waitTimeoutMs = 50 + ) + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val executor = Executors.newSingleThreadExecutor() + + try { + executor.submit { + pool.withReservation(1_000) { + entered.countDown() + release.await() + } + } + assertTrue(entered.await(1, TimeUnit.SECONDS)) + assertNull(pool.withReservation(1_000) { "unexpected" }) + } finally { + release.countDown() + executor.shutdownNow() + } + } + + @Test + fun `permits are released when decode throws`() { + val pool = DecompressionResourcePool(2_000, 1_000, 50) + + try { + pool.withReservation(2_000) { error("boom") } + } catch (_: IllegalStateException) { + // Expected. + } + + assertEquals(2, pool.availablePermits) + assertEquals("ok", pool.withReservation(2_000) { "ok" }) + } + + @Test + fun `runtime budget is based on heap memory and always admits one maximum packet`() { + val maxPacketResources = 20L * 1024 * 1024 + + assertEquals( + maxPacketResources, + DecompressionResourcePool.recommendedBudgetBytes( + maxHeapBytes = 64L * 1024 * 1024, + maxPacketResourceBytes = maxPacketResources + ) + ) + assertEquals( + 32L * 1024 * 1024, + DecompressionResourcePool.recommendedBudgetBytes( + maxHeapBytes = 256L * 1024 * 1024, + maxPacketResourceBytes = maxPacketResources + ) + ) + assertEquals( + 64L * 1024 * 1024, + DecompressionResourcePool.recommendedBudgetBytes( + maxHeapBytes = 2L * 1024 * 1024 * 1024, + maxPacketResourceBytes = maxPacketResources + ) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt b/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt new file mode 100644 index 00000000..8514d088 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt @@ -0,0 +1,165 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import java.nio.charset.StandardCharsets +import java.util.Base64 + +class CashuTokenDecoderTest { + @Test + fun `v3 sums proofs and sanitizes display metadata`() { + val token = v3Token( + """{"token":[{"mint":"https://MINT.example.com/path","proofs":[{"amount":1},{"amount":4},{"amount":16}]}],"unit":"sat","memo":" lunch\n"}""" + ) + + val info = CashuTokenDecoder.decode(token, strict = true) + + assertEquals(21L, info?.amount) + assertEquals("21 sat", info?.displayAmount) + assertEquals("mint.example.com", info?.mintHost) + assertEquals("lunch", info?.memo) + } + + @Test + fun `display amount applies currency minor units without changing proof sum`() { + val usd = CashuTokenDecoder.decode( + v3Token("""{"token":[{"proofs":[{"amount":900},{"amount":1}]}],"unit":"usd"}"""), + strict = true + ) + val jpy = CashuTokenDecoder.decode( + v3Token("""{"token":[{"proofs":[{"amount":901}]}],"unit":"jpy"}"""), + strict = true + ) + + assertEquals(901L, usd?.amount) + assertEquals("9.01 usd", usd?.displayAmount) + assertEquals("901 jpy", jpy?.displayAmount) + } + + @Test + fun `display amount does not assume decimals for custom units`() { + val token = CashuTokenDecoder.decode( + v3Token("""{"token":[{"proofs":[{"amount":901}]}],"unit":"usdc"}"""), + strict = true + ) + + assertEquals("901 usdc", token?.displayAmount) + } + + @Test + fun `v4 definite length token decodes strictly`() { + val token = validV4Token() + + val info = CashuTokenDecoder.decode(token, strict = true) + + assertEquals(21L, info?.amount) + assertEquals("mint.example.com", info?.mintHost) + } + + @Test + fun `strict v4 rejects truncation junk and trailing data`() { + val valid = validV4Token() + val truncated = valid.dropLast(12) + val junk = "cashuB" + "Q".repeat(40) + val payloadWithTrailingGarbage = decodePayload(valid) + byteArrayOf(0) + val trailing = "cashuB" + base64Url(payloadWithTrailingGarbage) + + assertNull(CashuTokenDecoder.decode(truncated, strict = true)) + assertNull(CashuTokenDecoder.decode(junk, strict = true)) + assertNull(CashuTokenDecoder.decode(trailing, strict = true)) + assertNotNull(CashuTokenDecoder.decode(junk)) + } + + @Test + fun `strict decoding rejects missing nonpositive fractional and overflowing amounts`() { + val values = listOf( + """{"token":[{"proofs":[]}]}""", + """{"token":[{"proofs":[{"amount":0}]}]}""", + """{"token":[{"proofs":[{"amount":1.5}]}]}""", + """{"token":[{"proofs":[{"amount":2100000000000000},{"amount":1}]}]}""" + ) + + values.forEach { assertNull(CashuTokenDecoder.decode(v3Token(it), strict = true)) } + } + + @Test + fun `extracts URI forms as bare deduplicated tokens with a cap`() { + val first = v3Token("""{"token":[{"proofs":[{"amount":1}]}]}""") + val second = v3Token("""{"token":[{"proofs":[{"amount":2}]}]}""") + val text = "cashu:$first and cashu://$first then $second" + + assertEquals(listOf(first, second), CashuTokenDecoder.extractTokens(text, max = 2)) + assertEquals(first, CashuTokenDecoder.bareToken("cashu%3A$first")) + } + + @Test + fun `sentence punctuation is not included in extracted token`() { + val token = v3Token("""{"token":[{"proofs":[{"amount":1}]}]}""") + + assertEquals(listOf(token), CashuTokenDecoder.extractTokens("Redeem $token.")) + assertNull(CashuTokenDecoder.bareToken("$token.")) + } + + @Test + fun `oversized and deeply nested input fails closed`() { + assertNull(CashuTokenDecoder.decode("cashuA" + "A".repeat(CashuTokenDecoder.MAX_TOKEN_LENGTH))) + var nested = byteArrayOf(0) + repeat(20) { nested = byteArrayOf(0x81.toByte()) + nested } + assertNull(CashuTokenDecoder.decode("cashuB" + base64Url(nested), strict = true)) + } + + private fun v3Token(json: String): String = + "cashuA" + base64Url(json.toByteArray(StandardCharsets.UTF_8)) + + private fun validV4Token(): String { + val proofs = listOf(1L, 4L, 16L).map { amount -> + cborMap( + "a" to cborUnsigned(amount), + "s" to cborText("secret"), + "c" to cborBytes(byteArrayOf(2, 0xab.toByte(), 0xcd.toByte())) + ) + } + val payload = cborMap( + "m" to cborText("https://mint.example.com"), + "u" to cborText("sat"), + "t" to cborArray( + cborMap( + "i" to cborBytes(byteArrayOf(0, 0xad.toByte(), 0x26, 0x8c.toByte())), + "p" to cborArray(*proofs.toTypedArray()) + ) + ) + ) + return "cashuB" + base64Url(payload) + } + + private fun cborUnsigned(value: Long) = cborHead(0, value) + private fun cborText(value: String) = + cborHead(3, value.toByteArray().size.toLong()) + value.toByteArray() + private fun cborBytes(value: ByteArray) = cborHead(2, value.size.toLong()) + value + private fun cborArray(vararg values: ByteArray) = + cborHead(4, values.size.toLong()) + values.fold(byteArrayOf(), ByteArray::plus) + private fun cborMap(vararg pairs: Pair) = + cborHead(5, pairs.size.toLong()) + pairs.fold(byteArrayOf()) { bytes, pair -> + bytes + cborText(pair.first) + pair.second + } + + private fun cborHead(major: Int, value: Long): ByteArray = when (value) { + in 0..23 -> byteArrayOf(((major shl 5) or value.toInt()).toByte()) + in 24..255 -> byteArrayOf(((major shl 5) or 24).toByte(), value.toByte()) + else -> byteArrayOf( + ((major shl 5) or 25).toByte(), + (value ushr 8).toByte(), + value.toByte() + ) + } + + private fun decodePayload(token: String): ByteArray { + val encoded = token.substring(6) + return Base64.getUrlDecoder().decode(encoded + "=".repeat((4 - encoded.length % 4) % 4)) + } + + private fun base64Url(bytes: ByteArray): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +} diff --git a/app/src/test/java/com/bitchat/android/ui/ChatHeaderCrowdingTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatHeaderCrowdingTest.kt new file mode 100644 index 00000000..ce079ba7 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChatHeaderCrowdingTest.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatHeaderCrowdingTest { + + @Test + fun `full header is used at four hundred dp and above`() { + assertEquals(HeaderCrowdingMode.Full, headerCrowdingMode(400.dp)) + assertEquals(HeaderCrowdingMode.Full, headerCrowdingMode(500.dp)) + } + + @Test + fun `joined channel count yields before the location label`() { + assertEquals( + HeaderCrowdingMode.HideJoinedChannelCount, + headerCrowdingMode(399.dp) + ) + assertEquals( + HeaderCrowdingMode.HideJoinedChannelCount, + headerCrowdingMode(360.dp) + ) + } + + @Test + fun `location action becomes icon only on narrow headers`() { + assertEquals( + HeaderCrowdingMode.IconOnlyLocationChannel, + headerCrowdingMode(359.dp) + ) + assertEquals( + HeaderCrowdingMode.IconOnlyLocationChannel, + headerCrowdingMode(320.dp) + ) + } + + @Test + fun `icon-only location action includes the selected channel in its description`() { + val action = "Open location and channel settings" + + assertEquals( + action, + locationChannelContentDescription( + actionDescription = action, + channelLabel = "mesh", + showLabel = true + ) + ) + assertEquals( + "mesh. $action", + locationChannelContentDescription( + actionDescription = action, + channelLabel = "mesh", + showLabel = false + ) + ) + assertEquals( + "#u33d. $action", + locationChannelContentDescription( + actionDescription = action, + channelLabel = "#u33d", + showLabel = false + ) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt new file mode 100644 index 00000000..44377856 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -0,0 +1,565 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.DarkBitchatColorScheme +import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatColorScheme +import com.bitchat.android.ui.theme.LightBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.PeerColorStyle +import com.bitchat.android.ui.theme.colorForPeer +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Runs under Robolectric because URL detection in message bodies goes through + * `android.util.Patterns.WEB_URL`, which is null on a bare JVM. + */ +@RunWith(RobolectricTestRunner::class) +class ChatUIUtilsTest { + private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + } + + private val palette = DarkBitchatPalette + private val colorScheme = DarkBitchatColorScheme + + private fun message( + content: String, + sender: String = "alice", + powDifficulty: Int? = null, + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(0), + powDifficulty = powDifficulty, + ) + + // MARK: - Timestamp metadata + + @Test + fun `text message metadata separates PoW badge with one space`() { + assertEquals( + "00:00 ⛨12b", + formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text, + ) + } + + @Test + fun `text message metadata omits non-positive PoW difficulty`() { + assertEquals( + "00:00", + formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text, + ) + } + + // MARK: - Body with inline trailing timestamp + + @Test + fun `body appends timestamp inline after the message text`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("hello there 00:00", body.text) + val timestamp = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00" + }.item + assertEquals(10.sp, timestamp.fontSize) + assertEquals(FontWeight.Normal, timestamp.fontWeight) + assertEquals(palette.textTertiary, timestamp.color) + } + + @Test + fun `body appends PoW badge after the inline timestamp`() { + val body = formatTextMessageBody( + message = message("mined", powDifficulty = 8), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("mined 00:00 ⛨8b", body.text) + val timestampAndPow = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00 ⛨8b" + }.item + assertEquals(10.sp, timestampAndPow.fontSize) + assertEquals(palette.textTertiary, timestampAndPow.color) + } + + @Test + fun `body can omit the timestamp for callers that render it separately`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertEquals("hello there", body.text) + } + + @Test + fun `body renders plain text in the neutral palette color, not terminal green`() { + val body = formatTextMessageBody( + message = message("plain words"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val textStyle = body.spanStyles.first { it.start == 0 } + assertEquals(colorScheme.onSurface, textStyle.item.color) + } + + @Test + fun `chat text styles match the exported type scale`() { + assertEquals(14.sp, MessageBodyTextStyle.fontSize) + assertEquals(20.sp, MessageBodyTextStyle.lineHeight) + assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight) + assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily) + assertEquals(14.sp, MessageSenderTextStyle.fontSize) + assertEquals(16.sp, MessageSenderTextStyle.lineHeight) + assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight) + } + + @Test + fun `body does not bold plain text for the sender's own messages`() { + // Regression guard: the old renderer bolded the entire body when the message was yours, + // and again when you were mentioned, which is what made busy channels unreadable. + val body = formatTextMessageBody( + message = message("my own words", sender = "bob"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue( + "no span should force a bold weight", + body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true } + ) + } + + // MARK: - Mention chips + + private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) = + body.spanStyles.filter { it.item.background.isSpecified() } + + private fun androidx.compose.ui.graphics.Color.isSpecified() = + this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f + + @Test + fun `mention renders as a single contiguous background chip`() { + val body = formatTextMessageBody( + message = message("hey @carol#04af what's up"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chips = mentionChipSpans(body) + assertEquals("expected exactly one chip", 1, chips.size) + + // The chip must cover "@carol#04af" as one run so it paints without seams. + val chip = chips.single() + assertEquals("@carol#04af", body.text.substring(chip.start, chip.end)) + } + + @Test + fun `mention targeting the current user uses the orange accent`() { + val body = formatTextMessageBody( + message = message("ping @bob now"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val chip = mentionChipSpans(body).single() + assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background) + assertEquals(0.2f, chip.item.background.alpha) + + val nameStyle: SpanStyle = body.spanStyles + .first { it.start == chip.start && it.item.color == palette.accentOrange } + .item + assertEquals(palette.accentOrange, nameStyle.color) + } + + @Test + fun `mention of another user is tinted by that user's own peer color`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val mentionPeerIdentities = buildMentionPeerIdentityMap( + messages = listOf( + BitchatMessage( + sender = "carol#04af", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + ) + val body = formatTextMessageBody( + message = message("cc @carol#04af"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val expected = colorForPeer(identity, palette) + val chip = mentionChipSpans(body).single() + assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) + assertTrue(body.spanStyles.any { it.item.color == expected }) + } + + @Test + fun `composer colors nickname and hash suffix from the mentioned peer identity`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val token = "@carol#04af" + val input = "ping $token now" + val transformed = MentionVisualTransformation( + mentionPeerIdentities = mapOf("carol#04af" to identity), + palette = palette, + ).filter(AnnotatedString(input)).text + + val expectedColor = colorForPeer(identity, palette) + val tokenStart = input.indexOf(token) + val suffixStart = input.indexOf("#04af") + val tokenEnd = tokenStart + token.length + + assertEquals(input, transformed.text) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == tokenEnd && + it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA) + }) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == suffixStart && + it.item.color == expectedColor + }) + assertTrue(transformed.spanStyles.any { + it.start == suffixStart && + it.end == tokenEnd && + it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA) + }) + } + + @Test + fun `ambiguous base nickname is not assigned to the wrong peer`() { + val firstIdentity = PeerIdentity.nostr("11111111".repeat(8)) + val secondIdentity = PeerIdentity.nostr("22222222".repeat(8)) + val identities = buildMentionPeerIdentityMap( + messages = emptyList(), + knownPeers = listOf( + "alice#1111" to firstIdentity, + "alice#2222" to secondIdentity, + ) + ) + + assertEquals(firstIdentity, identities["alice#1111"]) + assertEquals(secondIdentity, identities["alice#2222"]) + assertFalse(identities.containsKey("alice")) + assertEquals( + firstIdentity, + resolveMentionPeerIdentity("@alice#1111", identities) + ) + assertEquals(null, resolveMentionPeerIdentity("@alice", identities)) + } + + @Test + fun `message without mentions has no background chips`() { + val body = formatTextMessageBody( + message = message("no mentions in here"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + assertTrue(mentionChipSpans(body).isEmpty()) + } + + // MARK: - System / action messages + + @Test + fun `system message uses a double-slash prefix and no brackets`() { + val text = formatSystemMessage( + message = message("Tor started. Routing all chats via Tor", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ).text + + assertEquals("// Tor started. Routing all chats via Tor 00:00", text) + } + + @Test + fun `system message is not italic`() { + // The old treatment was `* italic asterisks *`, which competed visually with real + // messages despite being lower-priority narration. + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ) + + assertTrue(annotated.spanStyles.all { it.item.fontStyle == null }) + } + + @Test + fun `system action and timestamp use their exported weights sizes and opacity`() { + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ) + + val action = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == "// tor restarting" + }.item + val time = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == " 00:00" + }.item + + assertEquals(12.sp, action.fontSize) + assertEquals(FontWeight.Medium, action.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color) + assertEquals(10.sp, time.fontSize) + assertEquals(FontWeight.Normal, time.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color) + } + + // MARK: - Sender label + + @Test + fun `sender label drops angle brackets and dims the hash suffix`() { + val sender = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + + assertEquals("@carol #04af", sender.text) + + val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" } + val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" } + assertNotNull(suffixSpan.item.color) + assertEquals(14.sp, nameSpan.item.fontSize) + assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight) + assertEquals(14.sp, suffixSpan.item.fontSize) + assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight) + assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha) + assertTrue( + "suffix must be dimmer than the name", + suffixSpan.item.color.alpha < nameSpan.item.color.alpha + ) + } + + @Test + fun `sender label annotates the nickname for others but not for yourself`() { + val other = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) + + val mine = formatTextMessageSender( + message = message("hi", sender = "bob"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) + } + + // MARK: - Peer colors + + @Test + fun `peer identity factories normalize stable IDs without resolving UI colors`() { + assertEquals( + PeerIdentity.mesh("abcdef"), + PeerIdentity.mesh("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("nostr:nostr_ABCDEF") + ) + assertEquals( + "nostr:nostr:abcdef01", + PeerIdentity.nostr("ABCDEF0123456789").stableKey + ) + assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey) + } + + @Test + fun `peer color hue is stable across light and dark, only chroma differs`() { + // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per + // theme so dark mode stays muted-but-bright and light mode stays deep-but-readable. + val identity = PeerIdentity.mesh("abc") + val dark = colorForPeer(identity, DarkBitchatPalette) + val light = colorForPeer(identity, LightBitchatPalette) + + val darkHsv = FloatArray(3) + val lightHsv = FloatArray(3) + rgbToHsv(dark.red, dark.green, dark.blue, darkHsv) + rgbToHsv(light.red, light.green, light.blue, lightHsv) + + assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) + assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01) + assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01) + assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01) + assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01) + // Dark theme: muted chroma, never dark (readable on near-black). + assertTrue(darkHsv[1] < 0.75f) + assertTrue(darkHsv[2] >= 0.75f) + // Light theme: avoid neon / near-white peer labels. + assertTrue(lightHsv[1] < 0.85f) + assertTrue(lightHsv[2] <= 0.55f) + } + + @Test + fun `peer color avoids the orange hue reserved for self`() { + // Sweep a range of seeds; none may land within the reserved orange band. + repeat(500) { i -> + val color = colorForPeer( + PeerIdentity.mesh("seed$i"), + DarkBitchatPalette + ) + val hsv = FloatArray(3) + rgbToHsv(color.red, color.green, color.blue, hsv) + val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) + assertTrue( + "seed$i resolved to ${hsv[0]}°, inside the reserved orange band", + distanceFromOrange >= 17f || hsv[1] < 0.01f + ) + } + } + + @Test + fun `material owns standard text while Bitchat palette owns peer chroma`() { + assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface) + assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface) + assertTrue( + LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors + ) + } + + @Test + fun `geohash chat and people sheet resolve the same full Nostr identity`() { + val pubkey = "ABCDEF0123456789".repeat(4) + val peopleIdentity = PeerIdentity.nostr(pubkey) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + assertEquals( + colorForPeer(peopleIdentity, palette), + colorForPeer(chatIdentity, palette) + ) + } + + @Test + fun `mesh chat and people sheet resolve the same peer identity`() { + val peerID = "ABCDEF0123456789" + val peopleIdentity = PeerIdentity.mesh(peerID) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = peerID, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + } + + @Test + fun `full Nostr identity wins over a truncated routing alias`() { + val pubkey = "0123456789ABCDEF".repeat(4) + val identity = peerIdentityForMessage( + BitchatMessage( + sender = "alice", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr_${pubkey.take(16)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(PeerIdentity.nostr(pubkey), identity) + } + + private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + out[0] = when { + delta == 0f -> 0f + max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f + max == g -> 60f * (((b - r) / delta) + 2f) + else -> 60f * (((r - g) / delta) + 4f) + } + out[1] = if (max == 0f) 0f else delta / max + out[2] = max + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt new file mode 100644 index 00000000..2e52b750 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -0,0 +1,211 @@ +package com.bitchat.android.ui + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.util.Date + +@RunWith(RobolectricTestRunner::class) +class CommandProcessorTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val chatState = ChatState(scope = testScope) + private lateinit var commandProcessor: CommandProcessor + + val messageManager: MessageManager = MessageManager(state = chatState) + val channelManager: ChannelManager = ChannelManager( + state = chatState, + messageManager = messageManager, + dataManager = DataManager(context = context), + coroutineScope = testScope + ) + + private val meshService: MeshService = mock() + + @Before + fun setup() { + commandProcessor = CommandProcessor( + state = chatState, + messageManager = messageManager, + channelManager = channelManager, + privateChatManager = PrivateChatManager( + state = chatState, + messageManager = messageManager, + dataManager = DataManager(context = context), + noiseSessionDelegate = mock() + ) + ) + } + + @Ignore // Temporarily disabled due to Mockito final class issues + @Test + fun `when using lower case join command, command returns true`() { + val channel = "channel-1" + + val result = commandProcessor.processCommand( + command = "/j $channel", + meshService = meshService, + myPeerID = "peer-id", + onSendMessage = { a, b, c -> { } }, + viewModel = null + ) + + assertEquals(result, true) + } + + @Ignore // Temporarily disabled due to Mockito final class issues + @Test + fun `when using upper case join command, command returns true`() { + val channel = "channel-1" + + val result = commandProcessor.processCommand( + command = "/JOIN $channel", + meshService = meshService, + myPeerID = "peer-id", + onSendMessage = { a, b, c -> { } }, + viewModel = null + ) + + assertEquals(result, true) + } + + @Ignore // Temporarily disabled due to Mockito final class issues + @Test + fun `when unknown command lower case is given, command returns true but does not process special handling`() { + val channel = "channel-1" + + val result = commandProcessor.processCommand( + command = "/wtfjoin $channel", meshService = meshService, myPeerID = "peer-id", + onSendMessage = { a, b, c -> { } }, viewModel = null + ) + + assertEquals(result, true) + } + + @Test + fun `msg command persists incoming messages as locally read through shared chat opening`() { + val peerID = "0102030405060708" + val message = BitchatMessage( + id = "message-opened-by-command", + sender = "alice", + content = "hello", + timestamp = Date(1), + isPrivate = true, + senderPeerID = peerID + ) + val locallyRead = mutableListOf() + chatState.setPrivateChats(mapOf(peerID to listOf(message))) + whenever(meshService.getPeerNicknames()).thenReturn(mapOf(peerID to "alice")) + + commandProcessor = CommandProcessor( + state = chatState, + messageManager = messageManager, + channelManager = channelManager, + privateChatManager = PrivateChatManager( + state = chatState, + messageManager = messageManager, + dataManager = DataManager(context = context), + noiseSessionDelegate = mock(), + markMessageReadLocally = locallyRead::add + ) + ) + + commandProcessor.processCommand( + command = "/msg alice", + meshService = meshService, + myPeerID = "self", + onSendMessage = { _, _, _ -> }, + viewModel = null + ) + + assertTrue(locallyRead.contains(message.id)) + } + + @Test + fun `pay feedback is added to active geohash channel`() { + val geohash = "u0nd" + chatState.setSelectedLocationChannel( + ChannelID.Location(GeohashChannel(GeohashChannelLevel.PROVINCE, geohash)) + ) + + commandProcessor.processCommand( + command = "/pay invalid", + meshService = meshService, + myPeerID = "peer-id", + onSendMessage = { _, _, _ -> }, + viewModel = null + ) + + assertEquals( + "invalid cashu token — not sending it", + chatState.getChannelMessagesValue()["geo:$geohash"]?.single()?.content + ) + assertEquals(0, chatState.getMessagesValue().size) + } + + @Test + fun `join command leaves active geohash selection for mesh channel routing`() { + chatState.setSelectedLocationChannel( + ChannelID.Location(GeohashChannel(GeohashChannelLevel.REGION, "9q")) + ) + + commandProcessor.processCommand( + command = "/join backchannel", + meshService = meshService, + myPeerID = "peer-id", + onSendMessage = { _, _, _ -> }, + viewModel = null + ) + + assertEquals("#backchannel", chatState.getCurrentChannelValue()) + assertEquals(ChannelID.Mesh, chatState.selectedLocationChannel.value) + } + + @Test + fun `clearSuggestions hides the command suggestion popup`() { + // Typing "/" opens the command popup. + commandProcessor.updateCommandSuggestions("/") + assertTrue(chatState.getShowCommandSuggestionsValue()) + + // The send handlers call this after clearing the field in code, which does + // not run the text-change handler that normally hides the popup. + commandProcessor.clearSuggestions() + assertFalse(chatState.getShowCommandSuggestionsValue()) + assertTrue(chatState.getCommandSuggestionsValue().isEmpty()) + } + + @Test + fun `clearSuggestions hides the mention suggestion popup`() { + whenever(meshService.getPeerNicknames()).thenReturn(mapOf("peer-1" to "alice")) + + // Typing "@a" opens the mention popup. + commandProcessor.updateMentionSuggestions("@a", meshService, viewModel = null) + assertTrue(chatState.getShowMentionSuggestionsValue()) + + commandProcessor.clearSuggestions() + assertFalse(chatState.getShowMentionSuggestionsValue()) + assertTrue(chatState.getMentionSuggestionsValue().isEmpty()) + + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/ConversationSecurityStateTest.kt b/app/src/test/java/com/bitchat/android/ui/ConversationSecurityStateTest.kt new file mode 100644 index 00000000..c7de0885 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ConversationSecurityStateTest.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationSecurityStateTest { + @Test + fun `active mesh peer session wins for canonical conversation`() { + val result = resolveConversationSessionState( + conversationID = "contact-alice", + activeMeshPeerID = "mesh-alice", + peerSessionStates = mapOf( + "contact-alice" to "uninitialized", + "mesh-alice" to "established" + ) + ) + + assertEquals("established", result) + } + + @Test + fun `conversation session is fallback when live identity has no state`() { + val result = resolveConversationSessionState( + conversationID = "contact-alice", + activeMeshPeerID = "mesh-alice", + peerSessionStates = mapOf("contact-alice" to "handshaking") + ) + + assertEquals("handshaking", result) + } + + @Test + fun `conversation session resolves when mesh and conversation IDs are identical`() { + val result = resolveConversationSessionState( + conversationID = "mesh-alice", + activeMeshPeerID = "mesh-alice", + peerSessionStates = mapOf("mesh-alice" to "established") + ) + + assertEquals("established", result) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt new file mode 100644 index 00000000..869ae924 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt @@ -0,0 +1,130 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GeohashPresenceGroupingTest { + + private fun person(id: String, name: String = id, lastSeen: Long = 0) = GeoPerson( + id = id, + displayName = name, + lastSeen = Date(lastSeen) + ) + + @Test + fun `heartbeat anon is hidden while announced anon name remains`() { + val people = listOf( + person("local-heartbeat", "anon"), + person("named", "alice"), + person("teleported-named", "anon7674"), + person("teleported-heartbeat", "anon#04af") + ) + + val sections = sectionGeohashPeople( + people = people, + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("teleported-named", "teleported-heartbeat") + ) + + assertEquals(listOf("alice"), sections.onLocation.map { it.displayName }) + assertEquals(listOf("anon7674"), sections.teleportedIn.map { it.displayName }) + assertEquals(2, sections.onLocation.size + sections.teleportedIn.size) + } + + @Test + fun `self is first in on-location section when not teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("recent", lastSeen = 3_000), + person("me", name = "anon", lastSeen = 0), + person("older", lastSeen = 1_000) + ), + myId = "me", + selfIsTeleported = false, + teleportedIds = emptySet() + ) + + assertEquals(listOf("me", "recent", "older"), sections.onLocation.map { it.id }) + assertTrue(sections.teleportedIn.isEmpty()) + } + + @Test + fun `self is first in teleported section when teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("remote-teleport", lastSeen = 3_000), + person("me", lastSeen = 0), + person("local", lastSeen = 2_000) + ), + myId = "ME", + selfIsTeleported = true, + teleportedIds = setOf("remote-teleport") + ) + + assertEquals(listOf("local"), sections.onLocation.map { it.id }) + assertEquals(listOf("me", "remote-teleport"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `remote teleport matching is case insensitive`() { + val sections = sectionGeohashPeople( + people = listOf(person("ABCDEF")), + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("abcdef") + ) + + assertTrue(sections.onLocation.isEmpty()) + assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `duplicate nicknames are detected across case and sections`() { + val duplicates = duplicateGeohashBaseNames( + listOf( + person("first", "Alice"), + person("second", "alice"), + person("third", "bob") + ) + ) + + assertEquals(setOf("alice"), duplicates) + } + + @Test + fun `duplicate nickname gets the same last-four ID suffix as chat`() { + assertEquals( + "#cdef", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = true) + ) + assertEquals( + "", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = false) + ) + } + + @Test + fun `existing chat-style suffix is preserved`() { + assertEquals( + "#04af", + geohashIdentitySuffix(person("0123456789abcdef", "alice#04af"), showHashSuffix = true) + ) + } + + @Test + fun `disambiguated display name matches the mention token used by chat`() { + val alice = person("0123456789abcdef", "alice") + + assertEquals( + "alice#cdef", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = setOf("alice")) + ) + assertEquals( + "alice", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = emptySet()) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt new file mode 100644 index 00000000..b5d556e3 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LocationChannelsSheetTest { + + @Test + fun `teleported channel gets a standalone row`() { + val nearby = listOf(channel("u33dc")) + val teleported = channel("dr5ru") + + assertEquals( + teleported, + selectedLocationChannelOutsideNearby(ChannelID.Location(teleported), nearby) + ) + } + + @Test + fun `nearby selected channel is not duplicated`() { + val nearby = channel("u33dc") + + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Location(channel("U33DC")), + listOf(nearby) + ) + ) + } + + @Test + fun `mesh selection has no standalone location row`() { + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Mesh, + listOf(channel("u33dc")) + ) + ) + } + + @Test + fun `globe result resolves to a manual teleport channel`() { + assertEquals( + GeohashChannel( + level = GeohashChannelLevel.CITY, + geohash = "u33dc" + ), + channelForManualGeohash(" #U33DC ") + ) + } + + @Test + fun `invalid globe result cannot create a teleport channel`() { + assertNull(channelForManualGeohash("not-a-geohash")) + } + + private fun channel(geohash: String) = GeohashChannel( + level = GeohashChannelLevel.CITY, + geohash = geohash + ) +} diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt new file mode 100644 index 00000000..607aaf1e --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MentionSuggestionsTest { + + @Test + fun `only users without an announced nickname are excluded from mentions`() { + val suggestions = filterMentionCandidates( + candidates = listOf( + "anon", + "anon#04af", + "anon7674#df5b", + "alice#1234", + "anonymous", + "anonracer#04af" + ), + query = "" + ) + + assertEquals( + listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), + suggestions + ) + assertTrue(suggestions.none(::isUnannouncedNickname)) + } + + @Test + fun `mention filtering is case insensitive and removes duplicates`() { + val suggestions = filterMentionCandidates( + candidates = listOf("Bob#1234", "bob#1234", "bobby#5678", "alice#9999"), + query = "BO" + ) + + assertEquals(listOf("Bob#1234", "bobby#5678"), suggestions) + } + + @Test + fun `announced names beginning with anon stay mentionable`() { + assertTrue(isUnannouncedNickname("anon")) + assertTrue(isUnannouncedNickname("anon#04af")) + assertFalse(isUnannouncedNickname("anon1234#04af")) + assertFalse(isUnannouncedNickname("anonymous#04af")) + assertFalse(isUnannouncedNickname("anonracer")) + } + + @Test + fun `mention popup viewport is capped at five rows`() { + assertEquals(5, MaxVisibleMentionSuggestions) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt new file mode 100644 index 00000000..fb534e69 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -0,0 +1,186 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The arrival tracker decides which messages animate in. + * + * Getting it wrong is not subtle: animate too eagerly and every old message replays its entrance + * whenever `LazyColumn` recycles it back into view, which looks broken during a scroll. + */ +class MessageArrivalTrackerTest { + + private var seq = 0 + + private fun msg(id: String = "m${seq++}") = BitchatMessage( + id = id, + sender = "alice", + content = "hello", + timestamp = Date(0), + ) + + @Test + fun `first load adopts everything silently`() { + val tracker = MessageArrivalTracker() + val initial = listOf(msg("a"), msg("b"), msg("c")) + + assertTrue( + "a whole screenful animating on open reads as a glitch", + tracker.arrivals(initial).isEmpty() + ) + } + + @Test + fun `a message appended after the first load animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + messages += msg("c") + assertEquals(setOf("c"), tracker.arrivals(messages)) + } + + @Test + fun `an empty first load still lets the first real message animate`() { + // A brand new conversation seeds with nothing, so its opening message is a genuine arrival. + val tracker = MessageArrivalTracker() + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `re-presenting the same list animates nothing`() { + val tracker = MessageArrivalTracker() + val messages = listOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `an already-seen message never animates again`() { + // Stands in for LazyColumn recycling an old row back into view mid-scroll. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + messages += msg("b") + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `only the new messages in a batch animate`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + + messages += listOf(msg("b"), msg("c")) + assertEquals(setOf("b", "c"), tracker.arrivals(messages)) + } + + @Test + fun `a burst larger than the cap is adopted silently`() { + // History sync: animating hundreds of rows would spend the whole frame budget on motion + // nobody asked to see. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals + 1) { messages += msg("burst$it") } + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `a burst exactly at the cap still animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals) { messages += msg("b$it") } + assertEquals(MaxAnimatedArrivals, tracker.arrivals(messages).size) + } + + @Test + fun `clearing the conversation prunes the known set`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b"), msg("c")) + tracker.arrivals(messages) + + tracker.arrivals(emptyList()) + assertTrue("stale ids would leak and grow without bound", tracker.known.isEmpty()) + } + + @Test + fun `a message re-added after a clear animates again`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `a wholesale replacement animates nothing`() { + // Switching channels, or /clear followed by fresh content: the incoming list shares no ids + // with the outgoing one, so it is a different conversation rather than a burst of arrivals. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"))) + + val other = listOf(msg("x"), msg("y")) + assertTrue( + "a different conversation must not slide every message in", + tracker.arrivals(other).isEmpty() + ) + } + + @Test + fun `a small replacement below the burst cap still animates nothing`() { + // The burst cap alone would not catch this: two messages is well under it. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"), msg("c"))) + + assertTrue(tracker.arrivals(listOf(msg("x"), msg("y"))).isEmpty()) + } + + @Test + fun `a replacement that overlaps is treated as normal arrivals`() { + // Still the same conversation if anything carries over, so genuine new messages animate. + val tracker = MessageArrivalTracker() + val kept = msg("a") + tracker.arrivals(listOf(kept, msg("b"))) + + assertEquals(setOf("c"), tracker.arrivals(listOf(kept, msg("c")))) + } + + @Test + fun `after a wholesale replacement, later arrivals animate normally`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + + val switched = mutableListOf(msg("x")) + tracker.arrivals(switched) // adopted silently + + switched += msg("y") + assertEquals(setOf("y"), tracker.arrivals(switched)) + } + + @Test + fun `the known set never outgrows the conversation`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + // Simulate a channel switch: entirely different messages, same list size. + val switched = listOf(msg("x"), msg("y")) + tracker.arrivals(switched) + + assertEquals(setOf("x", "y"), tracker.known) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt new file mode 100644 index 00000000..eea760f7 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -0,0 +1,154 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Grouping decides whether a message repeats its author's name. Getting it wrong either spams + * the transcript with redundant labels or, worse, silently attributes one person's message to + * another — hence the emphasis on the negative cases here. + */ +class MessageGroupingTest { + + private val base = 1_700_000_000_000L + + private fun message( + sender: String = "alice", + senderPeerId: String? = "peer-alice", + offsetMs: Long = 0L, + isPrivate: Boolean = false, + channel: String? = null, + content: String = "hello", + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(base + offsetMs), + isPrivate = isPrivate, + senderPeerID = senderPeerId, + channel = channel, + ) + + @Test + fun `first message in a list never groups`() { + assertFalse(MessageGrouping.shouldGroup(previous = null, current = message())) + } + + @Test + fun `consecutive messages from the same peer group`() { + val first = message(offsetMs = 0) + val second = message(offsetMs = 30_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `messages from different peers do not group`() { + val first = message(sender = "alice", senderPeerId = "peer-alice") + val second = message(sender = "bob", senderPeerId = "peer-bob", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `same nickname but different peer id does not group`() { + // Two people can pick the same nickname; peer ID is the authority. + val first = message(sender = "alice", senderPeerId = "peer-one") + val second = message(sender = "alice", senderPeerId = "peer-two", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `falls back to display name when peer ids are unavailable`() { + val first = message(sender = "alice#04af", senderPeerId = null) + val second = message(sender = "alice#04af", senderPeerId = null, offsetMs = 1_000) + val other = message(sender = "bob#1122", senderPeerId = null, offsetMs = 2_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + assertFalse(MessageGrouping.shouldGroup(second, other)) + } + + @Test + fun `messages outside the grouping window start a new group`() { + val first = message(offsetMs = 0) + val justInside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS) + val justOutside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS + 1) + + assertTrue(MessageGrouping.shouldGroup(first, justInside)) + assertFalse(MessageGrouping.shouldGroup(first, justOutside)) + } + + @Test + fun `out of order timestamps do not group`() { + // Mesh delivery can surface messages out of order; a negative delta means we cannot + // reason about adjacency, so fall back to showing the sender. + val later = message(offsetMs = 60_000) + val earlier = message(offsetMs = 0) + + assertFalse(MessageGrouping.shouldGroup(later, earlier)) + } + + @Test + fun `system messages never group in either direction`() { + val system = message(sender = "system", senderPeerId = null, content = "tor started") + val user = message(offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(system, user)) + assertFalse(MessageGrouping.shouldGroup(user, system.copyWithOffset(2_000))) + assertFalse(MessageGrouping.shouldGroup(system, system.copyWithOffset(1_000))) + } + + @Test + fun `grouping does not cross the public private boundary`() { + val public = message(isPrivate = false) + val private = message(isPrivate = true, offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(public, private)) + } + + @Test + fun `grouping does not cross channels`() { + val inGeneral = message(channel = "#general") + val inRandom = message(channel = "#random", offsetMs = 1_000) + val inNoChannel = message(channel = null, offsetMs = 2_000) + + assertFalse(MessageGrouping.shouldGroup(inGeneral, inRandom)) + assertFalse(MessageGrouping.shouldGroup(inGeneral, inNoChannel)) + } + + @Test + fun `peer id comparison ignores case`() { + val first = message(senderPeerId = "PEER-Alice") + val second = message(senderPeerId = "peer-alice", offsetMs = 1_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + // MARK: - Spacing + + @Test + fun `spacing is zero for the very first row`() { + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = true)) + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = true)) + } + + @Test + fun `all transcript rows use the exported eight dp rhythm`() { + val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false) + val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false) + + assertEquals(8.dp, grouped) + assertEquals(8.dp, newGroup) + assertEquals(grouped, newGroup) + assertEquals(8.dp, MessageGrouping.SENDER_TOP_PADDING) + assertEquals(4.dp, MessageGrouping.SENDER_TO_BODY_SPACING) + } + + private fun BitchatMessage.copyWithOffset(offsetMs: Long) = + copy(timestamp = Date(base + offsetMs)) +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt new file mode 100644 index 00000000..90e279e1 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt @@ -0,0 +1,63 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MessageInteractionUtilsTest { + @Test + fun `self detection accepts peer id nickname and nickname suffix`() { + assertTrue(message(sender = "alice", senderPeerId = "peer-a").isFromSelf("me", "peer-a")) + assertTrue(message(sender = "me").isFromSelf("me", "peer-a")) + assertTrue(message(sender = "me#1a2b").isFromSelf("me", "peer-a")) + } + + @Test + fun `self detection rejects unrelated sender`() { + assertFalse(message(sender = "alice", senderPeerId = "peer-b").isFromSelf("me", "peer-a")) + } + + @Test + fun `URL normalization preserves explicit HTTP schemes`() { + assertEquals("http://example.com", normalizeMessageUrl("http://example.com")) + assertEquals("HTTPS://example.com", normalizeMessageUrl("HTTPS://example.com")) + } + + @Test + fun `URL normalization defaults bare URLs to HTTPS`() { + assertEquals("https://example.com", normalizeMessageUrl("example.com")) + } + + @Test + fun `geohash channel precision matches navigation levels`() { + val expectedLevels = mapOf( + "9q" to GeohashChannelLevel.REGION, + "9q8" to GeohashChannelLevel.PROVINCE, + "9q8y" to GeohashChannelLevel.PROVINCE, + "9q8yy" to GeohashChannelLevel.CITY, + "9q8yyk" to GeohashChannelLevel.NEIGHBORHOOD, + "9q8yyk8" to GeohashChannelLevel.BLOCK, + ) + + expectedLevels.forEach { (geohash, level) -> + assertEquals(level, channelForGeohash(geohash).level) + } + } + + @Test + fun `geohash channel normalizes casing`() { + assertEquals("9q8yy", channelForGeohash("9Q8YY").geohash) + } + + private fun message(sender: String, senderPeerId: String? = null): BitchatMessage = + BitchatMessage( + sender = sender, + content = "hello", + timestamp = Date(0), + senderPeerID = senderPeerId, + ) +} diff --git a/app/src/test/kotlin/android/util/Base64.kt b/app/src/test/kotlin/android/util/Base64.kt new file mode 100644 index 00000000..a997497a --- /dev/null +++ b/app/src/test/kotlin/android/util/Base64.kt @@ -0,0 +1,23 @@ +@file:JvmName("Base64") + +package android.util + +const val DEFAULT: Int = 0 +const val NO_WRAP: Int = 2 + +fun encodeToString(input: ByteArray, flags: Int): String { + val encoder = if (flags and NO_WRAP != 0) { + java.util.Base64.getEncoder() + } else { + java.util.Base64.getMimeEncoder() + } + return encoder.encodeToString(input) +} + +fun decode(input: String, flags: Int): ByteArray { + return if (flags and NO_WRAP != 0) { + java.util.Base64.getDecoder().decode(input) + } else { + java.util.Base64.getMimeDecoder().decode(input) + } +} diff --git a/app/src/test/kotlin/android/util/Log.kt b/app/src/test/kotlin/android/util/Log.kt index 703cbcb8..1d0bd3f1 100644 --- a/app/src/test/kotlin/android/util/Log.kt +++ b/app/src/test/kotlin/android/util/Log.kt @@ -12,6 +12,11 @@ fun e(tag: String, msg: String): Int { return 0; } +fun e(tag: String, msg: String, throwable: Throwable): Int { + println("ERROR: $tag: $msg (${throwable.message})") + return 0; +} + fun w(tag: String, msg: String): Int { println("WARN: $tag: $msg") return 0; @@ -25,4 +30,4 @@ fun v(tag: String, msg: String): Int { fun i(tag: String, msg: String): Int { println("INFO: $tag: $msg") return 0; -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt new file mode 100644 index 00000000..2d1ec24a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt @@ -0,0 +1,376 @@ +package com.bitchat + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.ConscryptMode +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Date + +@RunWith(RobolectricTestRunner::class) +@ConscryptMode(ConscryptMode.Mode.OFF) // Disable Conscrypt to avoid native library loading issues +class FileTransferTest { + + @Test + fun `encode and decode file packet with all fields should preserve data`() { + // Given: Complete file packet + val contentArray = ByteArray(1024) { (it % 256).toByte() } + val originalPacket = BitchatFilePacket( + fileName = "test.png", + mimeType = "image/png", + fileSize = 1024000, + content = contentArray + ) + + // When: Encode and decode + val encoded = originalPacket.encode() + val decoded = BitchatFilePacket.decode(encoded!!) + + // Then: Data should be preserved + assertNotNull(decoded) + assertEquals(originalPacket.fileName, decoded!!.fileName) + assertEquals(originalPacket.mimeType, decoded.mimeType) + assertEquals(originalPacket.fileSize, decoded.fileSize) + assertEquals(originalPacket.content.size, decoded.content.size) + for (i in 0 until originalPacket.content.size) { + assertEquals(originalPacket.content[i], decoded.content[i]) + } + } + + @Test + fun `encode file packet with filename should include filename TLV`() { + // Given: Packet with filename + val packet = BitchatFilePacket( + fileName = "myimage.jpg", + mimeType = "image/jpeg", + fileSize = 2048, + content = ByteArray(256) { 0xFF.toByte() } + ) + + // When: Encode + val encoded = packet.encode() + assertNotNull(encoded) + + // Then: Should contain filename TLV + // FILE_NAME type (0x01) + length (11) + "myimage.jpg" (UTF-8 with null terminator might add 1 byte) + val expectedType = 0x01.toByte() + val expectedFilename = "myimage.jpg".toByteArray(Charsets.UTF_8) + val expectedLength = expectedFilename.size // Should be 10 for UTF-8 "myimage.jpg" + + + + assertEquals(expectedType, encoded!![0]) + // Calculate the actual length from little-endian encoded data + val actualLength = (encoded[2].toInt() and 0xFF) or ((encoded[1].toInt() and 0xFF) shl 8) + // The encoding seems to be including a null terminator or extended bytes + assertEquals(11, actualLength) // The encoding produces 11 bytes for "myimage.jpg" + + val actualFilename = encoded!!.sliceArray(3 until 3 + expectedLength) + for (i in expectedFilename.indices) { + assertEquals(expectedFilename[i], actualFilename[i]) + } + } + + @Test + fun `encode file size should use big endian byte order for file size`() { + // Given: File with specific size + val fileSize = 0x12345678L + val packet = BitchatFilePacket( + fileName = "test.bin", + mimeType = "application/octet-stream", + fileSize = fileSize, + content = ByteArray(10) + ) + + // When: Encode + val encoded = packet.encode() + assertNotNull(encoded) + + // Then: File size should be in big endian order + // Find FILE_SIZE TLV (type 0x02) + var offset = 0 + while (offset < encoded!!.size - 1) { + if (encoded!![offset] == 0x02.toByte()) { + // This is FILE_SIZE TLV + offset += 1 // Skip type byte + val length = (encoded!![offset].toInt() and 0xFF) or ((encoded[offset + 1].toInt() and 0xFF) shl 8) + offset += 2 // Skip length bytes + if (length == 4) { // FILE_SIZE always has 4 bytes + val decodedFileSize = ByteBuffer.wrap(encoded!!.sliceArray(offset until offset + 4)) + .order(ByteOrder.BIG_ENDIAN) + .int.toLong() + assertEquals(fileSize, decodedFileSize) + break + } + } + offset += 1 + } + } + + @Test + fun `decode minimal file packet should handle defaults correctly`() { + // Given: Minimal valid packet (the constructor requires non-null values) + val originalPacket = BitchatFilePacket( + fileName = "test", + mimeType = "application/octet-stream", + fileSize = 32, // Matches content size + content = ByteArray(32) { 0xAA.toByte() } + ) + + // When: Encode and decode + val encoded = originalPacket.encode() + val decoded = BitchatFilePacket.decode(encoded!!) + + // Then: Data should be preserved completely + assertNotNull(decoded) + assertEquals(32, decoded!!.content.size) + for (i in 0 until 32) { + assertEquals(0xAA.toByte(), decoded.content[i]) + } + assertEquals("test", decoded.fileName) + assertEquals("application/octet-stream", decoded.mimeType) + assertEquals(32L, decoded.fileSize) + } + + @Test + fun `decode should skip unknown TLV types instead of dropping the whole file`() { + // Given: a packet from a peer that added one TLV this build does not + // know (a message id, tag 0x05), placed before CONTENT the way an + // encoder that appends content last would emit it. + val content = ByteArray(64) { (it % 256).toByte() } + val fileName = "photo.jpg".toByteArray(Charsets.UTF_8) + val mimeType = "image/jpeg".toByteArray(Charsets.UTF_8) + val unknownValue = "some-message-id".toByteArray(Charsets.UTF_8) + + val buf = ByteBuffer.allocate( + (1 + 2 + fileName.size) + (1 + 2 + 4) + (1 + 2 + mimeType.size) + + (1 + 2 + unknownValue.size) + (1 + 4 + content.size) + ).order(ByteOrder.BIG_ENDIAN) + buf.put(0x01.toByte()); buf.putShort(fileName.size.toShort()); buf.put(fileName) + buf.put(0x02.toByte()); buf.putShort(4); buf.putInt(content.size) + buf.put(0x03.toByte()); buf.putShort(mimeType.size.toShort()); buf.put(mimeType) + buf.put(0x05.toByte()); buf.putShort(unknownValue.size.toShort()); buf.put(unknownValue) + buf.put(0x04.toByte()); buf.putInt(content.size); buf.put(content) + + // When: Decoding + val decoded = BitchatFilePacket.decode(buf.array()) + + // Then: the unknown TLV costs nothing — the media still arrives. + // Rejecting it made every such transfer vanish with no error on either + // side, while iOS decoded the very same bytes fine. + assertNotNull(decoded) + assertEquals("photo.jpg", decoded!!.fileName) + assertEquals("image/jpeg", decoded.mimeType) + assertEquals(content.size.toLong(), decoded.fileSize) + assertEquals(content.size, decoded.content.size) + for (i in content.indices) { + assertEquals(content[i], decoded.content[i]) + } + } + + @Test + fun `decode should skip an unknown TLV that trails the content`() { + // Given: the same kind of extension appended AFTER content, which a + // decoder that stops at the first unknown tag also loses. + val content = ByteArray(16) { 0x7F } + val fileName = "note.m4a".toByteArray(Charsets.UTF_8) + val trailing = ByteArray(4) { 0x11 } + + val buf = ByteBuffer.allocate( + (1 + 2 + fileName.size) + (1 + 4 + content.size) + (1 + 2 + trailing.size) + ).order(ByteOrder.BIG_ENDIAN) + buf.put(0x01.toByte()); buf.putShort(fileName.size.toShort()); buf.put(fileName) + buf.put(0x04.toByte()); buf.putInt(content.size); buf.put(content) + buf.put(0x7F.toByte()); buf.putShort(trailing.size.toShort()); buf.put(trailing) + + // When: Decoding + val decoded = BitchatFilePacket.decode(buf.array()) + + // Then: Defaults still apply and the content is intact + assertNotNull(decoded) + assertEquals("note.m4a", decoded!!.fileName) + assertEquals("application/octet-stream", decoded.mimeType) + assertEquals(content.size.toLong(), decoded.fileSize) + assertEquals(content.size, decoded.content.size) + } + + @Test + fun `decode should reject an incomplete TLV header after an unknown extension`() { + // Given: a valid file and unknown extension followed by either only a + // tag or a tag plus one length byte. + val content = ByteArray(8) { 0x2A } + val fileName = "truncated.bin".toByteArray(Charsets.UTF_8) + val buf = ByteBuffer.allocate( + (1 + 2 + fileName.size) + (1 + 4 + content.size) + (1 + 2) + ).order(ByteOrder.BIG_ENDIAN) + buf.put(0x01.toByte()); buf.putShort(fileName.size.toShort()); buf.put(fileName) + buf.put(0x04.toByte()); buf.putInt(content.size); buf.put(content) + buf.put(0x05.toByte()); buf.putShort(0) + val packetWithUnknownExtension = buf.array() + + // When/Then: Android rejects the same incomplete tails that iOS does. + assertNull(BitchatFilePacket.decode(packetWithUnknownExtension + byteArrayOf(0x06))) + assertNull(BitchatFilePacket.decode(packetWithUnknownExtension + byteArrayOf(0x06, 0x00))) + } + + @Test + fun `decode should handle a packet padded with many zero-length unknown TLVs`() { + // Given: the cheapest padding a peer can send — a zero-length unknown + // TLV is 3 bytes, so one packet can carry hundreds of thousands of + // them. Anything the skip path does per TLV (copying the empty value, + // formatting a log line) is scaled by the sender, not by us. + val padCount = 200_000 + val content = ByteArray(8) { 0x5A } + val fileName = "padded.bin".toByteArray(Charsets.UTF_8) + + val buf = ByteBuffer.allocate( + (1 + 2 + fileName.size) + (padCount * 3) + (1 + 4 + content.size) + ).order(ByteOrder.BIG_ENDIAN) + buf.put(0x01.toByte()); buf.putShort(fileName.size.toShort()); buf.put(fileName) + repeat(padCount) { + buf.put(0x05.toByte()); buf.putShort(0) + } + buf.put(0x04.toByte()); buf.putInt(content.size); buf.put(content) + + // When: Decoding + val decoded = BitchatFilePacket.decode(buf.array()) + + // Then: the real fields still come through and the padding is ignored + assertNotNull(decoded) + assertEquals("padded.bin", decoded!!.fileName) + assertEquals(content.size.toLong(), decoded.fileSize) + assertEquals(content.size, decoded.content.size) + } + + @Test + fun `replaceFilePathInContent should correctly format content markers for different file types`() { + // Given: Different file types + val imageMessage = BitchatMessage( + id = "test1", + sender = "alice", + senderPeerID = "12345678", + content = "/data/user/0/com.bitchat.android/files/images/photo.jpg", + type = BitchatMessageType.Image, + timestamp = Date(System.currentTimeMillis()), + isPrivate = false + ) + + val audioMessage = BitchatMessage( + id = "test2", + sender = "bob", + senderPeerID = "87654321", + content = "/data/user/0/com.bitchat.android/files/audio/voice.amr", + type = BitchatMessageType.Audio, + timestamp = Date(System.currentTimeMillis()), + isPrivate = false + ) + + val fileMessage = BitchatMessage( + id = "test3", + sender = "charlie", + senderPeerID = "11223344", + content = "/data/user/0/com.bitchat.android/files/documents/document.pdf", + type = BitchatMessageType.File, + timestamp = Date(System.currentTimeMillis()), + isPrivate = false + ) + + // When: Converting to display format (this would be done in MessageMutable) + var result = imageMessage.content + result = result.replace( + "/data/user/0/com.bitchat.android/files/images/photo.jpg", + "[image] photo.jpg" + ) + + // Then: Should match expected pattern + assertEquals("[image] photo.jpg", result) + + // Similar pattern for audio and file would be used in the actual implementation + } + + @Test + fun `buildPrivateMessagePreview should generate user-friendly notifications for file types`() { + // Note: This test is for the NotificationTextUtils.buildPrivateMessagePreview function + // The actual function is in a separate utility file as part of the refactoring + + // Given: Incoming image message + val imageMessage = BitchatMessage( + id = "test1", + sender = "alice", + senderPeerID = "1234abcd", + content = "📷 sent an image", // This would be the result of the utility function + type = BitchatMessageType.Image, + timestamp = Date(System.currentTimeMillis()), + isPrivate = true + ) + + // When: Building preview (this would call NotificationTextUtils.buildPrivateMessagePreview) + val preview = imageMessage.content // In actual code, this would be generated + + // Then: Should provide user-friendly preview + assertEquals("📷 sent an image", preview) + + // Additional assertions would test different file types + // Audio: "🎤 sent a voice message" + // File with specific extension: "📄 document.pdf" + // Generic file: "📎 sent a file" + } + + @Test + fun `waveform extraction should handle empty audio data gracefully`() { + // This test would verify that empty or very short audio files + // don't cause crashes in waveform extraction + + // Given: Empty audio data + val emptyAudioData = ByteArray(0) + + // When: Attempting to extract waveform + // Note: Actual waveform extraction would be tested in the Waveform class + // This is a unit test placeholder + + // Then: Should not crash and should return reasonable result + // For empty data, waveform might be empty array or default values + assertEquals(0, emptyAudioData.size) + } + + @Test + fun `media picker should handle file size limits correctly`() { + // This test would verify that media file selection + // respects size limits before attempting transfer + + // Given: Large file size (simulated) + val largeFileSize = 100L * 1024 * 1024 // 100MB + val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES + + // When: Checking if file can be transferred + val isAllowed = largeFileSize <= maxAllowedSize + + // Then: Should be rejected + assert(!isAllowed) + } + + @Test + fun `transfer cancellation should cleanup resources properly`() { + // This test would verify that when a file transfer is cancelled, + // all associated resources are cleaned up + + // Given: Active transfer in progress + val transferId = "test_transfer_123" + + // When: Transfer is cancelled + // In the actual implementation, this would call cancellation logic + val cancelled = true // Simulated cancellation + + // Then: Resources should be cleaned up + // This would verify temp files are deleted, progress tracking is cleared, etc. + assert(cancelled) + } +} diff --git a/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt new file mode 100644 index 00000000..83802149 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt @@ -0,0 +1,45 @@ +package com.bitchat + +import com.bitchat.android.mesh.MeshPacketUtils +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class MeshPacketUtilsTest { + @Test + fun hexStringToByteArray_parsesFullId() { + val bytes = MeshPacketUtils.hexStringToByteArray("0011223344556677") + assertEquals(8, bytes.size) + assertEquals(0x00.toByte(), bytes[0]) + assertEquals(0x11.toByte(), bytes[1]) + assertEquals(0x22.toByte(), bytes[2]) + assertEquals(0x33.toByte(), bytes[3]) + assertEquals(0x44.toByte(), bytes[4]) + assertEquals(0x55.toByte(), bytes[5]) + assertEquals(0x66.toByte(), bytes[6]) + assertEquals(0x77.toByte(), bytes[7]) + } + + @Test + fun hexStringToByteArray_parsesShortId() { + val bytes = MeshPacketUtils.hexStringToByteArray("ab") + assertEquals(8, bytes.size) + assertEquals(0xab.toByte(), bytes[0]) + assertEquals(0x00.toByte(), bytes[1]) + } + + @Test + fun hexStringToByteArray_handlesInvalidHex() { + val bytes = MeshPacketUtils.hexStringToByteArray("zz") + assertEquals(8, bytes.size) + assertEquals(0x00.toByte(), bytes[0]) + } + + @Test + fun sha256Hex_matchesKnownValue() { + val hash = MeshPacketUtils.sha256Hex("hello".toByteArray()) + assertEquals( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + hash + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt b/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt deleted file mode 100644 index 9f821fcb..00000000 --- a/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.bitchat - -import android.content.Context -import androidx.core.app.NotificationManagerCompat -import androidx.test.core.app.ApplicationProvider -import com.bitchat.android.ui.NotificationManager -import com.bitchat.android.util.NotificationIntervalManager -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito -import org.mockito.Mockito.times -import org.mockito.MockitoAnnotations -import org.mockito.Spy -import org.mockito.kotlin.any -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class NotificationManagerTest { - - private val context: Context = ApplicationProvider.getApplicationContext() - private val notificationIntervalManager = NotificationIntervalManager() - lateinit var notificationManager: NotificationManager - - @Spy - val notificationManagerCompat: NotificationManagerCompat = - Mockito.spy(NotificationManagerCompat.from(context)) - - @Before - fun setup() { - MockitoAnnotations.openMocks(this) - notificationManager = NotificationManager( - context, - notificationManagerCompat, - notificationIntervalManager - ) - } - - @Test - fun `when there are no active peers, do not send active peer notification`() { - notificationManager.setAppBackgroundState(true) - notificationManager.showActiveUserNotification(emptyList()) - verify(notificationManagerCompat, never()).notify(any(), any()) - } - - @Test - fun `when app is in foreground, do not send active peer notification`() { - notificationManager.setAppBackgroundState(false) - notificationManager.showActiveUserNotification(listOf("peer-1")) - verify(notificationManagerCompat, never()).notify(any(), any()) - } - - @Test - fun `when there is an active peer, send notification`() { - notificationManager.setAppBackgroundState(true) - notificationManager.showActiveUserNotification(listOf("peer-1")) - verify(notificationManagerCompat, times(1)).notify(any(), any()) - } - - @Test - fun `when there is an active peer but less than 5 minutes have passed since last notification, do not send notification`() { - notificationManager.setAppBackgroundState(true) - notificationManager.showActiveUserNotification(listOf("peer-1")) - notificationManager.showActiveUserNotification(listOf("peer-2")) - verify(notificationManagerCompat, times(1)).notify(any(), any()) - } - - @Test - fun `when there is an active peer and more than 5 minutes have passed since last notification, send notification`() { - notificationManager.setAppBackgroundState(true) - notificationManager.showActiveUserNotification(listOf("peer-1")) - notificationIntervalManager.setLastNetworkNotificationTime(System.currentTimeMillis() - 301_000L) - notificationManager.showActiveUserNotification(listOf("peer-2")) - verify(notificationManagerCompat, times(2)).notify(any(), any()) - } - - @Test - fun `when there is a recently seen peer but no new active peers, no notification is sent`() { - notificationManager.setAppBackgroundState(true) - notificationIntervalManager.recentlySeenPeers.add("peer-1") - notificationManager.showActiveUserNotification(emptyList()) - verify(notificationManagerCompat, times(0)).notify(any(), any()) - } - - @Test - fun `when an active peer is a recently seen peer, do not send notification`() { - notificationManager.setAppBackgroundState(true) - notificationIntervalManager.recentlySeenPeers.add("peer-1") - notificationManager.showActiveUserNotification(listOf("peer-1")) - verify(notificationManagerCompat, times(0)).notify(any(), any()) - } - - @Test - fun `when an active peer is a new peer, send notification`() { - notificationManager.setAppBackgroundState(true) - notificationIntervalManager.recentlySeenPeers.addAll(emptyList()) - notificationManager.showActiveUserNotification(listOf("peer-1")) - verify(notificationManagerCompat, times(1)).notify(any(), any()) - } - - @Test - fun `when an active peer is a new peer and there are already multiple recently seen peers, send notification`() { - notificationManager.setAppBackgroundState(true) - notificationIntervalManager.recentlySeenPeers.addAll(listOf("peer-1", "peer-2")) - notificationManager.showActiveUserNotification(listOf("peer-3")) - verify(notificationManagerCompat, times(1)).notify(any(), any()) - } -} diff --git a/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt b/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt index d9c3897e..2a726ad0 100644 --- a/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt @@ -1,6 +1,8 @@ package com.bitchat import com.bitchat.android.mesh.PeerManager +import com.bitchat.android.mesh.PeerManagerDelegate +import com.bitchat.android.model.PeerCapabilities import junit.framework.TestCase.assertEquals import org.junit.Test @@ -31,6 +33,71 @@ class PeerManagerTest { val emptyDeviceAddresses = emptyMap() + @Test + fun peer_capabilities_are_retained_with_verified_identity() { + val capabilities = PeerCapabilities( + PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15) + ) + + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID = "peer-capabilities", + nickname = "alice", + noisePublicKey = ByteArray(32) { 1 }, + signingPublicKey = ByteArray(32) { 2 }, + isVerified = true, + capabilities = capabilities + ) + + assertEquals(capabilities, peerManager.getPeerInfo("peer-capabilities")?.capabilities) + } + + @Test + fun normal_peer_updates_preserve_signed_absent_and_empty_capabilities() { + val peerID = "peer-capability-state" + val noiseKey = ByteArray(32) { 3 } + val signingKey = ByteArray(32) { 4 } + + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + "alice", + noiseKey, + signingKey, + true, + null + ) + var info = peerManager.getPeerInfo(peerID)!! + assertEquals(true, info.hasVerifiedAnnouncement) + assertEquals(null, info.capabilities) + assertEquals(true, info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey)) + + peerManager.updatePeerInfo(peerID, "alice2", noiseKey, signingKey, true) + info = peerManager.getPeerInfo(peerID)!! + assertEquals(true, info.hasVerifiedAnnouncement) + assertEquals(null, info.capabilities) + + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + "alice2", + noiseKey, + signingKey, + true, + PeerCapabilities.NONE + ) + peerManager.updatePeerInfo(peerID, "alice3", noiseKey, signingKey, true) + info = peerManager.getPeerInfo(peerID)!! + assertEquals(true, info.hasVerifiedAnnouncement) + assertEquals(PeerCapabilities.NONE, info.capabilities) + + val changedNoiseKey = ByteArray(32) { 7 } + peerManager.updatePeerInfo(peerID, "alice4", changedNoiseKey, signingKey, true) + info = peerManager.getPeerInfo(peerID)!! + assertEquals(PeerCapabilities.NONE, info.capabilities) + assertEquals( + true, + info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey) + ) + } + val testRSSI = mapOf( "peer1" to 0, "peer2" to 10, @@ -101,6 +168,28 @@ class PeerManagerTest { assertEquals(testUsers.size - 3, numberOfAllPeers) } + @Test + fun suppressing_peer_list_update_still_notifies_peer_removal() { + val removedPeers = mutableListOf() + var peerListUpdates = 0 + peerManager.delegate = object : PeerManagerDelegate { + override fun onPeerListUpdated(peerIDs: List) { + peerListUpdates++ + } + + override fun onPeerRemoved(peerID: String) { + removedPeers.add(peerID) + } + } + peerManager.addOrUpdatePeer("peer-stale", "alice") + peerListUpdates = 0 + + peerManager.removePeer("peer-stale", notifyPeerList = false) + + assertEquals(listOf("peer-stale"), removedPeers) + assertEquals(0, peerListUpdates) + } + @Test fun last_seen_updated_correctly() { testUsers.forEach { peerID, _ -> @@ -257,4 +346,4 @@ class PeerManagerTest { assertEquals(expectedLine2, actualLine2) } -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt new file mode 100644 index 00000000..d1381170 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt @@ -0,0 +1,301 @@ +package com.bitchat.android.contracts + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.FragmentPayload +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.model.UnknownAnnouncementTLV +import com.bitchat.android.protocol.BinaryProtocol +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +/** + * Golden wire vectors for formats that a from-scratch client must reproduce. + * + * These assertions deliberately compare literal bytes rather than relying only + * on encode/decode round trips, which can hide matching bugs in both methods. + */ +class ClientRewriteWireContractTest { + + @Test + fun `v1 packet matches canonical unpadded bytes`() { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hex("1011121314151617"), + recipientID = null, + timestamp = 0x0102030405060708uL, + payload = hex("aabbcc"), + signature = null, + ttl = 7u + ) + + val encoded = BinaryProtocol.encode(packet, padding = false) + + assertArrayEquals( + hex("01020701020304050607080000031011121314151617aabbcc"), + encoded + ) + assertEquals(packet, BinaryProtocol.decode(encoded!!)) + } + + @Test + fun `v2 routed signed packet matches canonical section order`() { + val signature = ByteArray(64) { 0x5a } + val packet = BitchatPacket( + version = 2u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hex("0102030405060708"), + recipientID = hex("1112131415161718"), + timestamp = 42uL, + payload = hex("dead"), + signature = signature, + ttl = 5u, + route = listOf( + hex("2122232425262728"), + hex("3132333435363738") + ) + ) + + val encoded = BinaryProtocol.encode(packet, padding = false)!! + val expectedPrefix = hex( + "021105000000000000002a0b00000002" + + "0102030405060708" + + "1112131415161718" + + "02" + + "2122232425262728" + + "3132333435363738" + + "dead" + ) + + assertArrayEquals(expectedPrefix + signature, encoded) + assertEquals(packet, BinaryProtocol.decode(encoded)) + } + + @Test + fun `minimal chat message matches canonical binary payload`() { + val message = BitchatMessage( + id = "id", + sender = "bob", + content = "hi", + timestamp = Date(0x0102030405060708L) + ) + + val encoded = message.toBinaryPayload() + + assertArrayEquals( + hex("00010203040506070802696403626f6200026869"), + encoded + ) + assertEquals(message, BitchatMessage.fromBinaryPayload(encoded!!)) + } + + @Test + fun `chat message optional fields use flags and UTF-8 byte lengths`() { + val message = BitchatMessage( + id = "m", + sender = "é", + content = "hello", + timestamp = Date(42L), + isRelay = true, + originalSender = "o", + isPrivate = true, + recipientNickname = "r", + senderPeerID = "p", + mentions = listOf("a", "β"), + channel = "c" + ) + + val encoded = message.toBinaryPayload()!! + + assertArrayEquals( + hex( + "7f000000000000002a" + + "016d" + + "02c3a9" + + "000568656c6c6f" + + "016f" + + "0172" + + "0170" + + "02" + + "0161" + + "02ceb2" + + "0163" + ), + encoded + ) + assertEquals(message, BitchatMessage.fromBinaryPayload(encoded)) + } + + @Test + fun `encrypted chat payload carries ciphertext instead of placeholder content`() { + val message = BitchatMessage( + id = "e", + sender = "alice", + content = "must-not-be-on-wire", + timestamp = Date(1L), + encryptedContent = hex("000102ff"), + isEncrypted = true, + isPrivate = true + ) + + val decoded = BitchatMessage.fromBinaryPayload(message.toBinaryPayload()!!)!! + + assertEquals("", decoded.content) + assertArrayEquals(hex("000102ff"), decoded.encryptedContent) + assertTrue(decoded.isEncrypted) + assertTrue(decoded.isPrivate) + assertFalse(message.toBinaryPayload()!!.toString(Charsets.ISO_8859_1).contains("must-not-be-on-wire")) + } + + @Test + fun `private message and Noise envelopes match deployed type bytes`() { + val privateMessage = PrivateMessagePacket(messageID = "m1", content = "hi") + val privateMessageBytes = hex("00026d3101026869") + + assertArrayEquals(privateMessageBytes, privateMessage.encode()) + assertEquals(privateMessage, PrivateMessagePacket.decode(privateMessageBytes)) + assertArrayEquals( + hex("0100026d3101026869"), + NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privateMessageBytes).encode() + ) + assertEquals( + NoisePayloadType.FILE_TRANSFER, + NoisePayload.decode(hex("09cafe"))?.type + ) + assertArrayEquals( + hex("20cafe"), + NoisePayload.decode(hex("09cafe"))!!.encode() + ) + } + + @Test + fun `fragment payload matches the thirteen byte iOS header`() { + val fragment = FragmentPayload( + fragmentID = hex("0001020304050607"), + index = 1, + total = 3, + originalType = MessageType.MESSAGE.value, + data = hex("aabb") + ) + val wire = hex("00010203040506070001000302aabb") + + assertArrayEquals(wire, fragment.encode()) + assertEquals(fragment, FragmentPayload.decode(wire)) + assertTrue(fragment.isValid()) + } + + @Test + fun `sync request matches canonical TLV bytes and skips extensions`() { + val request = RequestSyncPacket( + p = 19, + m = 0x01020304L, + data = hex("aabb") + ) + val wire = hex("0100011302000401020304030002aabb") + + assertArrayEquals(wire, request.encode()) + assertSyncRequestEquals(request, RequestSyncPacket.decode(wire)) + + val withExtension = hex("7f0002cafe") + wire + assertSyncRequestEquals(request, RequestSyncPacket.decode(withExtension)) + } + + @Test + fun `identity announcement matches canonical TLV order and preserves extensions`() { + val announcement = IdentityAnnouncement( + nickname = "bob", + noisePublicKey = ByteArray(32) { 0x11 }, + signingPublicKey = ByteArray(32) { 0x22 }, + capabilities = PeerCapabilities.PRIVATE_MEDIA, + unknownTLVs = listOf(UnknownAnnouncementTLV(0x7f, hex("cafe"))) + ) + val expected = + hex("0103626f620220") + + ByteArray(32) { 0x11 } + + hex("0320") + + ByteArray(32) { 0x22 } + + hex("050200017f02cafe") + + val encoded = announcement.encode() + + assertArrayEquals(expected, encoded) + assertEquals(announcement, IdentityAnnouncement.decode(encoded!!)) + } + + @Test + fun `file transfer matches deployed mixed-width TLV vector`() { + val packet = BitchatFilePacket( + fileName = "a", + fileSize = 2, + mimeType = "m", + content = hex("dead") + ) + val wire = hex("01000161020004000000020300016d0400000002dead") + + assertArrayEquals(wire, packet.encode()) + + val decoded = BitchatFilePacket.decode(wire) + assertNotNull(decoded) + assertEquals(packet.fileName, decoded!!.fileName) + assertEquals(packet.fileSize, decoded.fileSize) + assertEquals(packet.mimeType, decoded.mimeType) + assertArrayEquals(packet.content, decoded.content) + } + + @Test + fun `required message prefixes reject every truncation`() { + val wire = BitchatMessage( + id = "id", + sender = "bob", + content = "hello", + timestamp = Date(1L) + ).toBinaryPayload()!! + + for (length in 0 until wire.size) { + assertNull( + "Accepted required message prefix of $length/${wire.size} bytes", + BitchatMessage.fromBinaryPayload(wire.copyOf(length)) + ) + } + assertNotNull(BitchatMessage.fromBinaryPayload(wire)) + } + + @Test + fun `TLV decoders reject missing required fields and truncated values`() { + assertNull(PrivateMessagePacket.decode(hex("00026d31"))) + assertNull(PrivateMessagePacket.decode(hex("00026d3101036869"))) + assertNull(RequestSyncPacket.decode(hex("0100011302000401020304"))) + assertNull(IdentityAnnouncement.decode(hex("0103626f62022011"))) + assertNull(BitchatFilePacket.decode(hex("010001610400000002de"))) + assertNull(FragmentPayload.decode(ByteArray(FragmentPayload.HEADER_SIZE - 1))) + } + + private fun hex(value: String): ByteArray { + require(value.length % 2 == 0) + return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun assertSyncRequestEquals( + expected: RequestSyncPacket, + actual: RequestSyncPacket? + ) { + assertNotNull(actual) + assertEquals(expected.p, actual!!.p) + assertEquals(expected.m, actual.m) + assertArrayEquals(expected.data, actual.data) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsConversationCleanupTest.kt b/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsConversationCleanupTest.kt new file mode 100644 index 00000000..3bed330b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsConversationCleanupTest.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.features.file + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.Date +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class FileUtilsConversationCleanupTest { + + @Test + fun `deleting conversation removes only unreferenced app-owned media`() { + val context = ApplicationProvider.getApplicationContext() + val testDirectory = File( + context.cacheDir, + "conversation-cleanup-${UUID.randomUUID()}" + ).apply { mkdirs() } + val deletedOnly = File(testDirectory, "deleted.jpg").apply { + writeBytes(byteArrayOf(1)) + } + val shared = File(testDirectory, "shared.jpg").apply { + writeBytes(byteArrayOf(2)) + } + val outsideAppStorage = File.createTempFile( + "conversation-cleanup-", + ".jpg" + ).apply { + writeBytes(byteArrayOf(3)) + } + + try { + FileUtils.deleteConversationMedia( + context = context, + deletedMessages = listOf( + mediaMessage("deleted", deletedOnly), + mediaMessage("shared-deleted", shared), + mediaMessage("outside", outsideAppStorage) + ), + retainedMessages = listOf(mediaMessage("shared-retained", shared)) + ) + + assertFalse(deletedOnly.exists()) + assertTrue(shared.exists()) + assertTrue(outsideAppStorage.exists()) + } finally { + testDirectory.deleteRecursively() + outsideAppStorage.delete() + } + } + + private fun mediaMessage(id: String, file: File) = BitchatMessage( + id = id, + sender = "alice", + content = file.absolutePath, + type = BitchatMessageType.Image, + timestamp = Date(1L), + isPrivate = true + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotPermissionsTest.kt b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotPermissionsTest.kt new file mode 100644 index 00000000..a7914fe0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotPermissionsTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.hotspot + +import android.Manifest +import org.junit.Assert.assertEquals +import org.junit.Test + +class HotspotPermissionsTest { + + @Test + fun `Android 17 requires nearby Wi-Fi and local network permissions`() { + assertEquals( + listOf( + Manifest.permission.NEARBY_WIFI_DEVICES, + Manifest.permission.ACCESS_LOCAL_NETWORK + ), + HotspotPermissions.requiredForSdk(37) + ) + } + + @Test + fun `Android 13 through 16 require nearby Wi-Fi permission`() { + val expected = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) + + assertEquals(expected, HotspotPermissions.requiredForSdk(33)) + assertEquals(expected, HotspotPermissions.requiredForSdk(36)) + } + + @Test + fun `Android 10 through 12 require fine location permission`() { + val expected = listOf(Manifest.permission.ACCESS_FINE_LOCATION) + + assertEquals(expected, HotspotPermissions.requiredForSdk(29)) + assertEquals(expected, HotspotPermissions.requiredForSdk(32)) + } + + @Test + fun `Android 9 and earlier require no hotspot runtime permission`() { + assertEquals(emptyList(), HotspotPermissions.requiredForSdk(28)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt new file mode 100644 index 00000000..4cc68492 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt @@ -0,0 +1,270 @@ +package com.bitchat.android.hotspot + +import android.net.wifi.p2p.WifiP2pManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HotspotStartupPolicyTest { + + @Test + fun `busy while P2P is disabled fails immediately instead of retrying`() { + val decision = HotspotStartupPolicy.decide( + reason = WifiP2pManager.BUSY, + attempt = 1, + p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED + ) + + assertEquals( + HotspotStartupPolicy.Decision.Fail(HotspotStartupPolicy.P2P_DISABLED_MESSAGE), + decision + ) + } + + @Test + fun `busy before the P2P state is known still retries`() { + val decision = HotspotStartupPolicy.decide( + reason = WifiP2pManager.BUSY, + attempt = 1, + p2pState = null + ) + + assertTrue(decision is HotspotStartupPolicy.Decision.Retry) + } + + @Test + fun `busy retry delays back off exponentially`() { + val delays = (1..4).map { attempt -> + val decision = HotspotStartupPolicy.decide( + reason = WifiP2pManager.BUSY, + attempt = attempt, + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED + ) + (decision as HotspotStartupPolicy.Decision.Retry).delayMillis + } + + assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L), delays) + } + + @Test + fun `busy on the final attempt gives up`() { + val decision = HotspotStartupPolicy.decide( + reason = WifiP2pManager.BUSY, + attempt = HotspotStartupPolicy.MAX_ATTEMPTS, + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED + ) + + assertTrue(decision is HotspotStartupPolicy.Decision.Fail) + } + + @Test + fun `unsupported P2P never retries`() { + val decision = HotspotStartupPolicy.decide( + reason = WifiP2pManager.P2P_UNSUPPORTED, + attempt = 1, + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED + ) + + assertEquals( + HotspotStartupPolicy.Decision.Fail(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE), + decision + ) + } + + @Test + fun `a group we recorded creating is removed before a new one is created`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = "DIRECT-BC-CUF6EN63", + ownedGroupName = "DIRECT-BC-CUF6EN63", + confirmedGroupName = null + ) + + assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action) + } + + @Test + fun `another app's group requires the user's confirmation`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = "DIRECT-xY-Chromecast", + ownedGroupName = "DIRECT-BC-CUF6EN63", + confirmedGroupName = null + ) + + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) + } + + @Test + fun `a confirmed replacement removes the confirmed foreign group`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = "DIRECT-xY-Chromecast", + ownedGroupName = null, + confirmedGroupName = "DIRECT-xY-Chromecast" + ) + + assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action) + } + + @Test + fun `consent for one group does not authorize removing a different one`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = "DIRECT-aB-QuickShare", + ownedGroupName = null, + confirmedGroupName = "DIRECT-xY-Chromecast" + ) + + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) + } + + @Test + fun `an unrecorded orphan needs confirmation even with our prefix`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = "DIRECT-BC-OLDGROUP", + ownedGroupName = null, + confirmedGroupName = null + ) + + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) + } + + @Test + fun `another phone's bitchat group is not treated as ours by its prefix`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = "DIRECT-BC-THEIRS99", + ownedGroupName = "DIRECT-BC-CUF6EN63", + confirmedGroupName = null + ) + + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) + } + + @Test + fun `a foreign group needs confirmation even when we recorded nothing`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = "DIRECT-xY-Chromecast", + ownedGroupName = null, + confirmedGroupName = null + ) + + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) + } + + @Test + fun `creation proceeds directly when no group exists`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, + existingGroupName = null, + ownedGroupName = null, + confirmedGroupName = null + ) + + assertEquals(HotspotStartupPolicy.StartAction.Create, action) + } + + @Test + fun `confirmation does not skip the disabled-P2P check`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED, + existingGroupName = "DIRECT-xY-Chromecast", + ownedGroupName = null, + confirmedGroupName = "DIRECT-xY-Chromecast" + ) + + assertEquals( + HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.P2P_DISABLED_MESSAGE), + action + ) + } + + @Test + fun `disabled P2P fails before touching any existing group`() { + val action = HotspotStartupPolicy.startAction( + p2pState = WifiP2pManager.WIFI_P2P_STATE_DISABLED, + existingGroupName = "DIRECT-BC-CUF6EN63", + ownedGroupName = "DIRECT-BC-CUF6EN63", + confirmedGroupName = null + ) + + assertEquals( + HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.P2P_DISABLED_MESSAGE), + action + ) + } + + @Test + fun `busy is retryable so a group orphaned mid-session can be cleared`() { + val decision = HotspotStartupPolicy.decide( + reason = WifiP2pManager.BUSY, + attempt = 1, + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED + ) + + assertEquals(HotspotStartupPolicy.Decision.Retry(1_000L), decision) + } + + @Test + fun `generic framework error never retries`() { + val decision = HotspotStartupPolicy.decide( + reason = WifiP2pManager.ERROR, + attempt = 1, + p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED + ) + + assertTrue(decision is HotspotStartupPolicy.Decision.Fail) + } + + @Test + fun `only the expected hosted group may be removed on stop`() { + assertTrue( + HotspotStartupPolicy.isExpectedHostedGroup( + existingGroupName = "DIRECT-BC-OURS1234", + isGroupOwner = true, + expectedGroupName = "DIRECT-BC-OURS1234" + ) + ) + assertFalse( + HotspotStartupPolicy.isExpectedHostedGroup( + existingGroupName = "DIRECT-xY-Cast", + isGroupOwner = true, + expectedGroupName = "DIRECT-BC-OURS1234" + ) + ) + assertFalse( + HotspotStartupPolicy.isExpectedHostedGroup( + existingGroupName = "DIRECT-BC-OURS1234", + isGroupOwner = false, + expectedGroupName = "DIRECT-BC-OURS1234" + ) + ) + assertFalse( + HotspotStartupPolicy.isExpectedHostedGroup( + existingGroupName = "DIRECT-BC-OURS1234", + isGroupOwner = true, + expectedGroupName = null + ) + ) + } + + @Test + fun `an old teardown cannot clear a newer ownership marker`() { + assertTrue( + HotspotStartupPolicy.shouldClearOwnedGroupName( + storedGroupName = "DIRECT-BC-OLD12345", + removedGroupName = "DIRECT-BC-OLD12345" + ) + ) + assertFalse( + HotspotStartupPolicy.shouldClearOwnedGroupName( + storedGroupName = "DIRECT-BC-NEW67890", + removedGroupName = "DIRECT-BC-OLD12345" + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt new file mode 100644 index 00000000..6d865582 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.identity + +import android.content.Context +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class PrivateMediaCapabilityPinPersistenceTest { + private val fingerprint = "ab".repeat(32) + private lateinit var manager: SecureIdentityStateManager + private lateinit var prefs: android.content.SharedPreferences + + @Before + fun setup() { + prefs = RuntimeEnvironment.getApplication().getSharedPreferences( + "private-media-pin-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + manager = SecureIdentityStateManager(prefs, testOnly = true) + manager.clearIdentityData() + } + + @After + fun tearDown() { + manager.clearIdentityData() + } + + @Test + fun `authenticated Ed key and capabilities persist rotate and clear atomically`() { + val firstKey = ByteArray(32) { 0x11 } + val rotatedKey = ByteArray(32) { 0x22 } + assertTrue(manager.storeAuthenticatedPeerState( + fingerprint, + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey) + )) + + val reloaded = SecureIdentityStateManager(prefs, testOnly = true) + assertArrayEquals(firstKey, reloaded.getAuthenticatedSigningKey(fingerprint)) + assertEquals( + PeerCapabilities.PRIVATE_MEDIA, + reloaded.getAuthenticatedPeerState(fingerprint)?.capabilities + ) + assertTrue(reloaded.isPrivateMediaCapable(fingerprint)) + + assertTrue(reloaded.storeAuthenticatedPeerState( + fingerprint, + AuthenticatedPeerState(PeerCapabilities.NONE, rotatedKey) + )) + assertArrayEquals(rotatedKey, reloaded.getAuthenticatedSigningKey(fingerprint)) + // HSTS-style private-media history is not erased by a no-bit proof. + assertTrue(reloaded.isPrivateMediaCapable(fingerprint)) + + reloaded.clearIdentityData() + assertFalse(manager.storeAuthenticatedPeerState( + fingerprint, + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey) + )) + val afterPanic = SecureIdentityStateManager(prefs, testOnly = true) + assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint)) + assertFalse(afterPanic.isPrivateMediaCapable(fingerprint)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt new file mode 100644 index 00000000..7598a96b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt @@ -0,0 +1,299 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoisePeerIdentity +import java.security.MessageDigest +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthenticatedPeerStateCoordinatorTest { + private class MemoryStore : AuthenticatedPeerStateStore { + val states = mutableMapOf() + val pins = mutableSetOf() + + override fun load(fingerprint: String): AuthenticatedPeerState? = states[fingerprint] + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean { + states[fingerprint] = state + if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) pins += fingerprint + onCommitted() + return true + } + override fun isPrivateMediaPinned(fingerprint: String): Boolean = fingerprint in pins + } + + private val remoteStatic = ByteArray(32) { 0x33 } + private val peerID = NoisePeerIdentity.derivePeerID(remoteStatic)!! + private val firstSession = AuthenticatedNoiseSession( + remoteStatic, + ByteArray(32) { 0x71 } + ) + private val secondSession = AuthenticatedNoiseSession( + remoteStatic, + ByteArray(32) { 0x72 } + ) + private val localState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x44 }) + private val remoteState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x55 }) + + @Test + fun `every generation emits and first valid proof echoes exactly once`() { + val store = MemoryStore() + val sent = CopyOnWriteArrayList() + val applied = CopyOnWriteArrayList() + var activeSession = firstSession + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { activeSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == activeSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, key, state -> + assertArrayEquals(remoteStatic, key) + applied += state + }, + sendState = { _, state, _ -> sent.add(state) }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + assertEquals(1, sent.size) + assertTrue(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals(2, sent.size) + assertTrue(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals("Repeated proof must not echo again", 2, sent.size) + assertEquals(1, applied.size) + + val different = AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 0x66 }) + assertFalse( + "A generation cannot replace its first proof", + coordinator.receive(peerID, different, firstSession) + ) + + activeSession = secondSession + // Policy can observe the crypto swap before its completion callback. It must adopt the + // new token as Awaiting instead of reusing gen-N Proven state. + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession)) + assertEquals(3, sent.size) + coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken) + assertEquals(3, sent.size) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession)) + assertFalse(coordinator.receive(peerID, remoteState, firstSession)) + assertTrue(coordinator.receive(peerID, different, secondSession)) + assertEquals(4, sent.size) + assertEquals(2, applied.size) + } + + @Test + fun `live generation is adopted once and watchdog is never reset by policy reads`() = runBlocking { + val sent = CopyOnWriteArrayList() + val resolutions = CopyOnWriteArrayList() + val coordinator = AuthenticatedPeerStateCoordinator( + scope = this, + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = MemoryStore(), + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> }, + sendState = { _, state, _ -> sent += state; true }, + onResolution = resolutions::add, + proofTimeoutMs = 20 + ) + + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + delay(10) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + delay(25) + + assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession)) + assertEquals(1, sent.size) + assertEquals(listOf(peerID), resolutions) + } + + @Test + fun `delayed old completion cannot replace a newer tracked generation`() { + val sentTokens = CopyOnWriteArrayList() + var activeSession = secondSession + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { activeSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == activeSession) action() else false + }, + store = MemoryStore(), + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> }, + sendState = { _, _, session -> sentTokens += session.sessionToken; true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + + coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession)) + assertEquals(1, sentTokens.size) + assertArrayEquals(secondSession.sessionToken, sentTokens.single()) + } + + @Test + fun `watchdog resolves no-proof generation without trusting persisted prior state`() = runBlocking { + val store = MemoryStore() + store.states[fingerprint(remoteStatic)] = remoteState + val resolutions = CopyOnWriteArrayList() + val coordinator = AuthenticatedPeerStateCoordinator( + scope = this, + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> }, + sendState = { _, _, _ -> true }, + onResolution = resolutions::add, + proofTimeoutMs = 20 + ) + + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + delay(50) + + assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession)) + assertEquals(listOf(peerID), resolutions) + } + + @Test + fun `copied-static preannounce Ed key is replaced by authenticated proof`() { + val peerManager = PeerManager() + val attackerEd = ByteArray(32) { 0x11 } + val victimEd = ByteArray(32) { 0x22 } + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + "attacker-name", + remoteStatic, + attackerEd, + true, + PeerCapabilities.PRIVATE_MEDIA + ) + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = MemoryStore(), + localStateProvider = { localState }, + applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, + sendState = { _, _, _ -> true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + + assertTrue( + coordinator.receive( + peerID, + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, victimEd), + firstSession + ) + ) + val peer = peerManager.getPeerInfo(peerID)!! + assertArrayEquals(victimEd, peer.signingPublicKey) + assertEquals(peerID, peer.nickname) + assertFalse("Copied self-signed nickname must lose verified status", peer.isVerifiedNickname) + assertFalse(peer.hasVerifiedAnnouncement) + } + + @Test + fun `failed durable persistence cannot publish peer state in memory`() { + val applied = CopyOnWriteArrayList() + val store = object : AuthenticatedPeerStateStore { + override fun load(fingerprint: String): AuthenticatedPeerState? = null + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean = false + override fun isPrivateMediaPinned(fingerprint: String): Boolean = false + } + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, state -> applied += state }, + sendState = { _, _, _ -> true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + + assertFalse(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + assertTrue(applied.isEmpty()) + } + + @Test + fun `stale decryption lease cannot persist or apply after generation replacement`() { + var activeSession = firstSession + var persistCalls = 0 + var applyCalls = 0 + val store = object : AuthenticatedPeerStateStore { + override fun load(fingerprint: String): AuthenticatedPeerState? = null + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean { + persistCalls += 1 + onCommitted() + return true + } + override fun isPrivateMediaPinned(fingerprint: String): Boolean = false + } + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { activeSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == activeSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> applyCalls += 1 }, + sendState = { _, _, _ -> true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + activeSession = secondSession + + assertFalse(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals(0, persistCalls) + assertEquals(0, applyCalls) + } + + private fun fingerprint(key: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(key).joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt new file mode 100644 index 00000000..3c6d5d13 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt @@ -0,0 +1,45 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class BluetoothConnectionManagerTest { + private lateinit var manager: BluetoothConnectionManager + + @Before + fun setUp() { + manager = BluetoothConnectionManager( + RuntimeEnvironment.getApplication(), + "0011223344556677" + ) + } + + @After + fun tearDown() { + manager.stopServices() + } + + @Test + fun `inactive manager rejects a broadcast instead of reporting it queued`() { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7), + recipientID = null, + timestamp = 1uL, + payload = byteArrayOf(1), + ttl = 7u + ) + + assertFalse(manager.broadcastPacket(RoutedPacket(packet))) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt new file mode 100644 index 00000000..bde80cdc --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BluetoothConnectionTrackerLinkObservationTest { + private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + private val tracker = BluetoothConnectionTracker(scope, mock()) + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `stale connection callbacks cannot mutate or remove replacement link`() { + val address = "AA:BB:CC:DD:EE:FF" + val device = mock() + whenever(device.address).thenReturn(address) + + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a") + ) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b") + ) + + assertFalse( + tracker.updateDeviceConnectionIfCurrent(address, "link-a") { + it.copy(rssi = -10) + } + ) + assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a")) + assertEquals("link-b", tracker.getCurrentLinkID(address)) + + assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677")) + assertEquals("0011223344556677", tracker.addressPeerMap[address]) + assertSame(device, tracker.getDeviceConnection(address)?.device) + } + + @Test + fun `one peer can remain directly observed over multiple current links`() { + val firstAddress = "AA:BB:CC:DD:EE:01" + val secondAddress = "AA:BB:CC:DD:EE:02" + val firstDevice = mock() + val secondDevice = mock() + whenever(firstDevice.address).thenReturn(firstAddress) + whenever(secondDevice.address).thenReturn(secondAddress) + + tracker.addDeviceConnection( + firstAddress, + BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a") + ) + tracker.addDeviceConnection( + secondAddress, + BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b") + ) + + assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID)) + assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID)) + assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID)) + assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID }) + + assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a")) + assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress]) + assertTrue(tracker.addressPeerMap.containsValue(PEER_ID)) + } + + private companion object { + const val PEER_ID = "0011223344556677" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt new file mode 100644 index 00000000..8c0f2ad4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DirectLinkAnnouncementPolicyTest { + @Test + fun `accepted max ttl announce is a direct routing observation`() { + val routed = announce(ttl = MAX_TTL) + + assertEquals( + DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID), + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + ) + } + + @Test + fun `relayed announce is not a direct routing observation`() { + assertNull( + DirectLinkAnnouncementPolicy.observationFor( + announce(ttl = (MAX_TTL - 1u).toUByte()), + MAX_TTL + ) + ) + } + + @Test + fun `repeated announce remains the same observation without transport authentication state`() { + val routed = announce(ttl = MAX_TTL) + + val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL) + + assertEquals(first, second) + } + + private fun announce(ttl: UByte) = RoutedPacket( + packet = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = PEER_ID.hexToBytes(), + timestamp = 1u, + payload = byteArrayOf(1), + ttl = ttl + ), + peerID = PEER_ID, + relayAddress = RELAY_ADDRESS, + ingressLinkID = LINK_ID + ) + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val PEER_ID = "0011223344556677" + const val RELAY_ADDRESS = "transport-neighbor" + const val LINK_ID = "current-link" + val MAX_TTL: UByte = 7u + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt new file mode 100644 index 00000000..79bc4c2d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -0,0 +1,486 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoisePeerIdentity +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoiseDecryptionResult +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.services.meshgraph.MeshGraphService +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageHandlerTest { + private lateinit var handler: MessageHandler + private lateinit var delegate: MessageHandlerDelegate + + private val myPeerID = "1111222233334444" + private val noiseKey = ByteArray(32) { 0x0B } + private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!! + private val authenticatedSession = AuthenticatedNoiseSession( + noiseKey, + ByteArray(32) { 0x5D } + ) + private val nickname = "peer" + private val signingKey = ByteArray(32) { 0x0A } + private val signature = ByteArray(64) { 1 } + private val announceClockSkewToleranceMs = 10 * 60 * 1000L + + @Before + fun setup() { + MeshGraphService.resetForTesting() + handler = MessageHandler(myPeerID, RuntimeEnvironment.getApplication()) + delegate = mock() + handler.delegate = delegate + + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true) + whenever( + delegate.updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + ).thenReturn(true) + } + + @After + fun tearDown() { + MeshGraphService.resetForTesting() + } + + @Test + fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() { + runBlocking { + val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfoFromVerifiedAnnouncement( + eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull() + ) + } + } + + @Test + fun `handleAnnounce accepts future announce within clock skew tolerance`() { + runBlocking { + val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Future announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfoFromVerifiedAnnouncement( + eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull() + ) + } + } + + @Test + fun `handleAnnounce stores advertised capabilities including unknown bits`() { + runBlocking { + val capabilities = PeerCapabilities( + PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15) + ) + val packet = announcePacket(ageMs = 0, capabilities = capabilities) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue(result) + verify(delegate).updatePeerInfoFromVerifiedAnnouncement( + eq(peerID), + eq(nickname), + any(), + any(), + eq(true), + eq(capabilities) + ) + } + } + + @Test + fun `handleAnnounce rejects announce older than clock skew tolerance`() { + runBlocking { + val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link")) + + assertFalse("Announce older than clock skew tolerance should not store peer identity", result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + } + } + + @Test + fun `handleAnnounce never promotes capability after invalid signature`() { + runBlocking { + whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(false) + val packet = announcePacket(ageMs = 0, capabilities = PeerCapabilities.PRIVATE_MEDIA) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + } + } + + @Test + fun `directed raw private media requires a valid signature`() { + runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(false) + val file = BitchatFilePacket( + fileName = "legacy.jpg", + fileSize = 3, + mimeType = "image/jpeg", + content = byteArrayOf(1, 2, 3) + ) + val unsigned = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = file.encode()!!, + signature = null, + ttl = 7u + ) + + handler.handleMessage(RoutedPacket(unsigned, peerID, "direct-link")) + handler.handleMessage( + RoutedPacket(unsigned.copy(signature = ByteArray(64) { 1 }), peerID, "direct-link") + ) + + verify(delegate, never()).onMessageReceived(any()) + } + } + + @Test + fun `valid signed directed raw private media remains interoperable`() { + runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(true) + val file = BitchatFilePacket( + fileName = "legacy-valid.jpg", + fileSize = 3, + mimeType = "image/jpeg", + content = byteArrayOf(1, 2, 3) + ) + val packet = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = file.encode()!!, + signature = signature, + ttl = 7u + ) + + handler.handleMessage(RoutedPacket(packet, peerID, "direct-link")) + + verify(delegate).verifySignature(packet, peerID) + verify(delegate).onMessageReceived(any()) + } + } + + @Test + fun `encrypted prerelease iOS Noise 0x09 private media is delivered`() { + runBlocking { + val file = BitchatFilePacket( + fileName = "prerelease-ios.pdf", + fileSize = 4, + mimeType = "application/pdf", + content = byteArrayOf(1, 2, 3, 4) + ) + val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!! + val ciphertext = byteArrayOf(0x41, 0x42, 0x43) + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn( + NoiseDecryptionResult(prereleasePlaintext, authenticatedSession) + ) + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.getMyNickname()).thenReturn("me") + whenever(delegate.encryptForPeer(any(), eq(peerID))).thenReturn(byteArrayOf(0x55)) + + val outerPacket = BitchatPacket( + version = 2u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + signature = signature, + ttl = 7u + ) + + handler.handleNoiseEncrypted(RoutedPacket(outerPacket, peerID, "direct-link")) + + verify(delegate).decryptFromPeer(ciphertext, peerID) + verify(delegate).onMessageReceived(any()) + } + } + + @Test + fun `valid encrypted peer state is delivered and malformed state is ignored`() = runBlocking { + val state = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey) + val validCiphertext = byteArrayOf(0x31) + val malformedCiphertext = byteArrayOf(0x32) + whenever(delegate.decryptFromPeer(validCiphertext, peerID)).thenReturn( + NoiseDecryptionResult( + NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode(), + authenticatedSession + ) + ) + whenever(delegate.decryptFromPeer(malformedCiphertext, peerID)).thenReturn( + NoiseDecryptionResult( + NoisePayload(NoisePayloadType.PEER_STATE, byteArrayOf(0x01, 0x01)).encode(), + authenticatedSession + ) + ) + val base = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = validCiphertext, + ttl = 7u + ) + + handler.handleNoiseEncrypted(RoutedPacket(base, peerID, "direct-link")) + handler.handleNoiseEncrypted( + RoutedPacket(base.copy(payload = malformedCiphertext), peerID, "direct-link") + ) + + verify(delegate).onAuthenticatedPeerStateReceived(peerID, state, authenticatedSession) + } + + @Test + fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking { + val attackerNoiseKey = ByteArray(32) { 0x6B } + val packet = announcePacket(ageMs = 0, noisePublicKey = attackerNoiseKey) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse("A valid self-signature cannot bind an attacker key to a victim ID", result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `announce requires a 32-byte Noise static key before peer update`() = runBlocking { + val malformedNoiseKey = ByteArray(31) { 0x0B } + val packet = announcePacket(ageMs = 0, noisePublicKey = malformedNoiseKey) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `announce packet sender cannot be processed under a different routed peer ID`() = runBlocking { + val otherPeerID = NoisePeerIdentity.derivePeerID(ByteArray(32) { 0x21 })!! + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, otherPeerID, "relay-link")) + + assertFalse(result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `known peer signing key cannot be replaced without bound Noise session`() = runBlocking { + whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 })) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(false) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `ambient bound Noise session does not authorize signing key replacement`() = runBlocking { + whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 })) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `persisted authenticated Ed key rejects copied-static preannounce after restart`() = runBlocking { + whenever(delegate.getAuthenticatedSigningKey(any())).thenReturn(ByteArray(32) { 0x44 }) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `repeated decrypt failures reset stale session and re-handshake`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val packet = encryptedPacket() + + repeat(2) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + verify(delegate).removeNoiseSession(peerID) + verify(delegate).initiateNoiseHandshake(peerID) + } + + @Test + fun `successful decrypt resets the failure counter`() = runBlocking { + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-1".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))) + .thenReturn(null) + .thenReturn(null) + .thenReturn(NoiseDecryptionResult(plaintext, authenticatedSession)) + .thenReturn(null) + .thenReturn(null) + val packet = encryptedPacket() + + repeat(5) { + handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `decrypt failures without an established session never reset`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(false) + val packet = encryptedPacket() + + repeat(4) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `successful decrypt reports the packet as valid`() = runBlocking { + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-2".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn( + NoiseDecryptionResult(plaintext, authenticatedSession) + ) + + assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link"))) + } + + private fun encryptedPacket(): BitchatPacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x41, 0x42, 0x43), + ttl = 7u + ) + + private fun announcePacket( + ageMs: Long, + ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), + capabilities: PeerCapabilities? = null, + noisePublicKey: ByteArray = noiseKey + ): BitchatPacket { + val announcement = IdentityAnnouncement( + nickname = nickname, + noisePublicKey = noisePublicKey, + signingPublicKey = signingKey, + capabilities = capabilities + ) + return BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = peerID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() - ageMs).toULong(), + payload = announcement.encode()!!, + signature = signature, + ttl = ttl + ) + } + + private fun String.hexToBytes(): ByteArray { + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun peerInfo(signingPublicKey: ByteArray) = PeerInfo( + id = peerID, + nickname = nickname, + isConnected = true, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = signingPublicKey, + isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt new file mode 100644 index 00000000..3a676382 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -0,0 +1,143 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PacketProcessorAnnounceSideEffectTest { + private val processors = mutableListOf() + + @After + fun tearDown() { + processors.forEach(PacketProcessor::shutdown) + } + + @Test + fun `rejected announce does not update last seen or relay`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = false) + val processor = processor(delegate) + + processor.processPacket(announce()) + withTimeout(1_000) { delegate.handled.await() } + + assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() }) + assertEquals(0, delegate.relayCount) + } + + @Test + fun `accepted announce unlocks post-handler side effects`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true) + val processor = processor(delegate) + + processor.processPacket(announce()) + + assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() }) + } + + @Test + fun `rejected handshake does not update last seen`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true, acceptHandshake = false) + val processor = processor(delegate) + + processor.processPacket(handshake()) + withTimeout(1_000) { delegate.handshakeHandled.await() } + + assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() }) + } + + @Test + fun `accepted handshake updates last seen`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true, acceptHandshake = true) + val processor = processor(delegate) + + processor.processPacket(handshake()) + + assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() }) + } + + private fun processor(delegate: RecordingDelegate): PacketProcessor = + PacketProcessor(MY_PEER_ID).also { + it.delegate = delegate + processors += it + } + + private fun announce(): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = PEER_ID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x01), + ttl = 7u + ) + return RoutedPacket(packet, PEER_ID, "direct-link") + } + + private fun handshake(): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = PEER_ID.hexToBytes(), + recipientID = MY_PEER_ID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x01), + ttl = 7u + ) + return RoutedPacket(packet, PEER_ID, "direct-link") + } + + private class RecordingDelegate( + private val acceptAnnounce: Boolean, + private val acceptHandshake: Boolean = false + ) : PacketProcessorDelegate { + val handled = CompletableDeferred() + val handshakeHandled = CompletableDeferred() + val lastSeen = CompletableDeferred() + @Volatile var relayCount = 0 + + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = true + override fun updatePeerLastSeen(peerID: String) { + lastSeen.complete(peerID) + } + override fun getPeerNickname(peerID: String): String? = null + override fun getNetworkSize() = 1 + override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean { + handshakeHandled.complete(Unit) + return acceptHandshake + } + override fun handleNoiseEncrypted(routed: RoutedPacket) = true + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + handled.complete(Unit) + return acceptAnnounce + } + override fun handleMessage(routed: RoutedPacket) = Unit + override fun handleLeave(routed: RoutedPacket) = Unit + override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null + override fun handleRequestSync(routed: RoutedPacket) = Unit + override fun sendAnnouncementToPeer(peerID: String) = Unit + override fun sendCachedMessages(peerID: String) = Unit + override fun relayPacket(routed: RoutedPacket) { + relayCount += 1 + } + override fun sendToPeer(peerID: String, routed: RoutedPacket) = false + } + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val MY_PEER_ID = "1111222233334444" + const val PEER_ID = "aaaabbbbccccdddd" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt new file mode 100644 index 00000000..ae896e53 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt @@ -0,0 +1,117 @@ + +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +class PacketRelayManagerTest { + + private lateinit var packetRelayManager: PacketRelayManager + private val delegate: PacketRelayManagerDelegate = mock() + + private val myPeerID = "1111111111111111" + private val otherPeerID = "2222222222222222" + private val nextHopPeerID = "3333333333333333" + private val finalRecipientID = "4444444444444444" + + @Before + fun setUp() { + packetRelayManager = PacketRelayManager(myPeerID) + packetRelayManager.delegate = delegate + whenever(delegate.getNetworkSize()).thenReturn(10) + whenever(delegate.getBroadcastRecipient()).thenReturn(byteArrayOf(0,0,0,0,0,0,0,0)) + } + + private fun createPacket(route: List?, recipient: String? = null): BitchatPacket { + return BitchatPacket( + type = MessageType.MESSAGE.value, + senderID = hexStringToPeerBytes(otherPeerID), + recipientID = recipient?.let { hexStringToPeerBytes(it) }, + timestamp = System.currentTimeMillis().toULong(), + payload = "hello".toByteArray(), + ttl = 5u, + route = route + ) + } + + @Test + fun `packet with duplicate hops is dropped`() = runTest { + val route = listOf( + hexStringToPeerBytes(nextHopPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `valid source-routed packet is relayed to next hop`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(nextHopPeerID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `last hop does not relay further`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(finalRecipientID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `packet with empty route is broadcast`() = runTest { + val packet = createPacket(null) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate).broadcastPacket(any()) + } + + private fun hexStringToPeerBytes(hex: String): ByteArray { + val result = ByteArray(8) + var idx = 0 + var out = 0 + while (idx + 1 < hex.length && out < 8) { + val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0 + result[out++] = b + idx += 2 + } + return result + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt new file mode 100644 index 00000000..8d4df343 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt @@ -0,0 +1,102 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PowerProfileResolverTest { + + @Test + fun `background normal without peers scans one second per minute`() { + val profile = resolve(battery = 80, background = true, peers = false) + + assertEquals(PowerManager.BatteryBand.NORMAL, profile.batteryBand) + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(59_000L, profile.ble.scanOffMs) + assertFalse(profile.ble.continuousScan) + assertEquals(8, profile.ble.maxConnections) + assertEquals(60_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `any direct transport peer accelerates background BLE discovery to thirty seconds`() { + val profile = resolve(battery = 80, background = true, peers = true) + + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(29_000L, profile.ble.scanOffMs) + } + + @Test + fun `background low profile uses selected wifi and nostr cadence`() { + val profile = resolve(battery = 20, background = true) + + assertEquals(PowerManager.BatteryBand.LOW, profile.batteryBand) + assertEquals(20_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(600_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(900_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(600_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(600_000L, profile.nostr.subscriptionValidationMs) + assertEquals(120_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `background critical profile uses slowest wifi and nostr cadence without reducing connections`() { + val profile = resolve(battery = 10, background = true, peers = true) + + assertEquals(PowerManager.BatteryBand.CRITICAL, profile.batteryBand) + assertEquals(30_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(900_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(1_200_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(900_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(900_000L, profile.nostr.subscriptionValidationMs) + assertEquals(300_000L, profile.meshAnnouncementIntervalMs) + assertEquals(8, profile.ble.maxConnections) + } + + @Test + fun `foreground normal preserves balanced BLE cadence`() { + val profile = resolve(battery = 80, background = false) + + assertEquals(PowerManager.PowerMode.BALANCED, profile.mode) + assertEquals(8_000L, profile.ble.scanOnMs) + assertEquals(2_000L, profile.ble.scanOffMs) + assertEquals(5_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(30_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `foreground charging is performance but background charging stays power limited`() { + val foreground = PowerProfileResolver.resolve(80, true, false, false) + val background = PowerProfileResolver.resolve(80, true, true, false) + + assertEquals(PowerManager.PowerMode.PERFORMANCE, foreground.mode) + assertTrue(foreground.ble.continuousScan) + assertEquals(PowerManager.PowerMode.POWER_SAVER, background.mode) + assertFalse(background.ble.continuousScan) + assertEquals(59_000L, background.ble.scanOffMs) + } + + @Test + fun `battery thresholds are inclusive`() { + assertEquals(PowerManager.BatteryBand.NORMAL, resolve(21, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(20, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(11, true).batteryBand) + assertEquals(PowerManager.BatteryBand.CRITICAL, resolve(10, true).batteryBand) + } + + private fun resolve( + battery: Int, + background: Boolean, + peers: Boolean = false + ) = PowerProfileResolver.resolve( + batteryLevel = battery, + isCharging = false, + isBackground = background, + hasDirectPeers = peers + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaSecurityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaSecurityTest.kt new file mode 100644 index 00000000..64deea13 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaSecurityTest.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.noise.AuthenticatedNoiseSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrivateMediaSecurityTest { + private val peerID = "0011223344556677" + private var authenticatedSession: AuthenticatedNoiseSession? = AuthenticatedNoiseSession( + ByteArray(32) { (it + 1).toByte() }, + ByteArray(32) { 0x51 } + ) + private var status: AuthenticatedPeerStateStatus = AuthenticatedPeerStateStatus.Awaiting + private var pinned = false + private val controller = PrivateMediaSecurityController( + authenticatedSessionProvider = { authenticatedSession }, + peerStateStatusProvider = { _, _ -> status }, + isPrivateMediaPinned = { pinned } + ) + + @Test + fun `live session waits for fresh generation proof`() { + assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID)) + } + + @Test + fun `valid private-media proof enables encrypted wire`() { + status = AuthenticatedPeerStateStatus.Proven( + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 1 }) + ) + assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Encrypted) + } + + @Test + fun `no-bit proof permits consent only when capability was never pinned`() { + status = AuthenticatedPeerStateStatus.Proven( + AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 1 }) + ) + assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID)) + + pinned = true + assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked) + } + + @Test + fun `no-proof timeout permits old-client consent but blocks pinned downgrade`() { + status = AuthenticatedPeerStateStatus.TimedOut + assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID)) + + pinned = true + assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked) + } + + @Test + fun `live session missing coordinator generation waits and never unlocks legacy`() { + status = AuthenticatedPeerStateStatus.Missing + assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID)) + } + + @Test + fun `pin never bypasses requirement for live authenticated session`() { + pinned = true + authenticatedSession = null + assertEquals(PrivateMediaPolicyDecision.NeedsHandshake, controller.sendPolicy(peerID)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt new file mode 100644 index 00000000..b84d641b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt @@ -0,0 +1,349 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.noise.AuthenticatedNoiseSession +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +class PrivateMediaTransferPreparerTest { + private val senderID = hex("0011223344556677") + private val recipientID = hex("8877665544332211") + private val authenticatedSession = AuthenticatedNoiseSession( + ByteArray(32) { 0x21 }, + ByteArray(32) { 0x22 } + ) + private val fragmentManagers = mutableListOf() + + @After + fun tearDown() { + fragmentManagers.forEach(FragmentManager::shutdown) + } + + @Test + fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() { + var encryptedPlaintext: ByteArray? = null + val preparer = preparer( + policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession), + encrypt = { bytes, _, _ -> + encryptedPlaintext = bytes + PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes) + } + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + val ready = outcome as PrivateMediaBuildOutcome.Ready + assertEquals(MessageType.NOISE_ENCRYPTED.value, ready.built.packet.type) + assertNotNull(ready.built.packet.signature) + assertEquals(PrivateMediaWireMode.ENCRYPTED_NOISE_0X20, ready.built.wireMode) + val decoded = NoisePayload.decode(encryptedPlaintext!!) + assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type) + assertEquals(0x20u.toUByte(), encryptedPlaintext!![0].toUByte()) + } + + @Test + fun `prerelease iOS Noise 0x09 decodes as file transfer but re-encodes canonical 0x20`() { + val filePayload = file(3).encode()!! + val prereleasePayload = byteArrayOf(0x09) + filePayload + + val decoded = NoisePayload.decode(prereleasePayload) + + assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type) + assertTrue(filePayload.contentEquals(decoded?.data)) + assertEquals(0x20u.toUByte(), decoded!!.encode()[0].toUByte()) + } + + @Test + fun `legacy mode requires consent and signing failure aborts`() { + val noConsent = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent) + .prepare("peer", recipientID, file(64), false) + assertTrue(noConsent is PrivateMediaBuildOutcome.RequiresLegacyConsent) + + val signingFailure = preparer( + policy = PrivateMediaPolicyDecision.RequiresLegacyConsent, + finalizer = { null } + ).prepare("peer", recipientID, file(64), true) + assertTrue(signingFailure is PrivateMediaBuildOutcome.Rejected) + assertTrue((signingFailure as PrivateMediaBuildOutcome.Rejected).reason.contains("nothing was sent")) + + val malformedSignature = preparer( + policy = PrivateMediaPolicyDecision.RequiresLegacyConsent, + finalizer = { packet -> packet.copy(signature = ByteArray(0)) } + ).prepare("peer", recipientID, file(64), true) + assertTrue(malformedSignature is PrivateMediaBuildOutcome.Rejected) + } + + @Test + fun `consented legacy mode is signed directed raw 0x22`() { + val outcome = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent) + .prepare("peer", recipientID, file(64), true) + + val ready = outcome as PrivateMediaBuildOutcome.Ready + assertEquals(MessageType.FILE_TRANSFER.value, ready.built.packet.type) + assertTrue(ready.built.packet.recipientID!!.contentEquals(recipientID)) + assertNotNull(ready.built.packet.signature) + assertEquals(PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22, ready.built.wireMode) + } + + @Test + fun `handshake requirement returns before encoding signing encryption or fragmentation`() { + var encrypted = false + var finalized = false + var fragmented = false + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake }, + encrypt = { _, _, _ -> + encrypted = true + PrivateMediaEncryptionResult.Success(byteArrayOf(1)) + }, + finalizeRoutedAndSigned = { + finalized = true + it + }, + fragment = { packet, maxFragments -> + fragmented = true + fragmentManager.createFragments(packet, maxFragments) + } + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + assertEquals(PrivateMediaBuildOutcome.NeedsHandshake, outcome) + assertTrue(!encrypted) + assertTrue(!finalized) + assertTrue(!fragmented) + } + + @Test + fun `peer-state wait returns before encoding signing encryption or fragmentation`() { + var encrypted = false + var finalized = false + var fragmented = false + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { PrivateMediaPolicyDecision.AwaitingPeerState }, + encrypt = { _, _, _ -> + encrypted = true + PrivateMediaEncryptionResult.Success(byteArrayOf(1)) + }, + finalizeRoutedAndSigned = { + finalized = true + it + }, + fragment = { packet, maxFragments -> + fragmented = true + fragmentManager.createFragments(packet, maxFragments) + } + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + assertEquals(PrivateMediaBuildOutcome.AwaitingPeerState, outcome) + assertTrue(!encrypted) + assertTrue(!finalized) + assertTrue(!fragmented) + } + + @Test + fun `impossible content size rejects before policy encryption signing or fragmentation`() { + var policyChecked = false + var encrypted = false + var finalized = false + var fragmented = false + val absolutePayloadUpperBound = + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID * + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { + policyChecked = true + PrivateMediaPolicyDecision.Encrypted(authenticatedSession) + }, + encrypt = { _, _, _ -> + encrypted = true + PrivateMediaEncryptionResult.Success(byteArrayOf(1)) + }, + finalizeRoutedAndSigned = { + finalized = true + it + }, + fragment = { _, _ -> + fragmented = true + emptyList() + } + ) + + val outcome = preparer.prepare( + "peer", + recipientID, + file(absolutePayloadUpperBound + 1), + false + ) + + assertTrue(outcome is PrivateMediaBuildOutcome.Rejected) + assertTrue((outcome as PrivateMediaBuildOutcome.Rejected).reason.contains("256")) + assertTrue(!policyChecked) + assertTrue(!encrypted) + assertTrue(!finalized) + assertTrue(!fragmented) + } + + @Test + fun `no route accepts 256 final fragments and rejects 257`() { + assertExactBoundary(route = null) + } + + @Test + fun `source route accepts 256 final fragments and rejects 257`() { + assertExactBoundary( + route = listOf( + hex("1021324354657687"), + hex("2031425364758697"), + hex("30415263748596a7") + ) + ) + } + + @Test + fun `generation churn re-runs policy once instead of losing the send intent`() { + val replacementSession = AuthenticatedNoiseSession( + authenticatedSession.remoteStaticKey, + ByteArray(32) { 0x23 } + ) + var policyCalls = 0 + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { + policyCalls += 1 + PrivateMediaPolicyDecision.Encrypted( + if (policyCalls == 1) authenticatedSession else replacementSession + ) + }, + encrypt = { bytes, _, session -> + if (session == authenticatedSession) { + PrivateMediaEncryptionResult.GenerationChanged + } else { + PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes) + } + }, + finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) }, + fragment = fragmentManager::createFragments + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + assertTrue(outcome is PrivateMediaBuildOutcome.Ready) + assertEquals(2, policyCalls) + } + + @Test + fun `repeated generation churn remains retryable`() { + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { PrivateMediaPolicyDecision.Encrypted(authenticatedSession) }, + encrypt = { _, _, _ -> PrivateMediaEncryptionResult.GenerationChanged }, + finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) }, + fragment = fragmentManager::createFragments + ) + + assertEquals( + PrivateMediaBuildOutcome.AwaitingPeerState, + preparer.prepare("peer", recipientID, file(64), false) + ) + } + + private fun assertExactBoundary(route: List?) { + val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) } + val preparer = preparer( + policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession), + finalizer = { packet -> + packet.copy( + version = if (route == null) packet.version else 2u, + route = route, + signature = ByteArray(64) { 0x5A } + ) + } + ) + + fun outcome(contentSize: Int): PrivateMediaBuildOutcome = preparer.prepare( + "peer", + recipientID, + BitchatFilePacket( + fileName = "boundary.bin", + fileSize = contentSize.toLong(), + mimeType = "application/octet-stream", + content = randomContent.copyOf(contentSize) + ), + false + ) + + var low = 1 + var high = randomContent.size + while (low < high) { + val mid = low + (high - low) / 2 + if (outcome(mid) is PrivateMediaBuildOutcome.Rejected) high = mid else low = mid + 1 + } + + val accepted = outcome(low - 1) as PrivateMediaBuildOutcome.Ready + val rejected = outcome(low) + assertEquals(256, accepted.built.fragments.size) + assertTrue(rejected is PrivateMediaBuildOutcome.Rejected) + assertTrue((rejected as PrivateMediaBuildOutcome.Rejected).reason.contains("256")) + } + + private fun preparer( + policy: PrivateMediaPolicyDecision, + encrypt: ( + ByteArray, + String, + AuthenticatedNoiseSession + ) -> PrivateMediaEncryptionResult = { bytes, _, _ -> + PrivateMediaEncryptionResult.Success(byteArrayOf(0x01) + bytes) + }, + finalizer: (BitchatPacket) -> BitchatPacket? = { packet -> + packet.copy(signature = ByteArray(64) { 0x33 }) + } + ): PrivateMediaTransferPreparer { + val fragmentManager = FragmentManager().also { fragmentManagers += it } + return PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { policy }, + encrypt = encrypt, + finalizeRoutedAndSigned = finalizer, + fragment = fragmentManager::createFragments, + now = { 1_700_000_000_000uL } + ) + } + + private fun file(size: Int) = BitchatFilePacket( + fileName = "test.bin", + fileSize = size.toLong(), + mimeType = "application/octet-stream", + content = ByteArray(size) { it.toByte() } + ) + + private fun hex(value: String): ByteArray = + value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt new file mode 100644 index 00000000..7503b6bb --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt @@ -0,0 +1,121 @@ +package com.bitchat.android.mesh + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RetryingControlPacketSenderTest { + @Test + fun `control packet is sent for the full redundant retry window`() = runTest { + val attempts = mutableListOf() + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 10, + interSendDelayMs = 1 + ) + + sender.enqueue( + key = "peer:message", + sendAttempt = { attempt -> + attempts += attempt + true + } + ) + advanceUntilIdle() + + assertEquals(listOf(1, 2, 3), attempts) + } + + @Test + fun `duplicate enqueue is coalesced while receipt retry is active`() = runTest { + var firstRequestAttempts = 0 + var duplicateRequestAttempts = 0 + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 10, + interSendDelayMs = 1 + ) + + sender.enqueue( + key = "peer:message", + sendAttempt = { + firstRequestAttempts += 1 + true + } + ) + sender.enqueue( + key = "peer:message", + sendAttempt = { + duplicateRequestAttempts += 1 + true + } + ) + advanceUntilIdle() + + assertEquals(3, firstRequestAttempts) + assertEquals(0, duplicateRequestAttempts) + } + + @Test + fun `transport writes for different receipts are serialized`() = runTest { + var activeWrites = 0 + var maximumActiveWrites = 0 + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 1, + retryDelayMs = 0, + interSendDelayMs = 0 + ) + + fun enqueue(key: String) { + sender.enqueue( + key = key, + sendAttempt = { + activeWrites += 1 + maximumActiveWrites = maxOf(maximumActiveWrites, activeWrites) + delay(10) + activeWrites -= 1 + true + } + ) + } + + enqueue("peer:first") + enqueue("peer:second") + advanceUntilIdle() + + assertEquals(1, maximumActiveWrites) + } + + @Test + fun `completion reports whether transport accepted any attempt`() = runTest { + val completions = mutableListOf() + val sender = RetryingControlPacketSender( + scope = this, + maxAttempts = 3, + retryDelayMs = 1, + interSendDelayMs = 0 + ) + + sender.enqueue( + key = "peer:rejected", + sendAttempt = { false }, + onComplete = completions::add + ) + sender.enqueue( + key = "peer:eventually-accepted", + sendAttempt = { attempt -> attempt == 2 }, + onComplete = completions::add + ) + advanceUntilIdle() + + assertEquals(2, completions.size) + assertEquals(setOf(false, true), completions.toSet()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt new file mode 100644 index 00000000..e43e11b5 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -0,0 +1,662 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoiseHandshakeProcessingResult +import com.bitchat.android.noise.NoisePeerIdentity +import com.bitchat.android.noise.NoiseSessionError +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class SecurityManagerTest { + + private lateinit var securityManager: SecurityManager + private lateinit var fakeEncryptionService: FakeEncryptionService + private lateinit var mockDelegate: SecurityManagerDelegate + + private val myPeerID = "1111222233334444" + private val otherPeerID = "aaaabbbbccccdddd" + // Key pairs (using dummy bytes for mock verification) + private val otherSigningKey = ByteArray(32) { 0xA } + private val otherNoiseKey = ByteArray(32) { 0xB } + private val sessionToken = ByteArray(32) { 0x5C } + private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!! + + private val dummyPayload = "Hello World".toByteArray() + private val validSignature = ByteArray(64) { 1 } + private val invalidSignature = ByteArray(64) { 0 } + + // Fake implementation to bypass initialization issues in tests + open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) { + var shouldVerify: Boolean = true + var lastVerifySignature: ByteArray? = null + var lastVerifyData: ByteArray? = null + var lastVerifyKey: ByteArray? = null + var handshakeResult = NoiseHandshakeProcessingResult(null, false) + var handshakeError: Exception? = null + var handshakeCalls = 0 + var removePeerCalls = 0 + + override fun initialize() { + // Do nothing to avoid KeyStore access in tests + } + + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { + lastVerifySignature = signature + lastVerifyData = data + lastVerifyKey = publicKeyBytes + + // Simple logic: if configured to verify, check if signature matches validSignature + // We use the signature bytes passed in setup() + if (shouldVerify) { + return signature.contentEquals(byteArrayOf(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)) + } + return false + } + + override fun processHandshakeMessageWithResult( + data: ByteArray, + peerID: String + ): NoiseHandshakeProcessingResult { + handshakeCalls += 1 + handshakeError?.let { throw it } + return handshakeResult + } + + override fun removePeer(peerID: String) { + removePeerCalls += 1 + } + } + + @Before + fun setup() { + fakeEncryptionService = FakeEncryptionService() + mockDelegate = mock() + + securityManager = SecurityManager(fakeEncryptionService, myPeerID) + securityManager.delegate = mockDelegate + } + + @After + fun tearDown() { + if (::securityManager.isInitialized) { + securityManager.shutdown() + } + } + + @Test + fun `validatePacket - rejects packet with missing signature`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet without signature should be rejected", result) + } + + @Test + fun `verifySignature - verifies canonical packet with announced signing key`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val packet = BitchatPacket( + version = 1u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = dummyPayload, + signature = validSignature, + ttl = 10u + ) + + assertTrue(securityManager.verifySignature(packet, otherPeerID)) + assertTrue(fakeEncryptionService.lastVerifySignature.contentEquals(validSignature)) + assertTrue(fakeEncryptionService.lastVerifyData.contentEquals(packet.toBinaryDataForSigning())) + assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + + @Test + fun `verifySignature - rejects missing signature and unknown signing key`() { + val unsigned = BitchatPacket( + version = 1u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = dummyPayload, + ttl = 10u + ) + assertFalse(securityManager.verifySignature(unsigned, otherPeerID)) + + unsigned.signature = validSignature + whenever(mockDelegate.getPeerInfo(otherPeerID)).thenReturn(null) + assertFalse(securityManager.verifySignature(unsigned, otherPeerID)) + } + + @Test + fun `validatePacket - rejects packet with invalid signature`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet with invalid signature should be rejected", result) + } + + @Test + fun `invalid packet does not poison duplicate detection for later valid packet`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = invalidSignature + assertFalse(securityManager.validatePacket(packet, otherPeerID)) + + packet.signature = validSignature + assertTrue(securityManager.validatePacket(packet, otherPeerID)) + } + + @Test + fun `validatePacket - rejects packet from unknown peer (no key)`() { + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result) + } + + @Test + fun `validatePacket - accepts packet with valid signature from known peer`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertTrue("Valid signed packet from known peer should be accepted", result) + } + + @Test + fun `validatePacket rejects unsigned and invalidly signed LEAVE packets`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val unsigned = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = byteArrayOf() + ) + assertFalse("Unsigned LEAVE must not evict or relay the claimed peer", securityManager.validatePacket(unsigned, otherPeerID)) + + val invalid = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = "forged".toByteArray() + ).also { it.signature = invalidSignature } + assertFalse("Bad LEAVE signature must be rejected", securityManager.validatePacket(invalid, otherPeerID)) + } + + @Test + fun `validatePacket accepts signed LEAVE from known peer`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = byteArrayOf() + ).also { it.signature = validSignature } + + assertTrue("A valid signed LEAVE remains wire-compatible", securityManager.validatePacket(packet, otherPeerID)) + assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + + @Test + fun `validatePacket rejects signed LEAVE outside replay window`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val stale = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + timestamp = (System.currentTimeMillis() - 5 * 60 * 1_000L - 1).toULong(), + payload = byteArrayOf() + ).also { it.signature = validSignature } + val future = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + timestamp = (System.currentTimeMillis() + 5 * 60 * 1_000L + 1_000).toULong(), + payload = byteArrayOf() + ).also { it.signature = validSignature } + + assertFalse("Captured LEAVE must expire even after replay-cache loss", securityManager.validatePacket(stale, otherPeerID)) + assertFalse("Future-dated LEAVE must not extend its replay lifetime", securityManager.validatePacket(future, otherPeerID)) + } + + @Test + fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertTrue("ANNOUNCE from unknown peer should be accepted (key extracted from payload)", result) + // Verify we used the correct key + assertTrue("Should have used extracted key for verification", + fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with invalid signature`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with invalid signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with malformed payload`() { + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = byteArrayOf(0x00, 0x01, 0x02) + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result) + } + + @Test + fun `validatePacket rejects self-signed announce whose Noise key derives another sender ID`() { + val attackerNoiseKey = ByteArray(32) { 0x6B } + val announcement = IdentityAnnouncement("Attacker", attackerNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, unknownPeerID)) + } + + @Test + fun `validatePacket rejects announce packet under a different routed sender`() { + val announcement = IdentityAnnouncement("Peer", otherNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, otherPeerID)) + } + + @Test + fun `validatePacket rejects announce conflicting with persisted authenticated Ed key`() { + whenever(mockDelegate.getAuthenticatedSigningKey(otherNoiseKey)) + .thenReturn(ByteArray(32) { 0x44 }) + val announcement = IdentityAnnouncement("Copied", otherNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, unknownPeerID)) + } + + @Test + fun `validatePacket - ignores own packets`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = myPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, myPeerID) + + assertFalse("Own packets should return false (skipped)", result) + } + + @Test + fun `validatePacket - detects duplicates`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result1 = securityManager.validatePacket(packet, otherPeerID) + assertTrue("First packet should be accepted", result1) + + val result2 = securityManager.validatePacket(packet, otherPeerID) + assertFalse("Duplicate packet should be rejected", result2) + } + + @Test + fun `validatePacket - handles ANNOUNCE duplicates correctly`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + // 1. Initial Announce (Fresh) + val packet1 = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS, // 7u + senderID = unknownPeerID, + payload = payload + ) + packet1.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + assertTrue("First ANNOUNCE should be accepted", securityManager.validatePacket(packet1, unknownPeerID)) + + // 2. Relayed Duplicate (Lower TTL) + val packet2 = packet1.copy(ttl = (com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - 1u).toUByte()) + assertFalse("Relayed duplicate ANNOUNCE should be rejected", securityManager.validatePacket(packet2, unknownPeerID)) + + // 3. Direct Duplicate (Max TTL) + val packet3 = packet1.copy(ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS) + assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID)) + } + + @Test + fun `replacement message one sends response without evicting or falsely completing`() = runBlocking { + val response = byteArrayOf(0x31, 0x32) + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(response, false) + val routed = handshakePacket(byteArrayOf(0x01, 0x02, 0x03)) + + val accepted = securityManager.handleNoiseHandshake(routed) + + assertTrue(accepted) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + verify(mockDelegate).sendHandshakeResponse(otherPeerID, response) + verify(mockDelegate, never()).onKeyExchangeCompleted( + any(), any(), any(), anyOrNull(), anyOrNull() + ) + } + + @Test + fun `identity mismatch preserves peer and does not poison retry or completion`() = runBlocking { + val routed = handshakePacket(byteArrayOf(0x41, 0x42, 0x43)) + fakeEncryptionService.handshakeError = NoiseSessionError.PeerIdentityMismatch( + otherPeerID, + "0000000000000000" + ) + + assertFalse(securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + verify(mockDelegate, never()).sendHandshakeResponse(any(), any()) + verify(mockDelegate, never()).onKeyExchangeCompleted( + any(), any(), any(), anyOrNull(), anyOrNull() + ) + + fakeEncryptionService.handshakeError = null + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey, + authenticatedSessionToken = sessionToken + ) + assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 2) + verify(mockDelegate).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + sessionToken, + "direct-link", + "direct-link-token" + ) + } + + @Test + fun `completion callback fires only for the frame that establishes a bound session`() = runBlocking { + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey, + authenticatedSessionToken = sessionToken + ) + val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53)) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + + verify(mockDelegate, times(1)).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + sessionToken, + "direct-link", + "direct-link-token" + ) + verify(mockDelegate, never()).sendHandshakeResponse(any(), any()) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + } + + @Test + fun `relayed completion does not authenticate the relay as the peer link`() = runBlocking { + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey, + authenticatedSessionToken = sessionToken + ) + val routed = handshakePacket( + payload = byteArrayOf(0x61, 0x62, 0x63), + ttl = 6u + ) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + verify(mockDelegate).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + sessionToken, + null, + null + ) + } + + @Test + fun `handshake dedup entries expire by time and allow delayed retry`() = runBlocking { + val payload = byteArrayOf(0x71, 0x72, 0x73) + val routed = handshakePacket(payload) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + assertFalse("Identical frame inside the dedup window must be dropped", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + securityManager.cleanupOldData( + System.currentTimeMillis() + + com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS + 1_000 + ) + + assertTrue("Expired dedup entries must not block a delayed retry", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 2) + } + + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { + val info = PeerInfo( + id = peerID, + nickname = "Test User", + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(32), + signingPublicKey = signingKey, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info) + } + + private fun handshakePacket(payload: ByteArray, ttl: UByte = 7u): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = otherPeerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = ttl + ) + return RoutedPacket( + packet = packet, + peerID = otherPeerID, + relayAddress = "direct-link", + ingressLinkID = "direct-link-token" + ) + } + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + // Duplicate-detection identity. + + /** + * Two distinct packets that agree on their first 64 bytes and share a + * timestamp. The old key hashed only that prefix, with a 32-bit + * `contentHashCode`, so these were "the same packet" and the second was + * silently dropped. + */ + private fun prefixSharingPair(): Pair { + val shared = ByteArray(64) { 0x7 } + val first = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = otherPeerID.hexToByteArrayForTest(), + recipientID = myPeerID.hexToByteArrayForTest(), + timestamp = 1_700_000_000_000uL, + payload = shared + byteArrayOf(0x01, 0x02, 0x03), + ttl = 7u + ) + val second = first.copy(payload = shared + byteArrayOf(0x0A, 0x0B, 0x0C)) + return first to second + } + + @Test + fun `packets differing only past the first 64 bytes are not treated as duplicates`() { + val (first, second) = prefixSharingPair() + + assertTrue(securityManager.validatePacket(first, otherPeerID)) + assertTrue( + "A distinct packet must not be dropped as a duplicate", + securityManager.validatePacket(second, otherPeerID) + ) + } + + @Test + fun `a genuine replay of the same packet is still rejected`() { + // The other half: strengthening the identity must not weaken replay + // protection, which is the reason this cache exists. + val (first, _) = prefixSharingPair() + + assertTrue(securityManager.validatePacket(first, otherPeerID)) + assertFalse( + "The identical packet must still be caught", + securityManager.validatePacket(first, otherPeerID) + ) + } + + @Test + fun `the same packet from two different peers is tracked separately`() { + // Peer scoping is deliberately kept: PacketIdUtil covers the packet's + // own senderID, which is not the peer it was received from once a + // packet has been relayed. + val (first, _) = prefixSharingPair() + + assertTrue(securityManager.validatePacket(first, otherPeerID)) + assertTrue(securityManager.validatePacket(first, unknownPeerID)) + } + + private fun String.hexToByteArrayForTest(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt b/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt new file mode 100644 index 00000000..056a5584 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AuthenticatedPeerStateTest { + private val signingKey = ByteArray(32) { it.toByte() } + + @Test + fun `encoder matches canonical iOS bytes`() { + val encoded = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey).encode() + + assertArrayEquals( + byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x01, 0x02, 0x20) + signingKey, + encoded + ) + assertEquals( + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey), + AuthenticatedPeerState.decode(encoded) + ) + } + + @Test + fun `decoder skips unknown TLVs and accepts either known-field order`() { + val payload = byteArrayOf( + 0x01, + 0x7F, 0x02, 0x55, 0x66, + 0x02, 0x20 + ) + signingKey + byteArrayOf(0x01, 0x01, 0x00) + + assertEquals( + AuthenticatedPeerState(PeerCapabilities.NONE, signingKey), + AuthenticatedPeerState.decode(payload) + ) + } + + @Test + fun `decoder rejects malformed duplicate missing and noncanonical fields`() { + val validCapabilities = byteArrayOf(0x01, 0x01, 0x00) + val validSigning = byteArrayOf(0x02, 0x20) + signingKey + val invalid = listOf( + byteArrayOf(), + byteArrayOf(0x02) + validCapabilities + validSigning, + byteArrayOf(0x01) + validCapabilities, + byteArrayOf(0x01) + validSigning, + byteArrayOf(0x01) + validCapabilities + validCapabilities + validSigning, + byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x00) + validSigning, + byteArrayOf(0x01, 0x01, 0x00) + validSigning, + byteArrayOf(0x01, 0x01, 0x09) + ByteArray(9) + validSigning, + byteArrayOf(0x01, 0x02, 0x1F) + ByteArray(31) + validCapabilities, + byteArrayOf(0x01, 0x7F), + byteArrayOf(0x01, 0x7F, 0x02, 0x01) + ) + + invalid.forEach { assertNull("Expected rejection for ${it.joinToString()}", AuthenticatedPeerState.decode(it)) } + } + + @Test + fun `Noise wrapper emits and decodes canonical 0x21`() { + val state = AuthenticatedPeerState(PeerCapabilities.NONE, signingKey) + val encoded = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode() + + assertEquals(0x21, encoded[0].toInt() and 0xFF) + assertEquals(NoisePayloadType.PEER_STATE, NoisePayload.decode(encoded)?.type) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt new file mode 100644 index 00000000..892f6565 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -0,0 +1,105 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IdentityAnnouncementTest { + private val nickname = "peer" + private val noiseKey = ByteArray(32) { 0x11 } + private val signingKey = ByteArray(32) { 0x22 } + + @Test + fun `capability assignments and encoding match iOS`() { + assertEquals(1L shl 0, PeerCapabilities.PREKEYS.rawValue) + assertEquals(1L shl 1, PeerCapabilities.WIFI_BULK.rawValue) + assertEquals(1L shl 2, PeerCapabilities.GATEWAY.rawValue) + assertEquals(1L shl 3, PeerCapabilities.GROUPS.rawValue) + assertEquals(1L shl 4, PeerCapabilities.BOARD.rawValue) + assertEquals(1L shl 5, PeerCapabilities.VOUCH.rawValue) + assertEquals(1L shl 6, PeerCapabilities.MESH_DIAGNOSTICS.rawValue) + assertEquals(1L shl 7, PeerCapabilities.BRIDGE.rawValue) + assertEquals(1L shl 8, PeerCapabilities.PRIVATE_MEDIA.rawValue) + assertEquals(1L shl 9, PeerCapabilities.PRIVATE_MEDIA_RECEIPTS.rawValue) + assertEquals(1L shl 10, PeerCapabilities.NON_DESTRUCTIVE_NOISE_REPLACEMENT.rawValue) + + val assigned = PeerCapabilities((1L shl 11) - 1) + assertArrayEquals(byteArrayOf(0xFF.toByte(), 0x07), assigned.encoded()) + assertEquals(assigned, PeerCapabilities.decode(assigned.encoded())) + } + + @Test + fun `capability encoding is minimal and preserves low 64 unknown bits`() { + assertArrayEquals(byteArrayOf(0x00), PeerCapabilities.NONE.encoded()) + assertArrayEquals(byteArrayOf(0x01), PeerCapabilities.PREKEYS.encoded()) + assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded()) + + val future = PeerCapabilities.decode( + byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80.toByte(), 0x55) + ) + assertEquals(Long.MIN_VALUE or 1L, future.rawValue) + assertArrayEquals( + byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80.toByte()), + future.encoded() + ) + } + + @Test + fun `legacy announcement without capability TLV still decodes`() { + val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! + + val decoded = IdentityAnnouncement.decode(legacy)!! + + assertEquals(nickname, decoded.nickname) + assertArrayEquals(noiseKey, decoded.noisePublicKey) + assertArrayEquals(signingKey, decoded.signingPublicKey) + assertNull(decoded.capabilities) + } + + @Test + fun `explicit empty capability TLV decodes as present but empty`() { + val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! + + val decoded = IdentityAnnouncement.decode(legacy + byteArrayOf(0x05, 0x00))!! + + assertEquals(PeerCapabilities.NONE, decoded.capabilities) + } + + @Test + fun `unknown capability bits and TLVs survive decode and re-encode`() { + val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! + val wire = legacy + byteArrayOf( + 0x05, 0x02, 0x00, 0x81.toByte(), // privateMedia plus unknown bit 15 + 0x7F, 0x03, 0x01, 0x02, 0x03 + ) + + val decoded = IdentityAnnouncement.decode(wire)!! + + assertEquals(0x8100L, decoded.capabilities?.rawValue) + assertEquals(1, decoded.unknownTLVs.size) + assertEquals(0x7F, decoded.unknownTLVs.single().type) + assertArrayEquals(byteArrayOf(0x01, 0x02, 0x03), decoded.unknownTLVs.single().value) + + val roundTripped = IdentityAnnouncement.decode(decoded.encode()!!)!! + assertEquals(decoded.capabilities, roundTripped.capabilities) + assertEquals(decoded.unknownTLVs, roundTripped.unknownTLVs) + } + + @Test + fun `local announcement send advertises private media`() { + val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! + + assertArrayEquals( + byteArrayOf(0x05, 0x02, 0x00, 0x01), + encoded.takeLast(4).toByteArray() + ) + assertTrue( + IdentityAnnouncement.decode(encoded)!! + .capabilities!! + .contains(PeerCapabilities.PRIVATE_MEDIA) + ) + assertEquals(PeerCapabilities.PRIVATE_MEDIA, PeerCapabilities.LOCAL_SUPPORTED) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/net/OkHttpProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/net/OkHttpProviderTest.kt new file mode 100644 index 00000000..95117435 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/net/OkHttpProviderTest.kt @@ -0,0 +1,26 @@ +package com.bitchat.android.net + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class OkHttpProviderTest { + + @Test + fun `reset clears cached clients without changing the route`() { + OkHttpProvider.reset() + val cachedHttp = OkHttpProvider.routedHttpClient() + val cachedWebSocket = OkHttpProvider.webSocketClient() + + OkHttpProvider.reset() + + val rebuiltHttp = OkHttpProvider.routedHttpClient() + val rebuiltWebSocket = OkHttpProvider.webSocketClient() + assertEquals(cachedHttp.route, rebuiltHttp.route) + assertNotSame(cachedHttp.client, rebuiltHttp.client) + assertNotSame(cachedWebSocket, rebuiltWebSocket) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt new file mode 100644 index 00000000..70badb96 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt @@ -0,0 +1,261 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.CipherState +import com.bitchat.android.noise.southernstorm.protocol.HandshakeState +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * Cacophony/Noise-C vector for Noise_XX_25519_ChaChaPoly_SHA256. + * + * This exercises the vendored Noise state machine directly, independent of managers, Android + * storage, and generated keys. + */ +class NoiseExternalVectorTest { + private val messages = listOf( + VectorMessage( + "4c756477696720766f6e204d69736573", + "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944" + + "4c756477696720766f6e204d69736573" + ), + VectorMessage( + "4d757272617920526f746862617264", + "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843" + + "81cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9" + + "bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f" + + "8814c7194e83f23dbd8d162c9326ad" + ), + VectorMessage( + "462e20412e20486179656b", + "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a8" + + "0ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6" + + "ae769a1d95941d49b25030" + ), + VectorMessage( + "4361726c204d656e676572", + "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df" + ), + VectorMessage( + "4a65616e2d426170746973746520536179", + "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5" + ), + VectorMessage( + "457567656e2042f6686d20766f6e2042617765726b", + "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3" + + "a58502ae3f" + ) + ) + + @Test + fun `Noise-C XX transcript matches every handshake and transport byte`() { + val initiator = vectorState(HandshakeState.INITIATOR) + val responder = vectorState(HandshakeState.RESPONDER) + try { + val states = listOf( + initiator to responder, + responder to initiator, + initiator to responder + ) + messages.take(3).zip(states).forEach { (message, peers) -> + assertHandshakeMessage(peers.first, peers.second, message) + } + + assertEquals(HandshakeState.SPLIT, initiator.action) + assertEquals(HandshakeState.SPLIT, responder.action) + assertArrayEquals(initiator.handshakeHash, responder.handshakeHash) + + val initiatorCiphers = initiator.split() + val responderCiphers = responder.split() + assertTransportMessage( + responderCiphers.sender, + initiatorCiphers.receiver, + messages[3] + ) + assertTransportMessage( + initiatorCiphers.sender, + responderCiphers.receiver, + messages[4] + ) + assertTransportMessage( + responderCiphers.sender, + initiatorCiphers.receiver, + messages[5] + ) + initiatorCiphers.sender.destroy() + initiatorCiphers.receiver.destroy() + responderCiphers.sender.destroy() + responderCiphers.receiver.destroy() + } finally { + initiator.destroy() + responder.destroy() + } + } + + @Test + fun `Noise state machine rejects invalid actions and a tampered handshake tag`() { + val initiator = vectorState(HandshakeState.INITIATOR) + val responder = vectorState(HandshakeState.RESPONDER) + try { + assertThrows(IllegalStateException::class.java) { initiator.start() } + assertThrows(IllegalStateException::class.java) { + responder.writeMessage(ByteArray(256), 0, null, 0, 0) + } + + assertHandshakeMessage(initiator, responder, messages[0]) + val message2 = write(responder, messages[1].payload) + val tampered = message2.copyOf() + tampered[tampered.lastIndex] = (tampered.last().toInt() xor 1).toByte() + + assertThrows(Exception::class.java) { + initiator.readMessage(tampered, 0, tampered.size, ByteArray(256), 0) + } + assertEquals(HandshakeState.FAILED, initiator.action) + } finally { + initiator.destroy() + responder.destroy() + } + } + + @Test + fun `ChaChaPoly authentication binds nonce ciphertext tag and associated data`() { + val key = ByteArray(32) { it.toByte() } + val plaintext = "associated".toByteArray() + val associatedData = "header".toByteArray() + val sender = Noise.createCipher("ChaChaPoly") + val receiver = Noise.createCipher("ChaChaPoly") + val wrongAdReceiver = Noise.createCipher("ChaChaPoly") + try { + sender.initializeKey(key, 0) + receiver.initializeKey(key, 0) + wrongAdReceiver.initializeKey(key, 0) + sender.setNonce(7) + receiver.setNonce(7) + wrongAdReceiver.setNonce(7) + val ciphertext = ByteArray(plaintext.size + sender.macLength) + val length = sender.encryptWithAd( + associatedData, + plaintext, + 0, + ciphertext, + 0, + plaintext.size + ) + + assertThrows(Exception::class.java) { + wrongAdReceiver.decryptWithAd( + "wrong".toByteArray(), + ciphertext, + 0, + ByteArray(length), + 0, + length + ) + } + val output = ByteArray(length) + val outputLength = receiver.decryptWithAd( + associatedData, + ciphertext, + 0, + output, + 0, + length + ) + assertArrayEquals(plaintext, output.copyOf(outputLength)) + } finally { + sender.destroy() + receiver.destroy() + wrongAdReceiver.destroy() + } + } + + private fun vectorState(role: Int): HandshakeState { + val state = HandshakeState(PROTOCOL, role) + val prologue = hex("4a6f686e2047616c74") + state.setPrologue(prologue, 0, prologue.size) + val staticPrivate = if (role == HandshakeState.INITIATOR) { + hex("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1") + } else { + hex("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893") + } + val ephemeralPrivate = if (role == HandshakeState.INITIATOR) { + hex("893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a") + } else { + hex("bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b") + } + state.localKeyPair.setPrivateKey(staticPrivate, 0) + state.fixedEphemeralKey.setPrivateKey(ephemeralPrivate, 0) + state.start() + return state + } + + private fun assertHandshakeMessage( + writer: HandshakeState, + reader: HandshakeState, + message: VectorMessage + ) { + val actualCiphertext = write(writer, message.payload) + assertArrayEquals(message.ciphertext, actualCiphertext) + + val plaintext = ByteArray(256) + val length = reader.readMessage( + actualCiphertext, + 0, + actualCiphertext.size, + plaintext, + 0 + ) + assertArrayEquals(message.payload, plaintext.copyOf(length)) + } + + private fun write(state: HandshakeState, payload: ByteArray): ByteArray { + val output = ByteArray(512) + val length = state.writeMessage(output, 0, payload, 0, payload.size) + return output.copyOf(length) + } + + private fun assertTransportMessage( + sender: CipherState, + receiver: CipherState, + message: VectorMessage + ) { + val encrypted = ByteArray(message.payload.size + sender.macLength) + val encryptedLength = sender.encryptWithAd( + null, + message.payload, + 0, + encrypted, + 0, + message.payload.size + ) + assertArrayEquals(message.ciphertext, encrypted.copyOf(encryptedLength)) + + val decrypted = ByteArray(encryptedLength) + val decryptedLength = receiver.decryptWithAd( + null, + encrypted, + 0, + decrypted, + 0, + encryptedLength + ) + assertArrayEquals(message.payload, decrypted.copyOf(decryptedLength)) + } + + private data class VectorMessage( + private val payloadHex: String, + private val ciphertextHex: String + ) { + val payload: ByteArray get() = hex(payloadHex) + val ciphertext: ByteArray get() = hex(ciphertextHex) + } + + companion object { + private const val PROTOCOL = "Noise_XX_25519_ChaChaPoly_SHA256" + + private fun hex(value: String): ByteArray = + value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt new file mode 100644 index 00000000..f180ea8e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt @@ -0,0 +1,153 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicReference + +class NoiseSessionManagerHandshakeTimeoutTest { + private data class TestIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val peerID: String + ) + + private val managers = mutableListOf() + + @After + fun tearDown() { + managers.forEach(NoiseSessionManager::shutdown) + } + + @Test + fun `stale initiator handshake is expired and reported as timeout`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val failure = AtomicReference() + aliceManager.onSessionFailed = { peerID, error -> + if (peerID == bob.peerID) failure.set(error) + } + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNull(aliceManager.getSession(bob.peerID)) + assertTrue(failure.get() is NoiseSessionError.HandshakeTimeout) + } + + @Test + fun `fresh handshake is not expired`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 9_000) + + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `expired handshake can be re-initiated immediately`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNotNull("Handshake must restart after expiry", aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `established session is never expired by the sweep`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val aliceSession = aliceManager.getSession(bob.peerID) + val bobSession = bobManager.getSession(alice.peerID) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + bobManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + + assertSame(aliceSession, aliceManager.getSession(bob.peerID)) + assertSame(bobSession, bobManager.getSession(alice.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertTrue(bobManager.hasEstablishedSession(alice.peerID)) + + val plaintext = "still alive".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `stale responder candidate expires while established session survives`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val established = aliceManager.getSession(bob.peerID) + + // Bob comes back and starts a replacement handshake: alice keeps the established + // session and parks the new handshake as a responder candidate. + val replacementManager = manager(bob) + val message1 = replacementManager.initiateHandshake(alice.peerID)!! + assertNotNull(aliceManager.processHandshakeMessage(bob.peerID, message1)) + assertSame(established, aliceManager.getSession(bob.peerID)) + + // Bob never finishes (message 2 lost): the candidate must expire without touching + // the working session. + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertSame(established, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + val plaintext = "unharmed".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + private fun completeHandshake( + initiator: NoiseSessionManager, + initiatorPeerID: String, + responder: NoiseSessionManager, + responderPeerID: String + ) { + val message1 = initiator.initiateHandshake(responderPeerID)!! + val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!! + val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!! + assertNull(responder.processHandshakeMessage(initiatorPeerID, message3)) + } + + private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager( + localStaticPrivateKey = identity.privateKey, + localStaticPublicKey = identity.publicKey, + localPeerID = identity.peerID + ).also { managers += it } + + private fun identity(): TestIdentity { + val dh = Noise.createDH("25519") + return try { + dh.generateKeyPair() + val privateKey = ByteArray(32) + val publicKey = ByteArray(32) + dh.getPrivateKey(privateKey, 0) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt new file mode 100644 index 00000000..1e18fd6a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt @@ -0,0 +1,444 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +class NoiseSessionManagerIdentityBindingTest { + private data class TestIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val peerID: String + ) + + private val managers = mutableListOf() + + @After + fun tearDown() { + managers.forEach(NoiseSessionManager::shutdown) + } + + @Test + fun `valid derived identities establish in initiator and responder roles`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertTrue(bobManager.hasEstablishedSession(alice.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID)) + + val plaintext = "bound transport".toByteArray() + val aliceSession = aliceManager.getAuthenticatedSession(bob.peerID)!! + val bobSession = bobManager.getAuthenticatedSession(alice.peerID)!! + val ciphertext = aliceManager.encryptForSession(plaintext, bob.peerID, aliceSession) + val decrypted = bobManager.decryptWithSession(ciphertext, alice.peerID) + assertArrayEquals(plaintext, decrypted.plaintext) + assertArrayEquals(bobSession.sessionToken, decrypted.authenticatedSession.sessionToken) + } + + @Test + fun `initiator rejects remote static key before returning message three`() { + val alice = identity() + val bob = identity() + val victim = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + var authenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 } + + val message1 = aliceManager.initiateHandshake(victim.peerID)!! + val message2 = bobManager.processHandshakeMessage(alice.peerID, message1)!! + + expectIdentityMismatch { + aliceManager.processHandshakeMessage(victim.peerID, message2) + } + + assertFalse(aliceManager.hasEstablishedSession(victim.peerID)) + assertNull(aliceManager.getSession(victim.peerID)) + assertTrue("No authenticated callback may escape a mismatched initiator", authenticatedCallbacks == 0) + } + + @Test + fun `responder rejects authenticated initiator key under a different claimed ID`() { + val alice = identity() + val bob = identity() + val victim = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + var authenticatedCallbacks = 0 + bobManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 } + + val message1 = aliceManager.initiateHandshake(bob.peerID)!! + val message2 = bobManager.processHandshakeMessage(victim.peerID, message1)!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + + expectIdentityMismatch { + bobManager.processHandshakeMessage(victim.peerID, message3) + } + + assertFalse(bobManager.hasEstablishedSession(victim.peerID)) + assertNull(bobManager.getSession(victim.peerID)) + assertTrue("No authenticated callback may escape a mismatched responder", authenticatedCallbacks == 0) + } + + @Test + fun `mismatched responder replacement preserves established session and transport keys`() { + val alice = identity() + val bob = identity() + val attacker = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + val attackerManager = manager(attacker) + var aliceAuthenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 } + + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + assertTrue(aliceAuthenticatedCallbacks == 1) + + val attackerMessage1 = attackerManager.initiateHandshake(alice.peerID)!! + val aliceMessage2 = aliceManager.processHandshakeMessage(bob.peerID, attackerMessage1)!! + val attackerMessage3 = attackerManager.processHandshakeMessage(alice.peerID, aliceMessage2)!! + + expectIdentityMismatch { + aliceManager.processHandshakeMessage(bob.peerID, attackerMessage3) + } + + assertSame("Rejected candidate must not replace the working session", originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertTrue("Rejected replacement must not fire authentication callback", aliceAuthenticatedCallbacks == 1) + + val plaintext = "original session survives".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + + // The failed candidate must be fully removed so a later valid restart can replace cleanly. + val restartedBobManager = manager(bob) + val validMessage1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val validMessage2 = aliceManager.processHandshakeMessage(bob.peerID, validMessage1)!! + val validMessage3 = restartedBobManager.processHandshakeMessage(alice.peerID, validMessage2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, validMessage3)) + assertTrue(aliceAuthenticatedCallbacks == 2) + + val retriedPlaintext = "valid retry promoted".toByteArray() + val retriedCiphertext = restartedBobManager.encrypt(retriedPlaintext, alice.peerID) + assertArrayEquals(retriedPlaintext, aliceManager.decrypt(retriedCiphertext, bob.peerID)) + } + + @Test + fun `valid responder replacement promotes only after bound handshake completes`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + var aliceAuthenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 } + + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!! + + // Simulate Bob restarting with the same persistent static identity and no session state. + val restartedBobManager = manager(bob) + val message1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!! + val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3)) + + assertNotSame(originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertTrue(aliceAuthenticatedCallbacks == 2) + val replacementBinding = aliceManager.getAuthenticatedSession(bob.peerID)!! + assertFalse(originalBinding.sessionToken.contentEquals(replacementBinding.sessionToken)) + try { + aliceManager.encryptForSession( + "stale generation".toByteArray(), + bob.peerID, + originalBinding + ) + fail("Expected stale generation-bound encryption to be rejected") + } catch (_: NoiseSessionError.SessionGenerationChanged) { + // Expected. + } + + val plaintext = "replacement transport".toByteArray() + val ciphertext = restartedBobManager.encryptForSession( + plaintext, + alice.peerID, + restartedBobManager.getAuthenticatedSession(alice.peerID)!! + ) + assertArrayEquals(plaintext, aliceManager.decrypt(ciphertext, bob.peerID)) + } + + @Test + fun `generation lease blocks replacement and rejects stale token afterward`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!! + + val restartedBobManager = manager(bob) + val message1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!! + val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!! + + val leaseEntered = CountDownLatch(1) + val releaseLease = CountDownLatch(1) + val replacementStarted = CountDownLatch(1) + val replacementCompleted = CountDownLatch(1) + val replacementFinished = AtomicBoolean(false) + val threadFailure = AtomicReference(null) + val leaseThread = Thread { + try { + assertTrue( + aliceManager.withAuthenticatedSession(bob.peerID, originalBinding) { + leaseEntered.countDown() + releaseLease.await(2, TimeUnit.SECONDS) + } + ) + } catch (error: Throwable) { + threadFailure.set(error) + } + } + val replacementThread = Thread { + try { + replacementStarted.countDown() + aliceManager.processHandshakeMessage(bob.peerID, message3) + replacementFinished.set(true) + } catch (error: Throwable) { + threadFailure.set(error) + } finally { + replacementCompleted.countDown() + } + } + + leaseThread.start() + assertTrue(leaseEntered.await(1, TimeUnit.SECONDS)) + replacementThread.start() + assertTrue(replacementStarted.await(1, TimeUnit.SECONDS)) + try { + assertFalse( + "Replacement must wait while the old generation lease is active", + replacementCompleted.await(100, TimeUnit.MILLISECONDS) + ) + } finally { + releaseLease.countDown() + } + leaseThread.join(2_000) + replacementThread.join(2_000) + assertTrue(replacementCompleted.await(1, TimeUnit.SECONDS)) + threadFailure.get()?.let { throw it } + assertTrue(replacementFinished.get()) + + try { + aliceManager.encryptForSession(byteArrayOf(1), bob.peerID, originalBinding) + fail("Expected old token to be rejected after replacement") + } catch (_: NoiseSessionError.SessionGenerationChanged) { + // Expected. + } + } + + @Test + fun `fresh initiator replacement preserves active session until authentication completes`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + + val restartedBobManager = manager(bob) + val message1 = aliceManager.initiateHandshake(bob.peerID, replaceEstablished = true)!! + assertSame(originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + + val message2 = restartedBobManager.processHandshakeMessage(alice.peerID, message1)!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + assertNull(restartedBobManager.processHandshakeMessage(alice.peerID, message3)) + + assertNotSame(originalSession, aliceManager.getSession(bob.peerID)) + val plaintext = "fresh link authenticated".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, restartedBobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `simultaneous initiator replacements use peer ID tie break and complete`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + + val originalAliceSession = aliceManager.getSession(bob.peerID) + val originalBobSession = bobManager.getSession(alice.peerID) + val aliceMessage1 = aliceManager.initiateHandshake( + bob.peerID, + replaceEstablished = true + )!! + val bobMessage1 = bobManager.initiateHandshake( + alice.peerID, + replaceEstablished = true + )!! + + val aliceCollisionResponse = aliceManager.processHandshakeMessage( + bob.peerID, + bobMessage1 + ) + val bobCollisionResponse = bobManager.processHandshakeMessage( + alice.peerID, + aliceMessage1 + ) + + if (alice.peerID < bob.peerID) { + assertNull(aliceCollisionResponse) + val message2 = bobCollisionResponse!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + assertNull(bobManager.processHandshakeMessage(alice.peerID, message3)) + } else { + assertNull(bobCollisionResponse) + val message2 = aliceCollisionResponse!! + val message3 = bobManager.processHandshakeMessage(alice.peerID, message2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3)) + } + + assertNotSame(originalAliceSession, aliceManager.getSession(bob.peerID)) + assertNotSame(originalBobSession, bobManager.getSession(alice.peerID)) + val plaintext = "collision replacement transport".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `simultaneous handshake collision matrix has one deterministic winner`() { + val identities = listOf( + identity("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"), + identity("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893"), + identity("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"), + identity("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb") + ) + + identities.indices.forEach { leftIndex -> + ((leftIndex + 1) until identities.size).forEach { rightIndex -> + val left = identities[leftIndex] + val right = identities[rightIndex] + val leftManager = manager(left) + val rightManager = manager(right) + val leftMessage1 = leftManager.initiateHandshake(right.peerID)!! + val rightMessage1 = rightManager.initiateHandshake(left.peerID)!! + + val leftResponse = leftManager.processHandshakeMessage(right.peerID, rightMessage1) + val rightResponse = rightManager.processHandshakeMessage(left.peerID, leftMessage1) + + if (left.peerID < right.peerID) { + assertNull(leftResponse) + val message3 = leftManager.processHandshakeMessage(right.peerID, rightResponse!!)!! + assertNull(rightManager.processHandshakeMessage(left.peerID, message3)) + } else { + assertNull(rightResponse) + val message3 = rightManager.processHandshakeMessage(left.peerID, leftResponse!!)!! + assertNull(leftManager.processHandshakeMessage(right.peerID, message3)) + } + + assertTrue(leftManager.hasEstablishedSession(right.peerID)) + assertTrue(rightManager.hasEstablishedSession(left.peerID)) + val payload = "matrix-$leftIndex-$rightIndex".toByteArray() + assertArrayEquals( + payload, + rightManager.decrypt( + leftManager.encrypt(payload, right.peerID), + left.peerID + ) + ) + } + } + } + + @Test + fun `peer ID derivation rejects malformed keys and non-wire claims`() { + val peer = identity() + + assertTrue(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID.uppercase(), peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID("not-a-wire-id", peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, ByteArray(31))) + assertNull(NoisePeerIdentity.derivePeerID(ByteArray(31))) + } + + private fun completeHandshake( + initiator: NoiseSessionManager, + initiatorPeerID: String, + responder: NoiseSessionManager, + responderPeerID: String + ) { + val message1 = initiator.initiateHandshake(responderPeerID)!! + val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!! + val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!! + assertNull(responder.processHandshakeMessage(initiatorPeerID, message3)) + } + + private fun expectIdentityMismatch(block: () -> Unit) { + try { + block() + fail("Expected authenticated Noise key to be rejected for the claimed peer ID") + } catch (_: NoiseSessionError.PeerIdentityMismatch) { + // Expected. + } + } + + private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager( + localStaticPrivateKey = identity.privateKey, + localStaticPublicKey = identity.publicKey, + localPeerID = identity.peerID + ).also { managers += it } + + private fun identity(): TestIdentity { + val dh = Noise.createDH("25519") + return try { + dh.generateKeyPair() + val privateKey = ByteArray(32) + val publicKey = ByteArray(32) + dh.getPrivateKey(privateKey, 0) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } + + private fun identity(privateKeyHex: String): TestIdentity { + val privateKey = privateKeyHex.chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + val dh = Noise.createDH("25519") + return try { + dh.setPrivateKey(privateKey, 0) + val publicKey = ByteArray(32) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSignatureTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSignatureTest.kt new file mode 100644 index 00000000..44044f2d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSignatureTest.kt @@ -0,0 +1,136 @@ +package com.bitchat.android.nostr + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Regression coverage for GH-733: geohash events (kind 20000/20001) were accepted from the + * relay without Schnorr signature verification, allowing full pubkey impersonation. + */ +@RunWith(RobolectricTestRunner::class) +class GeohashMessageHandlerSignatureTest { + + private val application: Application = ApplicationProvider.getApplicationContext() + + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var chatState: ChatState + private lateinit var dataManager: DataManager + private lateinit var messageManager: MessageManager + private lateinit var repo: GeohashRepository + private lateinit var handler: GeohashMessageHandler + + private val geohash = "u4pruy" + + @Before + fun setup() { + chatState = ChatState(scope = testScope) + dataManager = DataManager(context = application) + messageManager = MessageManager(state = chatState) + repo = GeohashRepository(application, chatState, dataManager) + handler = GeohashMessageHandler( + application = application, + repo = repo, + scope = testScope, + dataManager = dataManager, + addChannelMessage = messageManager::addChannelMessage, + signatureVerificationDispatcher = testDispatcher + ) + } + + private fun buildSignedEvent(identity: NostrIdentity, content: String): NostrEvent { + val unsigned = NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", geohash)), + content = content + ) + return unsigned.sign(identity.privateKeyHex) + } + + @Test + fun onEvent_acceptsGenuinelySignedEvent() { + val victim = NostrIdentity.generate() + val genuine = buildSignedEvent(victim, "hello from the real victim") + + handler.onEvent(genuine, geohash) + + val stored = chatState.getChannelMessagesValue()["geo:$geohash"] + assertEquals(1, stored?.size) + assertEquals("hello from the real victim", stored?.first()?.content) + } + + @Test + fun onEvent_rejectsEventWithForgedSignature() { + val victim = NostrIdentity.generate() + val attacker = NostrIdentity.generate() + + // Attacker claims the victim's pubkey but signs with their own key - a forged event. + val forged = buildSignedEvent(victim, "impersonated message").copy( + sig = NostrEvent( + pubkey = attacker.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", geohash)), + content = "impersonated message" + ).sign(attacker.privateKeyHex).sig + ) + + // Sanity check: this event is indeed invalid before we even touch the handler. + assertEquals(false, forged.isValidSignature()) + + handler.onEvent(forged, geohash) + + val stored = chatState.getChannelMessagesValue()["geo:$geohash"] + assertNull("Forged event must not be rendered as a legitimate message", stored) + } + + @Test + fun onEvent_stillAcceptsGenuineEventAfterForgedCopyWithSameIdWasRejected() { + val victim = NostrIdentity.generate() + val attacker = NostrIdentity.generate() + val genuine = buildSignedEvent(victim, "hello from the real victim") + + // A forged event carrying the SAME id as the genuine one (ids are a content hash, + // independent of the signature), but signed by an attacker - relays can deliver this + // before the genuine copy arrives from another relay. + val forgedWithSameId = genuine.copy( + sig = NostrEvent( + pubkey = attacker.publicKeyHex, + createdAt = genuine.createdAt, + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", geohash)), + content = "hello from the real victim" + ).sign(attacker.privateKeyHex).sig + ) + assertEquals(genuine.id, forgedWithSameId.id) + assertEquals(false, forgedWithSameId.isValidSignature()) + + // Forged copy arrives first and must be rejected without poisoning the dedup cache. + handler.onEvent(forgedWithSameId, geohash) + assertNull(chatState.getChannelMessagesValue()["geo:$geohash"]) + + // The genuine copy (same id, valid signature) arrives afterwards from another relay - + // it must still be rendered, not silently dropped as a "duplicate". + handler.onEvent(genuine, geohash) + + val stored = chatState.getChannelMessagesValue()["geo:$geohash"] + assertEquals(1, stored?.size) + assertEquals("hello from the real victim", stored?.first()?.content) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt new file mode 100644 index 00000000..6bd86042 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt @@ -0,0 +1,159 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NearbyNotesControllerTest { + private val subscriptions = mutableListOf() + private var unsubscribeCount = 0 + + private fun controller() = NearbyNotesController( + subscribe = subscriptions::add, + unsubscribe = { unsubscribeCount += 1 }, + ) + + private fun foregroundController() = controller().also { + it.updateAppForeground(true) + } + + @Test + fun `active mesh timeline does not subscribe before explicit reveal`() { + val controller = foregroundController() + + controller.updateAvailability( + locationEnabled = true, + locationAuthorized = true, + buildingGeohash = "u4pruydq", + ) + controller.activate() + + assertTrue(controller.offersRevealHint()) + assertTrue(subscriptions.isEmpty()) + + controller.reveal() + + assertFalse(controller.offersRevealHint()) + assertEquals(listOf("u4pruydq"), subscriptions) + } + + @Test + fun `reveal remains dormant until a nearby notes surface is active`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + + controller.reveal() + + assertTrue(subscriptions.isEmpty()) + + controller.activate() + + assertEquals(listOf("u4pruydq"), subscriptions) + } + + @Test + fun `last deactivate unsubscribes exactly once`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.reveal() + controller.activate() + controller.activate() + + controller.deactivate() + assertEquals(0, unsubscribeCount) + + controller.deactivate() + controller.deactivate() + + assertEquals(1, unsubscribeCount) + } + + @Test + fun `backgrounding closes the subscription and foregrounding restores it`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAppForeground(false) + + assertEquals(1, unsubscribeCount) + assertTrue(controller.revealed.value) + + controller.updateAppForeground(false) + assertEquals(1, unsubscribeCount) + + controller.updateAppForeground(true) + assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions) + } + + @Test + fun `disable and permission revocation close the live subscription`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAvailability(false, true, "u4pruydq") + assertEquals(1, unsubscribeCount) + + controller.updateAvailability(true, true, "u4pruydq") + assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions) + + controller.updateAvailability(true, false, "u4pruydq") + assertEquals(2, unsubscribeCount) + } + + @Test + fun `moving building cells releases old subscription before retargeting`() { + val events = mutableListOf() + val controller = NearbyNotesController( + subscribe = { events += "subscribe:$it" }, + unsubscribe = { events += "unsubscribe" }, + ) + controller.updateAppForeground(true) + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAvailability(true, true, "u4pruydr") + + assertEquals( + listOf( + "subscribe:u4pruydq", + "unsubscribe", + "subscribe:u4pruydr", + ), + events, + ) + } + + @Test + fun `building sampling is excluded until reveal while bookmarks remain eligible`() { + val channels = listOf( + GeohashChannel(GeohashChannelLevel.BUILDING, "u4pruydq"), + GeohashChannel(GeohashChannelLevel.BLOCK, "u4pruyd"), + GeohashChannel(GeohashChannelLevel.CITY, "u4pru"), + ) + + assertEquals( + listOf("u4pruyd", "u4pru", "saved123"), + geohashesForSampling( + availableChannels = channels, + bookmarks = listOf("saved123"), + notesRevealed = false, + ), + ) + assertEquals( + listOf("u4pruydq", "u4pruyd", "u4pru", "saved123"), + geohashesForSampling( + availableChannels = channels, + bookmarks = listOf("saved123"), + notesRevealed = true, + ), + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt new file mode 100644 index 00000000..152d4ad0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.nostr + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.services.AppStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrBackgroundEventProcessorTest { + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + AppStateStore.clear() + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + + @After + fun tearDown() { + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking { + val application = ApplicationProvider.getApplicationContext() + val processor = NostrBackgroundEventProcessor(application, scope) + val identity = NostrIdentity.generate() + val expectedIds = mutableSetOf() + + repeat(300) { index -> + val event = NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = index + 1, + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", "u4pruy")), + content = "message-$index" + ).sign(identity.privateKeyHex) + expectedIds += event.id + + processor.onGeohashMessage( + event = event, + geohash = "u4pruy" + ) + } + + withTimeout(5_000) { + while (AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().size < 300) { + kotlinx.coroutines.yield() + } + } + + assertEquals( + expectedIds, + AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet() + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt new file mode 100644 index 00000000..634e053f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -0,0 +1,195 @@ +package com.bitchat.android.nostr + +import android.os.Build +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ConversationRepository +import com.bitchat.android.services.InMemoryConversationStorageCipher +import com.bitchat.android.services.SeenMessageStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class NostrDirectMessageHandlerTest { + private val gson = Gson() + private lateinit var scope: CoroutineScope + private lateinit var conversationRepository: ConversationRepository + private lateinit var conversationDatabaseName: String + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + AppStateStore.clear() + conversationDatabaseName = "nostr-dm-${UUID.randomUUID()}.db" + conversationRepository = ConversationRepository( + context = RuntimeEnvironment.getApplication(), + dispatcher = Dispatchers.Unconfined, + databaseName = conversationDatabaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + AppStateStore.setConversationRepositoryForTest(conversationRepository) + } + + @After + fun tearDown() { + AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(null) + conversationRepository.closeForTest() + RuntimeEnvironment.getApplication() + .deleteDatabase(conversationDatabaseName) + scope.cancel() + Dispatchers.resetMain() + } + + @Test + fun `private messages use authenticated rumor time instead of randomized gift wrap time`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val messageManager = MessageManager(state) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + whenever(seenStore.hasDelivered(any())).thenReturn(true) + whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false) + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = { _, _ -> }, + scope = scope, + repo = GeohashRepository(application, state, dataManager), + dataManager = dataManager, + seenStoreProvider = { seenStore } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val now = (System.currentTimeMillis() / 1000).toInt() + val firstRumorTime = now - 120 + val secondRumorTime = now - 60 + val firstId = "first-real-time" + val secondId = "second-real-time" + + val first = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "first", + messageID = firstId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = firstRumorTime, + giftWrapCreatedAt = now - 5 + ) + val second = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "second", + messageID = secondId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = secondRumorTime, + giftWrapCreatedAt = now - 86_400 + ) + + handler.onGiftWrap(first, "", recipient) + waitForMessage(state, firstId) + handler.onGiftWrap(second, "", recipient) + waitForMessage(state, secondId) + + val messages = state.getPrivateChatsValue().values.single() + assertEquals(listOf(firstId, secondId), messages.map { it.id }) + assertEquals(firstRumorTime * 1000L, messages[0].timestamp.time) + assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time) + } + + private fun waitForMessage(state: ChatState, messageId: String) { + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { + while (state.getPrivateChatsValue().values.flatten().none { it.id == messageId }) { + delay(10) + } + } + } + } + + private fun privateMessageGiftWrap( + content: String, + sender: NostrIdentity, + recipient: NostrIdentity, + rumorCreatedAt: Int, + giftWrapCreatedAt: Int + ): NostrEvent { + val rumorBase = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = rumorCreatedAt, + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = content + ) + val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) + val sealContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(rumor), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = sender.privateKeyHex + ) + val seal = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = giftWrapCreatedAt, + kind = NostrKind.SEAL, + tags = emptyList(), + content = sealContent + ).sign(sender.privateKeyHex) + + val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() + val giftWrapContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(seal), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = wrapPrivateKey + ) + return NostrEvent( + pubkey = wrapPublicKey, + createdAt = giftWrapCreatedAt, + kind = NostrKind.GIFT_WRAP, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = giftWrapContent + ).sign(wrapPrivateKey) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrEventDeduplicatorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEventDeduplicatorTest.kt new file mode 100644 index 00000000..557ead2e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEventDeduplicatorTest.kt @@ -0,0 +1,66 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class NostrEventDeduplicatorTest { + @Test + fun `isDuplicate flags a repeated event id`() { + val deduplicator = NostrEventDeduplicator(maxCapacity = 10) + + assertEquals(false, deduplicator.isDuplicate("event-1")) + assertEquals(true, deduplicator.isDuplicate("event-1")) + } + + @Test + fun `capacity evicts the least recently used event id`() { + val deduplicator = NostrEventDeduplicator(maxCapacity = 2) + + deduplicator.isDuplicate("a") + deduplicator.isDuplicate("b") + deduplicator.isDuplicate("c") // evicts "a" + + assertTrue(deduplicator.contains("b")) + assertTrue(deduplicator.contains("c")) + assertEquals(false, deduplicator.contains("a")) + } + + /** + * totalChecks used to be incremented outside the lruLock that guards every other + * mutation in this class, so concurrent callers could race on the read-modify-write + * and lose increments. Every other counter (duplicateCount, evictionCount) was + * already incremented under the lock, which is why only this one drifted. + */ + @Test + fun `totalChecks counts every call exactly once under concurrent access`() { + val deduplicator = NostrEventDeduplicator(maxCapacity = 10_000) + val threadCount = 8 + val checksPerThread = 2_000 + val executor = Executors.newFixedThreadPool(threadCount) + val start = CountDownLatch(1) + val done = CountDownLatch(threadCount) + + repeat(threadCount) { threadIndex -> + executor.submit { + start.await() + repeat(checksPerThread) { callIndex -> + deduplicator.isDuplicate("thread-$threadIndex-event-$callIndex") + } + done.countDown() + } + } + + start.countDown() + assertTrue(done.await(10, TimeUnit.SECONDS)) + executor.shutdown() + + assertEquals( + (threadCount * checksPerThread).toLong(), + deduplicator.getStats().totalChecks + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt new file mode 100644 index 00000000..8954cbe3 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NostrPendingEventQueueTest { + @Test + fun `empty relay set is not queued`() { + val queue = NostrPendingEventQueue(capacity = 2) + + assertNull(queue.enqueue(event("empty"), emptyList(), liveLocationToken = null)) + assertEquals(0, queue.size()) + } + + @Test + fun `capacity evicts the oldest publish`() { + val queue = NostrPendingEventQueue(capacity = 2) + queue.enqueue(event("one"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("two"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("three"), listOf("relay"), liveLocationToken = null) + + assertEquals( + listOf("two", "three"), + queue.pendingForRelay("relay").map { it.event.content } + ) + } + + @Test + fun `duplicate event publishes retain independent delivery state`() { + val queue = NostrPendingEventQueue(capacity = 4) + val signedEvent = event("same") + val firstId = requireNotNull( + queue.enqueue(signedEvent, listOf("relay-a", "relay-b"), liveLocationToken = null) + ) + val secondId = requireNotNull( + queue.enqueue(signedEvent, listOf("relay-a"), liveLocationToken = null) + ) + assertNotEquals(firstId, secondId) + + queue.markDelivered(firstId, "relay-a") + + assertEquals( + listOf(secondId), + queue.pendingForRelay("relay-a").map { it.queueId } + ) + assertEquals( + listOf(firstId), + queue.pendingForRelay("relay-b").map { it.queueId } + ) + + queue.markDelivered(firstId, "relay-b") + assertEquals(1, queue.size()) + } + + @Test + fun `privacy purge retains non-live publishes`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("manual"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("live"), listOf("relay"), liveLocationToken = 42L) + + queue.removeLiveLocationEvents() + + assertEquals( + listOf("manual"), + queue.pendingForRelay("relay").map { it.event.content } + ) + } + + private fun event(content: String): NostrEvent { + val privateKey = "0".repeat(63) + "1" + return NostrEvent( + pubkey = NostrCrypto.derivePublicKey(privateKey), + createdAt = 1, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), + content = content + ).sign(privateKey) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt new file mode 100644 index 00000000..5faae233 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -0,0 +1,139 @@ +package com.bitchat.android.nostr + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NostrProtocolTest { + private val gson = Gson() + + @Test + fun decryptPrivateMessage_acceptsAuthenticatedSeal() { + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val giftWrap = NostrProtocol.createPrivateMessage( + content = "bitchat1:test", + recipientPubkey = recipient.publicKeyHex, + senderIdentity = sender + ).single() + + val decrypted = NostrProtocol.decryptPrivateMessage(giftWrap, recipient) + + assertEquals("bitchat1:test", decrypted?.first) + assertEquals(sender.publicKeyHex, decrypted?.second) + } + + @Test + fun decryptPrivateMessage_rejectsSealWhoseSignerDoesNotMatchRumor() { + val claimedSender = NostrIdentity.generate() + val attacker = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val giftWrap = forgedGiftWrap( + content = "bitchat1:forged", + claimedSender = claimedSender, + sealSigner = attacker, + recipient = recipient + ) + + val decrypted = NostrProtocol.decryptPrivateMessage(giftWrap, recipient) + + assertNull(decrypted) + } + + @Test + fun createPrivateMessage_reservesSlackInsideIosLookback() { + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + + assertEquals( + IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS, + NostrCrypto.NIP17_DEFAULT_MAX_PAST_SECONDS + ) + + repeat(20) { + val beforeCreation = (System.currentTimeMillis() / 1000).toInt() + val giftWrap = NostrProtocol.createPrivateMessage( + content = "bitchat1:test", + recipientPubkey = recipient.publicKeyHex, + senderIdentity = sender + ).single() + val afterCreation = (System.currentTimeMillis() / 1000).toInt() + val sealJson = NostrCrypto.decryptNIP44( + ciphertext = giftWrap.content, + senderPublicKeyHex = giftWrap.pubkey, + recipientPrivateKeyHex = recipient.privateKeyHex + ) + val seal = gson.fromJson(sealJson, NostrEvent::class.java) + + assertTimestampWithinIosLookback("gift wrap", giftWrap.createdAt, beforeCreation, afterCreation) + assertTimestampWithinIosLookback("seal", seal.createdAt, beforeCreation, afterCreation) + } + } + + private fun assertTimestampWithinIosLookback( + envelope: String, + createdAt: Int, + beforeCreation: Int, + afterCreation: Int + ) { + assertTrue( + "$envelope timestamp must leave 2 hours inside the iOS lookback", + createdAt >= beforeCreation - MAX_OUTBOUND_BACKDATE_SECONDS + ) + assertTrue( + "$envelope timestamp must not be in the future", + createdAt <= afterCreation + ) + } + + private fun forgedGiftWrap( + content: String, + claimedSender: NostrIdentity, + sealSigner: NostrIdentity, + recipient: NostrIdentity + ): NostrEvent { + val rumorBase = NostrEvent( + pubkey = claimedSender.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = content + ) + val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) + val sealContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(rumor), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = sealSigner.privateKeyHex + ) + val seal = NostrEvent( + pubkey = sealSigner.publicKeyHex, + createdAt = NostrCrypto.randomizeTimestampUpToPast(), + kind = NostrKind.SEAL, + tags = emptyList(), + content = sealContent + ).sign(sealSigner.privateKeyHex) + + val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() + val giftWrapContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(seal), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = wrapPrivateKey + ) + return NostrEvent( + pubkey = wrapPublicKey, + createdAt = NostrCrypto.randomizeTimestampUpToPast(), + kind = NostrKind.GIFT_WRAP, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = giftWrapContent + ).sign(wrapPrivateKey) + } + + private companion object { + const val IOS_DM_LOOKBACK_SECONDS = 86_400 + const val TIMESTAMP_SAFETY_SLACK_SECONDS = 7_200 + const val MAX_OUTBOUND_BACKDATE_SECONDS = + IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt new file mode 100644 index 00000000..de53a579 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -0,0 +1,79 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrRelayManagerLifecycleSmokeTest { + @Test + fun `owner teardown is synchronous and preserves other subscription owners`() { + val manager = NostrRelayManager.shared + manager.disconnect() + manager.clearAllSubscriptions() + val filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)) + + manager.subscribe( + filter = filter, + id = "background-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = NostrRelayManager.OWNER_BACKGROUND + ) + manager.subscribe( + filter = filter, + id = "ui-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = "test-ui" + ) + + manager.unsubscribeOwner("test-ui") + + assertEquals(setOf("background-contract"), manager.getActiveSubscriptions().keys) + manager.clearAllSubscriptions() + } + + @Test + fun `disconnected manager maintains subscription and empty publish invariants locally`() { + val manager = NostrRelayManager.shared + manager.disconnect() + manager.clearAllSubscriptions() + + val id = manager.subscribe( + filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)), + id = "local-contract", + handler = {}, + targetRelayUrls = emptyList() + ) + + assertEquals("local-contract", id) + assertEquals(1, manager.getActiveSubscriptionCount()) + assertTrue(manager.getActiveSubscriptions().containsKey(id)) + assertTrue(manager.validateSubscriptionConsistency().isConsistent) + manager.sendEvent(signedEvent(), relayUrls = emptyList()) + manager.retryConnection("wss://not-configured.example") + + manager.unsubscribe(id) + assertEquals(0, manager.getActiveSubscriptionCount()) + assertFalse(manager.isConnected.value) + assertTrue(manager.getRelayStatuses().none { it.isConnected }) + + manager.disconnect() + assertFalse(manager.isConnected.value) + } + + private fun signedEvent(): NostrEvent { + val privateKey = "0".repeat(63) + "1" + return NostrEvent( + pubkey = NostrCrypto.derivePublicKey(privateKey), + createdAt = 1, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), + content = "local" + ).sign(privateKey) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayReconnectPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayReconnectPolicyTest.kt new file mode 100644 index 00000000..f5e2581a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayReconnectPolicyTest.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The relay layer has no connectivity callback, its periodic validator only + * repairs subscriptions on sockets that are already open, and `connect()` runs + * once at startup. The backoff schedule is therefore the only thing that can + * bring relays back after the phone loses its data connection, so it has to + * saturate rather than terminate. + */ +class RelayReconnectPolicyTest { + + private val initial = AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS + private val ceiling = AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS + + @Test + fun `the first retry waits the initial interval`() { + assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(0))) + } + + @Test + fun `the interval doubles per attempt until it reaches the ceiling`() { + var attempt = 0 + var previous = 0L + var sawCeiling = false + + repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) { + attempt = RelayReconnectPolicy.nextAttempt(attempt) + val delay = RelayReconnectPolicy.backoffMs(attempt) + + assertTrue("delay must never exceed the ceiling", delay <= ceiling) + if (delay == ceiling) { + sawCeiling = true + } else { + assertEquals("expected doubling below the ceiling", maxOf(initial, previous * 2), delay) + } + previous = delay + } + + assertTrue("the schedule must actually reach the ceiling", sawCeiling) + } + + @Test + fun `an outage longer than the schedule keeps retrying at the ceiling`() { + var attempt = 0 + // Far past the old give-up point; a real outage can last hours. + repeat(500) { attempt = RelayReconnectPolicy.nextAttempt(attempt) } + + assertEquals(RelayReconnectPolicy.SATURATION_ATTEMPTS, attempt) + assertEquals(ceiling, RelayReconnectPolicy.backoffMs(attempt)) + } + + @Test + fun `a long outage cannot run the attempt counter or the exponent away`() { + var attempt = 0 + repeat(10_000) { attempt = RelayReconnectPolicy.nextAttempt(attempt) } + + val delay = RelayReconnectPolicy.backoffMs(attempt) + assertTrue("delay must stay finite and bounded", delay in 1..ceiling) + } + + @Test + fun `a successful connection resets the schedule to the initial interval`() { + var attempt = 0 + repeat(6) { attempt = RelayReconnectPolicy.nextAttempt(attempt) } + assertTrue(RelayReconnectPolicy.backoffMs(attempt) > initial) + + // updateRelayStatus zeroes reconnectAttempts on a successful open. + attempt = 0 + + assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(attempt))) + } + + @Test + fun `a nonsensical stored attempt count still yields a usable delay`() { + assertEquals(initial, RelayReconnectPolicy.backoffMs(RelayReconnectPolicy.nextAttempt(-5))) + assertTrue(RelayReconnectPolicy.backoffMs(0) in 1..ceiling) + assertTrue(RelayReconnectPolicy.backoffMs(Int.MAX_VALUE) in 1..ceiling) + } + + @Test + fun `the whole schedule stays under an hour of total wait before the ceiling`() { + var attempt = 0 + var total = 0L + repeat(RelayReconnectPolicy.SATURATION_ATTEMPTS) { + attempt = RelayReconnectPolicy.nextAttempt(attempt) + total += RelayReconnectPolicy.backoffMs(attempt) + } + + assertTrue("reaching the steady state must not take an hour", total < 60 * 60 * 1000L) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/onboarding/OptionalPermissionRequestTest.kt b/app/src/test/kotlin/com/bitchat/android/onboarding/OptionalPermissionRequestTest.kt new file mode 100644 index 00000000..b89a9a74 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/onboarding/OptionalPermissionRequestTest.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.onboarding + +import android.Manifest +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class OptionalPermissionRequestTest { + private lateinit var application: Application + + @Before + fun setUp() { + application = ApplicationProvider.getApplicationContext() + application.getSharedPreferences("bitchat_permissions", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + shadowOf(application).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + } + + @Test + fun `missing notification permission is offered once`() { + val permissionManager = PermissionManager(application) + + assertEquals( + listOf(Manifest.permission.POST_NOTIFICATIONS), + permissionManager.getUnrequestedOptionalPermissions() + ) + + permissionManager.markOptionalPermissionsRequested( + listOf(Manifest.permission.POST_NOTIFICATIONS) + ) + + assertTrue(permissionManager.getUnrequestedOptionalPermissions().isEmpty()) + } + + @Test + fun `granted notification permission is not requested`() { + shadowOf(application).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + + assertTrue(PermissionManager(application).getUnrequestedOptionalPermissions().isEmpty()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt new file mode 100644 index 00000000..a3f0341f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt @@ -0,0 +1,87 @@ +package com.bitchat.android.onboarding + +import android.app.Application +import android.bluetooth.BluetoothManager +import android.content.Context +import android.location.LocationManager +import androidx.activity.ComponentActivity +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class SystemStateManagerContractTest { + @Test + fun `Bluetooth disabled and enabled states are observable without throwing`() { + val app = ApplicationProvider.getApplicationContext() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).create() + val adapter = app.getSystemService(BluetoothManager::class.java).adapter + val manager = BluetoothStatusManager( + activity = controller.get(), + context = app, + onBluetoothEnabled = {}, + onBluetoothDisabled = {} + ) + + shadowOf(adapter).setEnabled(false) + assertEquals(BluetoothStatus.DISABLED, manager.checkBluetoothStatus()) + shadowOf(adapter).setEnabled(true) + assertEquals(BluetoothStatus.ENABLED, manager.checkBluetoothStatus()) + controller.destroy() + } + + @Test + fun `location disabled and enabled states are observable and receiver is cleaned up`() { + val app = ApplicationProvider.getApplicationContext() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).create() + val locationManager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val manager = LocationStatusManager( + activity = controller.get(), + context = app, + onLocationEnabled = {}, + onLocationDisabled = {} + ) + + shadowOf(locationManager).setLocationEnabled(false) + assertEquals(LocationStatus.DISABLED, manager.checkLocationStatus()) + shadowOf(locationManager).setLocationEnabled(true) + assertEquals(LocationStatus.ENABLED, manager.checkLocationStatus()) + + manager.cleanup() + manager.cleanup() + controller.destroy() + } + + @Test + fun `location status routing and recovery messages remain exact`() { + val app = ApplicationProvider.getApplicationContext() + val controller = Robolectric.buildActivity(ComponentActivity::class.java).create() + var enabled = 0 + val disabled = mutableListOf() + val manager = LocationStatusManager( + activity = controller.get(), + context = app, + onLocationEnabled = { enabled++ }, + onLocationDisabled = disabled::add + ) + + manager.handleLocationStatus(LocationStatus.ENABLED) + manager.handleLocationStatus(LocationStatus.NOT_AVAILABLE) + + assertEquals(1, enabled) + assertEquals( + listOf("Location services are not available on this device."), + disabled + ) + assertTrue(manager.getStatusMessage(LocationStatus.DISABLED).contains("Bluetooth scanning")) + manager.cleanup() + controller.destroy() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt b/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt new file mode 100644 index 00000000..f83a17e3 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/service/PeerAvailabilityNotifierTest.kt @@ -0,0 +1,342 @@ +package com.bitchat.android.service + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +class PeerAvailabilityTrackerTest { + + @Test + fun `repeated flapping produces only one alert during cooldown`() { + val clock = MutableClock() + val tracker = tracker(clock = clock) + var showCount = 0 + + if (tracker.update(1, isAppInBackground = true) == PeerAvailabilityAction.SHOW) { + showCount++ + tracker.markAlertShown() + } + + repeat(5) { + clock.advance(1_000L) + assertEquals( + PeerAvailabilityAction.CLEAR, + tracker.update(0, isAppInBackground = true) + ) + clock.advance(PeerAvailabilityTracker.EMPTY_REARM_DELAY_MS) + if (tracker.update(1, isAppInBackground = true) == PeerAvailabilityAction.SHOW) { + showCount++ + tracker.markAlertShown() + } + } + + assertEquals(1, showCount) + } + + @Test + fun `mesh must remain empty before notification re-arms`() { + val clock = MutableClock() + val tracker = tracker( + clock = clock, + alertCooldownMs = 0L + ) + + assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true)) + tracker.markAlertShown() + + assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true)) + clock.advance(PeerAvailabilityTracker.EMPTY_REARM_DELAY_MS - 1) + assertEquals(PeerAvailabilityAction.NONE, tracker.update(1, isAppInBackground = true)) + + assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true)) + clock.advance(PeerAvailabilityTracker.EMPTY_REARM_DELAY_MS) + assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true)) + } + + @Test + fun `cooldown survives tracker recreation`() { + val clock = MutableClock() + val history = InMemoryAlertHistory() + val originalTracker = tracker(clock = clock, history = history) + + assertEquals( + PeerAvailabilityAction.SHOW, + originalTracker.update(1, isAppInBackground = true) + ) + originalTracker.markAlertShown() + + clock.advance(PeerAvailabilityTracker.ALERT_COOLDOWN_MS - 1) + val restartedDuringCooldown = tracker(clock = clock, history = history) + assertEquals( + PeerAvailabilityAction.NONE, + restartedDuringCooldown.update(1, isAppInBackground = true) + ) + + clock.advance(1) + val restartedAfterCooldown = tracker(clock = clock, history = history) + assertEquals( + PeerAvailabilityAction.SHOW, + restartedAfterCooldown.update(1, isAppInBackground = true) + ) + } + + @Test + fun `foreground discovery is not replayed after app enters background`() { + val tracker = tracker() + + assertEquals(PeerAvailabilityAction.NONE, tracker.update(1, isAppInBackground = false)) + assertEquals(PeerAvailabilityAction.NONE, tracker.update(1, isAppInBackground = true)) + assertEquals(PeerAvailabilityAction.CLEAR, tracker.update(0, isAppInBackground = true)) + assertEquals(PeerAvailabilityAction.SHOW, tracker.update(1, isAppInBackground = true)) + } + + @Test(expected = IllegalArgumentException::class) + fun `negative peer count is rejected`() { + tracker().update(-1, isAppInBackground = true) + } + + private fun tracker( + clock: MutableClock = MutableClock(), + history: InMemoryAlertHistory = InMemoryAlertHistory(), + alertCooldownMs: Long = PeerAvailabilityTracker.ALERT_COOLDOWN_MS + ): PeerAvailabilityTracker { + return PeerAvailabilityTracker( + alertHistory = history, + nowMillis = clock::now, + alertCooldownMs = alertCooldownMs + ) + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [32]) +@OptIn(ExperimentalCoroutinesApi::class) +class PeerAvailabilityNotifierTest { + private lateinit var context: Context + private lateinit var systemNotificationManager: NotificationManager + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + systemNotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + systemNotificationManager.cancelAll() + context.getSharedPreferences( + SharedPreferencesPeerAvailabilityAlertHistory.PREFERENCES_NAME, + Context.MODE_PRIVATE + ).edit().clear().commit() + } + + @Test + fun `background availability waits ten seconds before posting on dedicated channel`() = runTest { + val textProvider = testTextProvider() + val notifier = createNotifier( + scope = this, + textProvider = textProvider + ) + + notifier.onPeerCountChanged(0, isAppInBackground = true) + notifier.onPeerCountChanged(2, isAppInBackground = true) + + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS - 1) + assertNull( + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + ) + + advanceTimeBy(1) + runCurrent() + val notification = + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + assertNotNull(notification) + assertEquals(PeerAvailabilityNotifier.CHANNEL_ID, notification.channelId) + assertEquals( + textProvider.title(), + notification.extras.getString("android.title") + ) + assertEquals( + textProvider.body(2), + notification.extras.getString("android.text") + ) + assertTrue(notification.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0) + assertNotNull( + systemNotificationManager.getNotificationChannel(PeerAvailabilityNotifier.CHANNEL_ID) + ) + } + + @Test + fun `later peer arrivals are included without restarting aggregation window`() = runTest { + val textProvider = testTextProvider() + val notifier = createNotifier( + scope = this, + textProvider = textProvider + ) + + notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(6_000L) + notifier.onPeerCountChanged(3, isAppInBackground = true) + advanceTimeBy(4_000L) + runCurrent() + + val notification = + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + assertNotNull(notification) + assertEquals( + textProvider.body(3), + notification.extras.getString("android.text") + ) + } + + @Test + fun `opening app during aggregation suppresses notification`() = runTest { + var isAppInBackground = true + val history = InMemoryAlertHistory() + val notifier = createNotifier( + scope = this, + availabilityTracker = PeerAvailabilityTracker(history), + isAppCurrentlyInBackground = { isAppInBackground } + ) + + notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2) + isAppInBackground = false + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2) + runCurrent() + + assertNull( + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + ) + assertNull(history.lastAlertAtMillis) + } + + @Test + fun `returning to zero cancels pending availability notification`() = runTest { + val notifier = createNotifier(scope = this) + + notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2) + notifier.onPeerCountChanged(0, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS) + runCurrent() + + assertNull( + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + ) + } + + @Test + fun `explicit clear cancels pending availability notification`() = runTest { + val notifier = createNotifier(scope = this) + + notifier.onPeerCountChanged(1, isAppInBackground = true) + notifier.clear() + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS) + runCurrent() + + assertNull( + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + ) + } + + @Test + fun `disabled notifications do not post`() = runTest { + val history = InMemoryAlertHistory() + val notifier = createNotifier( + scope = this, + availabilityTracker = PeerAvailabilityTracker(history), + canPostNotifications = { false } + ) + + notifier.onPeerCountChanged(1, isAppInBackground = true) + advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS) + runCurrent() + + assertNull( + shadowOf(systemNotificationManager).getNotification( + PeerAvailabilityNotifier.NOTIFICATION_ID + ) + ) + assertNull(history.lastAlertAtMillis) + } + + @Test + fun `shared preferences history persists last alert time`() { + val history = SharedPreferencesPeerAvailabilityAlertHistory(context) + history.lastAlertAtMillis = 42L + + assertEquals( + 42L, + SharedPreferencesPeerAvailabilityAlertHistory(context).lastAlertAtMillis ?: -1L + ) + } + + private fun tracker(): PeerAvailabilityTracker { + return PeerAvailabilityTracker(InMemoryAlertHistory()) + } + + private fun createNotifier( + scope: CoroutineScope, + availabilityTracker: PeerAvailabilityTracker = tracker(), + textProvider: PeerAvailabilityTextProvider = testTextProvider(), + isAppCurrentlyInBackground: () -> Boolean = { true }, + canPostNotifications: () -> Boolean = { true } + ): PeerAvailabilityNotifier { + return PeerAvailabilityNotifier( + context = context, + scope = scope, + tracker = availabilityTracker, + textProvider = textProvider, + isAppCurrentlyInBackground = isAppCurrentlyInBackground, + canPostNotifications = canPostNotifications + ) + } + + private fun testTextProvider(): PeerAvailabilityTextProvider { + return object : PeerAvailabilityTextProvider { + override fun title(): String = "Bitchatters nearby" + + override fun body(peerCount: Int): String = "$peerCount people around" + } + } +} + +private class InMemoryAlertHistory( + override var lastAlertAtMillis: Long? = null +) : PeerAvailabilityAlertHistory + +private class MutableClock( + private var currentMillis: Long = 1_000L +) { + fun now(): Long = currentMillis + + fun advance(durationMillis: Long) { + currentMillis += durationMillis + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt new file mode 100644 index 00000000..cd8bf40d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt @@ -0,0 +1,112 @@ +package com.bitchat.android.service + +import android.os.Build +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class TransportBridgeServiceTest { + private val targetId = "test-${UUID.randomUUID()}" + + @After + fun tearDown() { + TransportBridgeService.unregister(targetId) + } + + @Test + fun `bridged prepared plan retains exact payloads with decremented TTL`() { + var captured: RoutedPacket? = null + TransportBridgeService.register( + targetId, + object : TransportBridgeService.TransportLayer { + override fun send(packet: RoutedPacket) { + captured = packet + } + + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + captured = packet + return true + } + } + ) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = ByteArray(8) { 1 }, + recipientID = ByteArray(8) { 2 }, + timestamp = System.nanoTime().toULong(), + payload = byteArrayOf(3, 4, 5), + signature = ByteArray(64) { 6 }, + ttl = 7u + ) + val prepared = listOf( + packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(10)), + packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(11)) + ) + + TransportBridgeService.broadcast( + sourceId = "source-${UUID.randomUUID()}", + packet = RoutedPacket(packet, preparedPackets = prepared) + ) + + val forwarded = captured + assertNotNull(forwarded) + assertEquals(6u.toUByte(), forwarded!!.packet.ttl) + assertEquals(2, forwarded.preparedPackets?.size) + forwarded.preparedPackets!!.zip(prepared).forEach { (actual, original) -> + assertEquals(6u.toUByte(), actual.ttl) + assertTrue(actual.payload.contentEquals(original.payload)) + assertEquals(original.type, actual.type) + } + } + + @Test + fun `rejected bridge send remains eligible after transport reconnects`() = runTest { + var transportConnected = false + var attempts = 0 + TransportBridgeService.register( + targetId, + object : TransportBridgeService.TransportLayer { + override fun send(packet: RoutedPacket) = Unit + + override suspend fun sendAndReport(packet: RoutedPacket): Boolean { + attempts += 1 + return transportConnected + } + } + ) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = ByteArray(8) { 1 }, + recipientID = ByteArray(8) { 2 }, + timestamp = System.nanoTime().toULong(), + payload = byteArrayOf(3, 4, 5), + signature = ByteArray(64) { 6 }, + ttl = 7u + ) + val sourceId = "source-${UUID.randomUUID()}" + + assertFalse( + TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet)) + ) + transportConnected = true + assertTrue( + TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet)) + ) + assertEquals(2, attempts) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt new file mode 100644 index 00000000..893abe0e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -0,0 +1,391 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlinx.coroutines.runBlocking +import java.util.Date + +class AppStateStoreTest { + @Before + fun setUp() { + AppStateStore.resumePrivateConversationsAfterPanic() + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.resumePrivateConversationsAfterPanic() + AppStateStore.clear() + } + + @Test + fun `public timeline collapses request sync replay even when android message ids differ`() { + val timestamp = Date(1_700_000_000_000L) + val originalDelivery = BitchatMessage( + id = "random-id-from-first-delivery", + sender = "alice", + content = "hello from sync", + timestamp = timestamp, + senderPeerID = "1122334455667788" + ) + val requestSyncReplay = originalDelivery.copy(id = "different-random-id-from-replay") + + AppStateStore.addPublicMessage(originalDelivery) + AppStateStore.addPublicMessage(requestSyncReplay) + + assertEquals(listOf(originalDelivery), AppStateStore.publicMessages.value) + } + + @Test + fun `public timeline still keeps same content sent at different packet timestamps`() { + val first = BitchatMessage( + id = "first-packet-id", + sender = "alice", + content = "same text", + timestamp = Date(1_700_000_000_000L), + senderPeerID = "1122334455667788" + ) + val second = first.copy( + id = "second-packet-id", + timestamp = Date(first.timestamp.time + 1_000L) + ) + + AppStateStore.addPublicMessage(first) + AppStateStore.addPublicMessage(second) + + assertEquals(listOf(first, second), AppStateStore.publicMessages.value) + } + + @Test + fun `peer list merges transport updates instead of overwriting`() { + AppStateStore.setTransportPeers("WIFI", listOf("wifi-peer")) + AppStateStore.setTransportPeers("BLE", emptyList()) + + assertEquals(listOf("wifi-peer"), AppStateStore.peers.value) + + AppStateStore.setTransportPeers("BLE", listOf("ble-peer")) + + assertEquals(listOf("wifi-peer", "ble-peer"), AppStateStore.peers.value) + } + + @Test + fun `direct peers union across transports`() { + AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1", "shared")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "shared")) + + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.getDirectPeers() + ) + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.directPeers.value + ) + } + + @Test + fun `clearing one transport keeps the other transport direct peers`() { + AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1")) + + AppStateStore.clearTransportDirectPeers("WIFI") + + assertEquals(setOf("ble-1"), AppStateStore.getDirectPeers()) + } + + @Test + fun `latest direct peer set replaces previous set for same transport`() { + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "wifi-2")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-3")) + + assertEquals(setOf("wifi-3"), AppStateStore.getDirectPeers()) + } + + @Test + fun `private chat aliases merge into canonical peer`() { + val live = BitchatMessage(id = "live-message", sender = "alice", content = "live", timestamp = Date(1)) + val noise = BitchatMessage(id = "noise-message", sender = "alice", content = "noise", timestamp = Date(2)) + val nostr = BitchatMessage(id = "nostr-message", sender = "alice", content = "nostr", timestamp = Date(3)) + + AppStateStore.addPrivateMessage("live-peer", live) + AppStateStore.addPrivateMessage("noise-hex", noise) + AppStateStore.addPrivateMessage("nostr_alias", nostr) + + AppStateStore.unifyPrivateChatsIntoPeer("live-peer", listOf("noise-hex", "nostr_alias")) + + assertEquals(setOf("live-peer"), AppStateStore.privateMessages.value.keys) + assertEquals(listOf(live, noise, nostr), AppStateStore.privateMessages.value["live-peer"]) + } + + @Test + fun `noise hex private messages are stored under stable contact conversation`() { + val noiseKeyHex = "00".repeat(32) + val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32)) + val message = BitchatMessage(id = "noise-message", sender = "alice", content = "hello", timestamp = Date(1)) + + AppStateStore.addPrivateMessage(noiseKeyHex, message) + + assertEquals(setOf(contactID), AppStateStore.privateMessages.value.keys) + assertEquals(listOf(message), AppStateStore.privateMessages.value[contactID]) + } + + @Test + fun `live nickname updates retained conversation metadata`() { + val message = BitchatMessage( + id = "rename-message", + sender = "Alice Old", + content = "hello", + timestamp = Date(1), + isPrivate = true + ) + AppStateStore.addPrivateMessage("peer-a", message) + + AppStateStore.updatePrivateConversationDisplayNames( + mapOf("peer-a" to "Alice New") + ) + + assertEquals( + "Alice New", + AppStateStore.privateConversationDisplayNames.value.getValue("peer-a") + ) + } + + @Test + fun `restored unread and nickname state canonicalize with message aliases`() { + val noiseKeyHex = "02".repeat(32) + val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey( + ByteArray(32) { 2 } + ) + val message = BitchatMessage( + id = "restored-alias-message", + sender = "Alice", + content = "hello", + timestamp = Date(1), + isPrivate = true + ) + + AppStateStore.restorePrivateConversations( + PersistedConversationSnapshot( + chats = mapOf(noiseKeyHex to listOf(message)), + readMessageIDs = emptySet(), + arrivalOrder = listOf(message.id), + deletedMessageIDs = emptySet(), + displayNames = mapOf(noiseKeyHex to "Alice Renamed"), + unreadCounts = mapOf(noiseKeyHex to 1) + ) + ) + + assertEquals(setOf(contactID), AppStateStore.privateMessages.value.keys) + assertEquals(mapOf(contactID to 1), AppStateStore.unreadPrivateMessageCounts.value) + assertEquals( + mapOf(contactID to "Alice Renamed"), + AppStateStore.privateConversationDisplayNames.value + ) + } + + @Test + fun `canonicalized private chat history keeps arrival order when peer clocks differ`() { + val noiseKeyHex = "01".repeat(32) + val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 1 }) + val firstArrival = BitchatMessage( + id = "first-arrival", + sender = "alice", + content = "sent from a clock that is ahead", + timestamp = Date(3) + ) + val secondArrival = BitchatMessage( + id = "second-arrival", + sender = "alice", + content = "sent from a clock that is behind", + timestamp = Date(1) + ) + + AppStateStore.addPrivateMessage(contactID, firstArrival) + AppStateStore.addPrivateMessage(noiseKeyHex, secondArrival) + + assertEquals( + listOf(firstArrival, secondArrival), + AppStateStore.privateMessages.value[contactID] + ) + } + + @Test + fun `background receipt status persists and cannot be downgraded`() { + val message = BitchatMessage( + id = "outgoing-message", + sender = "bob", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = DeliveryStatus.Sending + ) + AppStateStore.addPrivateMessage("peer-a", message) + + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Delivered("peer-a", Date(2)) + ) + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Read("peer-a", Date(3)) + ) + AppStateStore.updatePrivateMessageStatus( + message.id, + DeliveryStatus.Delivered("peer-a", Date(4)) + ) + + val status = AppStateStore.privateMessages.value + .values + .flatten() + .single() + .deliveryStatus + assertTrue(status is DeliveryStatus.Read) + } + + @Test + fun `in flight message can become failed without overwriting confirmed delivery`() { + val sending = BitchatMessage( + id = "send-failure", + sender = "me", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = DeliveryStatus.Sending + ) + AppStateStore.addPrivateMessage("peer-a", sending) + + AppStateStore.updatePrivateMessageStatus( + sending.id, + DeliveryStatus.Failed("network unavailable") + ) + assertTrue( + AppStateStore.privateMessages.value + .getValue("peer-a") + .single() + .deliveryStatus is DeliveryStatus.Failed + ) + + AppStateStore.updatePrivateMessageStatus( + sending.id, + DeliveryStatus.Delivered("alice", Date(2)) + ) + AppStateStore.updatePrivateMessageStatus( + sending.id, + DeliveryStatus.Failed("late timeout") + ) + assertTrue( + AppStateStore.privateMessages.value + .getValue("peer-a") + .single() + .deliveryStatus is DeliveryStatus.Delivered + ) + } + + @Test + fun `closing a conversation releases full payload history but keeps its summary`() { + repeat(3) { index -> + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "history-$index", + sender = "alice", + content = "message $index", + timestamp = Date(index.toLong()), + isPrivate = true + ) + ) + } + + AppStateStore.releasePrivateConversationHistory("peer-a") + + assertEquals( + listOf("history-2"), + AppStateStore.privateMessages.value.getValue("peer-a").map { it.id } + ) + } + + @Test + fun `long lived process applies the same bounded private history policy`() { + AppStateStore.setNickname("me") + repeat(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION) { index -> + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "incoming-$index", + sender = "alice", + content = "message", + timestamp = Date(index.toLong()), + isPrivate = true + ) + ) + } + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "latest-outgoing", + sender = "me", + content = "latest", + timestamp = Date(Long.MAX_VALUE), + isPrivate = true + ) + ) + + val retained = AppStateStore.privateMessages.value.getValue("peer-a") + assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, retained.size) + assertEquals("incoming-1", retained.first().id) + assertEquals("latest-outgoing", retained.last().id) + } + + @Test + fun `private message admission is atomic and can durably classify known read messages`() { + val message = BitchatMessage( + id = "same-transport-message", + sender = "alice", + content = "hello", + timestamp = Date(1L), + isPrivate = true + ) + + assertTrue(AppStateStore.addPrivateMessage("peer-a", message, forceRead = true)) + assertFalse(AppStateStore.addPrivateMessage("peer-a", message)) + + assertEquals(1, AppStateStore.privateMessages.value.getValue("peer-a").size) + assertTrue(AppStateStore.isPrivateMessageRead(message.id)) + } + + @Test + fun `panic clear rejects private messages until explicitly resumed`() { + val beforePanic = BitchatMessage( + id = "before-panic", + sender = "alice", + content = "erase me", + timestamp = Date(1L), + isPrivate = true + ) + val duringPanic = beforePanic.copy(id = "during-panic") + val afterPanic = beforePanic.copy(id = "after-panic") + + assertTrue(AppStateStore.addPrivateMessage("peer-a", beforePanic)) + AppStateStore.updatePrivateConversationDisplayNames( + mapOf("peer-a" to "Alice") + ) + assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() }) + assertTrue(AppStateStore.privateMessages.value.isEmpty()) + assertTrue(AppStateStore.privateConversationDisplayNames.value.isEmpty()) + assertFalse(AppStateStore.addPrivateMessage("peer-a", duringPanic)) + + AppStateStore.resumePrivateConversationsAfterPanic() + + assertTrue(AppStateStore.addPrivateMessage("peer-a", afterPanic)) + assertEquals( + listOf(afterPanic), + AppStateStore.privateMessages.value.getValue("peer-a") + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt new file mode 100644 index 00000000..e16996f1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.services + +import android.content.Context +import android.os.Build +import com.bitchat.android.identity.SecureIdentityStateManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class ContactDirectoryTest { + + private lateinit var identityManager: SecureIdentityStateManager + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences( + "contact-directory-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) + ContactDirectory.initialize(context) { null } + ContactDirectory.identityManagerProvider = { identityManager } + } + + @After + fun tearDown() { + ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } + } + + @Test + fun `offline contact resolves display name from cached fingerprint nickname`() { + val fingerprint = "ab".repeat(32) + identityManager.cacheFingerprintNickname(fingerprint, "Alice") + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertEquals("Alice", resolution.displayName) + assertNull(resolution.meshPeerID) + } + + @Test + fun `offline contact without cached nickname has no display name`() { + val fingerprint = "cd".repeat(32) + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertNull(resolution.displayName) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt new file mode 100644 index 00000000..ce3eedbd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt @@ -0,0 +1,76 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.ChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class ConversationAliasResolverTest { + private lateinit var scope: CoroutineScope + private lateinit var state: ChatState + + @Before + fun setUp() { + AppStateStore.clear() + scope = CoroutineScope(SupervisorJob()) + state = ChatState(scope) + } + + @After + fun tearDown() { + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `explicit alias merge restores global order for interleaved arrivals`() { + val firstArrival = BitchatMessage( + id = "alias-a-first", + sender = "alice", + content = "first arrival through alias A", + timestamp = Date(300) + ) + val secondArrival = BitchatMessage( + id = "alias-b-first", + sender = "alice", + content = "second arrival through alias B", + timestamp = Date(100) + ) + val thirdArrival = BitchatMessage( + id = "alias-a-second", + sender = "alice", + content = "third arrival through alias A", + timestamp = Date(200) + ) + AppStateStore.addPrivateMessage("target-peer", firstArrival) + AppStateStore.addPrivateMessage("source-alias", secondArrival) + AppStateStore.addPrivateMessage("target-peer", thirdArrival) + state.setPrivateChats( + linkedMapOf( + "target-peer" to listOf(firstArrival, thirdArrival), + "source-alias" to listOf(secondArrival) + ) + ) + + ConversationAliasResolver.unifyChatsIntoPeer( + state = state, + targetPeerID = "target-peer", + keysToMerge = listOf("source-alias") + ) + + assertEquals( + listOf(firstArrival, secondArrival, thirdArrival), + state.getPrivateChatsValue()["target-peer"] + ) + assertEquals( + listOf(firstArrival, secondArrival, thirdArrival), + AppStateStore.privateMessages.value["target-peer"] + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt new file mode 100644 index 00000000..65f484a8 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationDatabaseTest.kt @@ -0,0 +1,589 @@ +package com.bitchat.android.services + +import android.content.ContentValues +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.UUID +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class ConversationDatabaseTest { + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var database: ConversationDatabase + private lateinit var storageCipher: InMemoryConversationStorageCipher + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "conversation-test-${UUID.randomUUID()}.db" + storageCipher = InMemoryConversationStorageCipher() + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) + } + + @After + fun tearDown() { + database.close() + context.deleteDatabase(databaseName) + } + + @Test + fun `message fields read state and delivery status survive reopen`() { + val message = BitchatMessage( + id = "round-trip", + sender = "alice", + content = "photo path", + type = BitchatMessageType.Image, + timestamp = Date(123_456L), + isRelay = true, + originalSender = "alice-original", + isPrivate = true, + recipientNickname = "me", + senderPeerID = "0123456789abcdef", + mentions = listOf("me", "bob"), + channel = "private", + encryptedContent = byteArrayOf(1, 2, 3), + isEncrypted = true, + deliveryStatus = DeliveryStatus.Delivered("me", Date(123_999L)), + senderNostrPubkey = "a".repeat(64) + ) + + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice", "0123456789abcdef"), + displayName = "alice", + message = message, + isRead = false + ) + database.markRead(message.id) + database.updateDeliveryStatus( + message.id, + DeliveryStatus.Read("me", Date(124_000L)) + ) + database.close() + + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) + val restored = database.loadSnapshot() + val restoredMessage = restored.chats.getValue("contact_alice").single() + + assertEquals(message.id, restoredMessage.id) + assertEquals(message.sender, restoredMessage.sender) + assertEquals(message.content, restoredMessage.content) + assertEquals(message.type, restoredMessage.type) + assertEquals(message.timestamp, restoredMessage.timestamp) + assertEquals(message.mentions, restoredMessage.mentions) + assertEquals(message.senderNostrPubkey, restoredMessage.senderNostrPubkey) + assertArrayEquals(message.encryptedContent, restoredMessage.encryptedContent) + assertEquals( + DeliveryStatus.Read("me", Date(124_000L)), + restoredMessage.deliveryStatus + ) + assertEquals(setOf(message.id), restored.readMessageIDs) + assertEquals(listOf(message.id), restored.arrivalOrder) + assertEquals("alice", restored.displayNames.getValue("contact_alice")) + } + + @Test + fun `aliases merge into one conversation and duplicate transport delivery stays unique`() { + val first = message("first", "alice", 100L) + val second = message("second", "alice", 200L) + + database.upsertMessage("mesh-alias", setOf("mesh-alias"), "alice", first, false) + database.upsertMessage("nostr_alias", setOf("nostr_alias"), "alice", second, false) + database.mergeAliases( + targetConversationID = "contact_alice", + aliases = setOf("mesh-alias", "nostr_alias", "contact_alice") + ) + val duplicate = database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "nostr_alias"), + null, + first, + false + ) + + val restored = database.loadSnapshot() + assertFalse(duplicate.inserted) + assertEquals(setOf("contact_alice"), restored.chats.keys) + assertEquals(listOf("first", "second"), restored.chats.getValue("contact_alice").map { it.id }) + assertEquals("alice", restored.displayNames.getValue("contact_alice")) + } + + @Test + fun `conversation deletion cascades messages while a later message starts fresh`() { + database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "contact_alice"), + "alice", + message("old", "alice", 100L), + false + ) + + database.deleteConversation( + "contact_alice", + setOf("mesh-alias", "contact_alice") + ) + assertTrue(database.loadSnapshot().chats.isEmpty()) + + database.close() + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) + database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "contact_alice"), + "alice", + message("old", "alice", 100L), + false + ) + assertTrue(database.loadSnapshot().chats.isEmpty()) + + database.upsertMessage( + "contact_alice", + setOf("mesh-alias", "contact_alice"), + "alice", + message("new", "alice", 200L), + false + ) + val restored = database.loadSnapshot() + assertEquals(listOf("new"), restored.chats.getValue("contact_alice").map { it.id }) + assertFalse(restored.readMessageIDs.contains("old")) + assertTrue(restored.deletedMessageIDs.contains("old")) + } + + @Test + fun `per conversation retention keeps the newest bounded history`() { + val total = ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION + 5 + repeat(total) { index -> + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice"), + displayName = "alice", + message = message("message-$index", "alice", index.toLong()), + isRead = true + ) + } + + val snapshot = database.loadSnapshot() + val messages = snapshot.chats.getValue("contact_alice") + assertEquals(ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION, messages.size) + assertEquals("message-5", messages.first().id) + assertEquals("message-${total - 1}", messages.last().id) + assertEquals( + (0 until 5).mapTo(mutableSetOf()) { "message-$it" }, + snapshot.deletedMessageIDs + ) + } + + @Test + fun `global retention is hard bounded when every conversation has one message`() { + database.close() + context.deleteDatabase(databaseName) + database = ConversationDatabase( + context = context, + databaseName = databaseName, + maxMessagesPerConversation = 10, + maxMessagesTotal = 3, + maxPayloadBytes = Long.MAX_VALUE, + storageCipher = storageCipher + ) + repeat(4) { index -> + database.upsertMessage( + conversationID = "peer-$index", + aliases = setOf("peer-$index"), + displayName = "peer $index", + message = message("single-$index", "peer $index", index.toLong()), + isRead = true + ) + } + + database.pruneToRetentionLimits() + + val snapshot = database.loadSnapshot() + assertEquals(3, snapshot.chats.values.sumOf { it.size }) + assertFalse(snapshot.chats.containsKey("peer-0")) + assertTrue(snapshot.deletedMessageIDs.contains("single-0")) + } + + @Test + fun `initial snapshot decrypts only latest row while preserving exact unread count`() { + repeat(3) { index -> + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice"), + displayName = "alice", + message = message("summary-$index", "alice", index.toLong()), + isRead = index == 2 + ) + } + + val initial = database.loadInitialSnapshot() + + assertEquals(listOf("summary-2"), initial.chats.getValue("contact_alice").map { it.id }) + assertEquals(2, initial.unreadCounts.getValue("contact_alice")) + assertEquals(setOf("summary-2"), initial.readMessageIDs) + assertEquals(3, database.loadConversation("contact_alice").arrivalOrder.size) + } + + @Test + fun `nickname metadata updates without requiring a new message`() { + database.upsertMessage( + conversationID = "mesh-alias", + aliases = setOf("mesh-alias"), + displayName = "Alice Old", + message = message("rename-message", "Alice Old", 1L), + isRead = true + ) + + database.updateConversationIdentity( + conversationID = "contact_alice", + aliases = setOf("mesh-alias", "contact_alice"), + displayName = "Alice New" + ) + database.close() + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) + + val restored = database.loadInitialSnapshot() + + assertEquals(setOf("contact_alice"), restored.chats.keys) + assertEquals("Alice New", restored.displayNames.getValue("contact_alice")) + assertEquals("Alice Old", restored.chats.getValue("contact_alice").single().sender) + } + + @Test + fun `sensitive message and display fields are encrypted and legacy columns are scrubbed`() { + val secret = "unique-secret-${UUID.randomUUID()}" + database.upsertMessage( + conversationID = "contact_alice", + aliases = setOf("contact_alice"), + displayName = "display-$secret", + message = BitchatMessage( + id = "encrypted-row", + sender = "sender-$secret", + content = "content-$secret", + timestamp = Date(42L), + isPrivate = true, + recipientNickname = "recipient-$secret" + ), + isRead = false + ) + + database.readableDatabase.query( + "private_messages", + arrayOf("sender", "content", "recipient_nickname", "payload_ciphertext"), + "message_id = ?", + arrayOf("encrypted-row"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("", cursor.getString(0)) + assertEquals("", cursor.getString(1)) + assertNull(cursor.getString(2)) + val envelope = cursor.getBlob(3) + assertFalse(envelope.toString(Charsets.ISO_8859_1).contains(secret)) + val plaintext = storageCipher.decrypt( + envelope, + "message:encrypted-row".toByteArray() + ).toString(Charsets.UTF_8) + assertTrue(plaintext.contains(secret)) + } + database.readableDatabase.query( + "conversations", + arrayOf("display_name", "display_name_ciphertext"), + "conversation_id = ?", + arrayOf("contact_alice"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertNull(cursor.getString(0)) + assertNotNull(cursor.getBlob(1)) + } + } + + @Test + fun `media byte retention reports only orphaned app-owned attachments`() { + database.close() + context.deleteDatabase(databaseName) + database = ConversationDatabase( + context = context, + databaseName = databaseName, + maxMessagesPerConversation = 10, + maxMessagesTotal = 10, + maxPayloadBytes = Long.MAX_VALUE, + maxMediaBytes = 4, + storageCipher = storageCipher + ) + val first = File(context.filesDir, "conversation-first-${UUID.randomUUID()}.bin") + .apply { writeBytes(byteArrayOf(1, 2, 3, 4)) } + val second = File(context.filesDir, "conversation-second-${UUID.randomUUID()}.bin") + .apply { writeBytes(byteArrayOf(5, 6, 7, 8)) } + try { + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("media-1", "alice", 1L).copy( + type = BitchatMessageType.File, + content = first.absolutePath + ), + true + ) + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("media-2", "alice", 2L).copy( + type = BitchatMessageType.File, + content = second.absolutePath + ), + true + ) + + val orphaned = database.pruneToRetentionLimits() + + assertEquals(setOf(first.canonicalPath), orphaned) + assertEquals(listOf("media-2"), database.loadSnapshot().arrivalOrder) + } finally { + first.delete() + second.delete() + } + } + + @Test + fun `shared attachment path is counted once and retained while referenced`() { + database.close() + context.deleteDatabase(databaseName) + database = ConversationDatabase( + context = context, + databaseName = databaseName, + maxMessagesPerConversation = 10, + maxMessagesTotal = 10, + maxPayloadBytes = Long.MAX_VALUE, + maxMediaBytes = 4, + storageCipher = storageCipher + ) + val shared = File(context.filesDir, "conversation-shared-${UUID.randomUUID()}.bin") + .apply { writeBytes(byteArrayOf(1, 2, 3, 4)) } + try { + repeat(2) { index -> + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("shared-$index", "alice", index.toLong()).copy( + type = BitchatMessageType.File, + content = shared.absolutePath + ), + true + ) + } + + assertTrue(database.pruneToRetentionLimits().isEmpty()) + assertEquals(2, database.loadSnapshot().arrivalOrder.size) + } finally { + shared.delete() + } + } + + @Test + fun `panic clear rotates the storage key before erasing rows`() { + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("before-panic", "alice", 1L), + true + ) + val envelope = database.readableDatabase.query( + "private_messages", + arrayOf("payload_ciphertext"), + "message_id = ?", + arrayOf("before-panic"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getBlob(0) + } + + database.clearAll() + + assertTrue(database.loadSnapshot().chats.isEmpty()) + assertThrows(Exception::class.java) { + storageCipher.decrypt(envelope, "message:before-panic".toByteArray()) + } + } + + @Test + fun `destroy storage deletes database files so history cannot reload`() { + database.upsertMessage( + "contact_alice", + setOf("contact_alice"), + "alice", + message("survives-failure", "alice", 1L), + true + ) + assertTrue(context.databaseList().contains(databaseName)) + + database.destroyStorage() + + assertFalse(context.databaseList().contains(databaseName)) + } + + @Test + fun `version one plaintext database migrates without losing history`() { + database.close() + context.deleteDatabase(databaseName) + createVersionOneDatabase( + conversationID = "legacy-contact", + messageID = "legacy-message", + sender = "legacy-alice", + content = "legacy-content" + ) + + database = ConversationDatabase(context, databaseName, storageCipher = storageCipher) + val restored = database.loadSnapshot() + + assertEquals("legacy-content", restored.chats.getValue("legacy-contact").single().content) + database.readableDatabase.query( + "private_messages", + arrayOf("sender", "content", "payload_ciphertext", "received_at"), + "message_id = ?", + arrayOf("legacy-message"), + null, + null, + null + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("", cursor.getString(0)) + assertEquals("", cursor.getString(1)) + assertNotNull(cursor.getBlob(2)) + assertEquals(123L, cursor.getLong(3)) + } + } + + private fun createVersionOneDatabase( + conversationID: String, + messageID: String, + sender: String, + content: String + ) { + context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null).use { db -> + db.execSQL( + """ + CREATE TABLE conversations ( + conversation_id TEXT COLLATE NOCASE PRIMARY KEY NOT NULL, + display_name TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE conversation_aliases ( + alias TEXT COLLATE NOCASE PRIMARY KEY NOT NULL, + conversation_id TEXT COLLATE NOCASE NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE private_messages ( + arrival_sequence INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE NOT NULL, + conversation_id TEXT COLLATE NOCASE NOT NULL, + sender TEXT NOT NULL, + content TEXT NOT NULL, + message_type INTEGER NOT NULL, + sent_at INTEGER NOT NULL, + is_relay INTEGER NOT NULL, + original_sender TEXT, + is_private INTEGER NOT NULL, + recipient_nickname TEXT, + sender_peer_id TEXT, + mentions_json TEXT, + channel_name TEXT, + encrypted_content BLOB, + is_encrypted INTEGER NOT NULL, + delivery_type INTEGER NOT NULL, + delivery_text TEXT, + delivery_at INTEGER, + delivery_reached INTEGER, + delivery_total INTEGER, + sender_nostr_pubkey TEXT, + is_read INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + db.execSQL( + "CREATE TABLE deleted_private_messages (" + + "message_id TEXT PRIMARY KEY NOT NULL, deleted_at INTEGER NOT NULL)" + ) + db.insertOrThrow( + "conversations", + null, + ContentValues().apply { + put("conversation_id", conversationID) + put("display_name", sender) + put("created_at", 1L) + put("updated_at", 1L) + } + ) + db.insertOrThrow( + "conversation_aliases", + null, + ContentValues().apply { + put("alias", conversationID) + put("conversation_id", conversationID) + } + ) + db.insertOrThrow( + "private_messages", + null, + ContentValues().apply { + put("message_id", messageID) + put("conversation_id", conversationID) + put("sender", sender) + put("content", content) + put("message_type", BitchatMessageType.Message.ordinal) + put("sent_at", 123L) + put("is_relay", 0) + put("is_private", 1) + put("is_encrypted", 0) + put("delivery_type", 2) + put("is_read", 0) + } + ) + db.version = 1 + } + } + + private fun message(id: String, sender: String, timestamp: Long) = BitchatMessage( + id = id, + sender = sender, + content = "hello-$id", + timestamp = Date(timestamp), + isPrivate = true, + senderPeerID = "0123456789abcdef" + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt new file mode 100644 index 00000000..48928ef4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationListPreferencesTest.kt @@ -0,0 +1,110 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.identity.SecureIdentityStateManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class ConversationListPreferencesTest { + private lateinit var context: Context + private lateinit var preferencesName: String + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + preferencesName = "conversation-list-${UUID.randomUUID()}" + ContactDirectory.initialize(context) { null } + } + + @After + fun tearDown() { + ContactDirectory.initialize(context) { null } + } + + @Test + fun `pin mute and draft persist while conversation removal clears all three`() { + val first = preferences() + first.togglePinned("CONTACT_ALICE") + first.toggleMuted("contact_alice") + first.setDraft("contact_alice", "unfinished reply") + + val restored = preferences() + assertTrue(restored.isPinned("contact_alice")) + assertTrue(restored.isMuted("CONTACT_ALICE")) + assertTrue(restored.draftFor("contact_alice") == "unfinished reply") + + restored.removeConversation("contact_alice") + val afterDelete = preferences() + assertFalse(afterDelete.isPinned("contact_alice")) + assertFalse(afterDelete.isMuted("contact_alice")) + assertNull(afterDelete.draftFor("contact_alice")) + } + + @Test + fun `draft persistence is globally bounded and panic clear is durable`() { + val preferences = preferences() + repeat(60) { index -> + preferences.setDraft("peer-$index", "x".repeat(3_000)) + } + + assertTrue(preferences.drafts.value.size <= 50) + assertTrue(preferences.drafts.value.values.sumOf(String::length) <= 128_000) + assertNull(preferences.draftFor("peer-0")) + assertTrue(preferences.draftFor("peer-59")?.isNotEmpty() == true) + + preferences.togglePinned("peer-59") + preferences.toggleMuted("peer-59") + preferences.clearAll() + + val afterPanic = preferences() + assertTrue(afterPanic.pinned.value.isEmpty()) + assertTrue(afterPanic.muted.value.isEmpty()) + assertTrue(afterPanic.drafts.value.isEmpty()) + } + + @Test + fun `identity canonicalization preserves pin mute and draft state`() { + val peerID = "1122334455667788" + val contactID = "contact_alice" + val beforeIdentityResolution = preferences(canonicalize = { it }) + beforeIdentityResolution.togglePinned(peerID) + beforeIdentityResolution.toggleMuted(peerID) + beforeIdentityResolution.setDraft(peerID, "unfinished reply") + + val afterIdentityResolution = preferences( + canonicalize = { value -> + if (value.equals(peerID, ignoreCase = true)) contactID else value + } + ) + afterIdentityResolution.canonicalizeAliases() + + assertEquals(setOf(contactID), afterIdentityResolution.pinned.value) + assertEquals(setOf(contactID), afterIdentityResolution.muted.value) + assertEquals( + mapOf(contactID to "unfinished reply"), + afterIdentityResolution.drafts.value + ) + } + + private fun preferences( + canonicalize: (String) -> String = ContactDirectory::canonicalConversationId + ): ConversationListPreferences { + val sharedPreferences = + context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + return ConversationListPreferences( + stateManager = SecureIdentityStateManager(sharedPreferences, testOnly = true), + testOnly = true, + canonicalize = canonicalize + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt new file mode 100644 index 00000000..0d5947c9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationRepositoryTest.kt @@ -0,0 +1,113 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +class ConversationRepositoryTest { + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var repository: ConversationRepository + private val executor = Executors.newSingleThreadExecutor() + private val dispatcher = executor.asCoroutineDispatcher() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "conversation-repository-test-${UUID.randomUUID()}.db" + } + + @After + fun tearDown() { + if (::repository.isInitialized) repository.closeForTest() + dispatcher.close() + context.deleteDatabase(databaseName) + } + + @Test + fun `reload restores persisted history after initial process restore`() { + repository = ConversationRepository( + context = context, + dispatcher = dispatcher, + databaseName = databaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + val message = BitchatMessage( + id = "persisted-message", + sender = "alice", + content = "survives restart", + timestamp = Date(100L), + isPrivate = true + ) + repository.upsertMessage( + conversationID = "peer-alice", + aliases = setOf("peer-alice"), + displayName = "alice", + message = message, + isRead = true + ) + runBlocking { repository.awaitPendingWrites() } + + val initialSnapshot = AtomicReference() + repository.initialize(initialSnapshot::set) + runBlocking { repository.awaitPendingWrites() } + assertEquals( + listOf(message), + initialSnapshot.get().chats.getValue("peer-alice") + ) + + val reloadedSnapshot = AtomicReference() + repository.reload(reloadedSnapshot::set) + runBlocking { repository.awaitPendingWrites() } + assertEquals( + listOf(message), + reloadedSnapshot.get().chats.getValue("peer-alice") + ) + } + + @Test + fun `panic clear drains queued writes and leaves database empty`() { + repository = ConversationRepository( + context = context, + dispatcher = dispatcher, + databaseName = databaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + repository.upsertMessage( + conversationID = "peer-alice", + aliases = setOf("peer-alice"), + displayName = "alice", + message = BitchatMessage( + id = "queued-before-panic", + sender = "alice", + content = "must be erased", + timestamp = Date(100L), + isPrivate = true + ), + isRead = true + ) + + assertTrue(runBlocking { repository.clearAllAndWait() }) + + val snapshot = AtomicReference() + repository.reload(snapshot::set) + runBlocking { repository.awaitPendingWrites() } + assertTrue(snapshot.get().chats.isEmpty()) + assertTrue(snapshot.get().readMessageIDs.isEmpty()) + assertTrue(snapshot.get().deletedMessageIDs.isEmpty()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt b/app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt new file mode 100644 index 00000000..ee93be23 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/InMemoryConversationStorageCipher.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.services + +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal class InMemoryConversationStorageCipher : ConversationStorageCipher { + private var key: SecretKey = generateKey() + + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(associatedData) + return byteArrayOf(1) + cipher.iv + cipher.doFinal(plaintext) + } + + override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray { + require(envelope.size > 13 && envelope[0] == 1.toByte()) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(128, envelope.copyOfRange(1, 13)) + ) + cipher.updateAAD(associatedData) + return cipher.doFinal(envelope.copyOfRange(13, envelope.size)) + } + + override fun destroyKey() { + key = generateKey() + } + + private fun generateKey(): SecretKey = + KeyGenerator.getInstance("AES").apply { + init(256, SecureRandom()) + }.generateKey() +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt new file mode 100644 index 00000000..a430eb9a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt @@ -0,0 +1,293 @@ +package com.bitchat.android.services + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class IncomingMessageAdmissionTest { + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var repository: ConversationRepository + private val repositoriesToClose = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "admission-test-${UUID.randomUUID()}.db" + repository = ConversationRepository( + context = context, + dispatcher = Dispatchers.Unconfined, + databaseName = databaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + repositoriesToClose += repository + AppStateStore.setConversationRepositoryForTest(repository) + AppStateStore.resumePrivateConversationsAfterPanic() + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.resumePrivateConversationsAfterPanic() + AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(null) + repositoriesToClose.forEach(ConversationRepository::closeForTest) + context.deleteDatabase(databaseName) + context.deleteDatabase("failing-$databaseName") + } + + @Test + fun `private message rejected during panic cannot continue transport dispatch`() { + assertTrue(runBlocking { AppStateStore.panicClearPrivateConversations() }) + + assertFalse( + IncomingMessageAdmission.admitToAppState( + privateMessage(id = "during-panic") + ) + ) + assertTrue(AppStateStore.privateMessages.value.isEmpty()) + } + + @Test + fun `duplicate private transport delivery is rejected before downstream effects`() { + val message = privateMessage(id = "same-message-over-two-transports") + + assertTrue(IncomingMessageAdmission.admitToAppState(message)) + assertFalse(IncomingMessageAdmission.admitToAppState(message)) + } + + @Test + fun `older retained replay is rejected after summary-only restart`() { + val older = privateMessage(id = "older-retained") + val latest = privateMessage(id = "latest-summary").copy(timestamp = Date(2L)) + assertTrue(IncomingMessageAdmission.admitToAppState(older)) + assertTrue(IncomingMessageAdmission.admitToAppState(latest)) + + AppStateStore.clear() + repository.reload { snapshot -> + AppStateStore.restorePrivateConversations(snapshot) + } + runBlocking { repository.awaitPendingWrites() } + assertEquals( + listOf(latest.id), + AppStateStore.privateMessages.value.getValue("peer-a").map { it.id } + ) + + assertFalse(IncomingMessageAdmission.admitToAppState(older)) + assertEquals( + listOf(latest.id), + AppStateStore.privateMessages.value.getValue("peer-a").map { it.id } + ) + } + + @Test + fun `delivery receipt persists after older message is unloaded from memory`() { + val older = privateMessage(id = "older-outgoing").copy( + sender = "me", + senderPeerID = "self", + recipientNickname = "alice", + deliveryStatus = DeliveryStatus.Sent + ) + val latest = privateMessage(id = "latest-summary").copy(timestamp = Date(2L)) + assertTrue(AppStateStore.addPrivateMessage("peer-a", older, forceRead = true)) + assertTrue(IncomingMessageAdmission.admitToAppState(latest)) + runBlocking { repository.awaitPendingWrites() } + + AppStateStore.releasePrivateConversationHistory("peer-a") + assertEquals( + listOf(latest.id), + AppStateStore.privateMessages.value.getValue("peer-a").map { it.id } + ) + + val delivered = DeliveryStatus.Delivered(to = "alice", at = Date(3L)) + AppStateStore.updatePrivateMessageStatus(older.id, delivered) + runBlocking { repository.awaitPendingWrites() } + + val snapshot = runBlocking { + repository.loadConversationAndWait("peer-a") + } + assertEquals( + delivered, + snapshot?.chats?.getValue("peer-a") + ?.single { it.id == older.id } + ?.deliveryStatus + ) + } + + @Test + fun `public and channel messages preserve best effort admission`() { + val public = BitchatMessage( + id = "public", + sender = "alice", + content = "hello", + timestamp = Date(1L) + ) + val channel = public.copy(id = "channel", channel = "#mesh") + + assertTrue(IncomingMessageAdmission.admitToAppState(public)) + assertTrue(IncomingMessageAdmission.admitToAppState(channel)) + assertTrue(AppStateStore.publicMessages.value.contains(public)) + } + + @Test + fun `outgoing callback runs only after local echo survives process state clear`() { + val state = ChatState(TestScope()) + state.setNickname("me") + val manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(context), + noiseSessionDelegate = testNoiseDelegate() + ) + var callbackInvoked = false + + val sent = runBlocking { + manager.sendPrivateMessageDurably( + content = "durable outgoing", + peerID = "peer-a", + recipientNickname = "alice", + senderNickname = "me", + myPeerID = "self" + ) { _, _, _, _ -> + callbackInvoked = true + } + } + + assertTrue(sent) + assertTrue(callbackInvoked) + AppStateStore.clear() + repository.reload { snapshot -> + assertEquals( + "durable outgoing", + snapshot.chats.getValue("peer-a").single().content + ) + } + runBlocking { repository.awaitPendingWrites() } + } + + @Test + fun `outgoing messages to different peers keep separate persisted conversations`() { + val state = ChatState(TestScope()) + state.setNickname("me") + val manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(context), + noiseSessionDelegate = testNoiseDelegate() + ) + + runBlocking { + assertTrue( + manager.sendPrivateMessageDurably( + content = "only for alice", + peerID = "peer-a", + recipientNickname = "alice", + senderNickname = "me", + myPeerID = "self" + ) { _, _, _, _ -> } + ) + assertTrue( + manager.sendPrivateMessageDurably( + content = "only for bob", + peerID = "peer-b", + recipientNickname = "bob", + senderNickname = "me", + myPeerID = "self" + ) { _, _, _, _ -> } + ) + + val alice = repository.loadConversationAndWait("peer-a") + val bob = repository.loadConversationAndWait("peer-b") + + assertEquals( + listOf("only for alice"), + alice?.chats?.getValue("peer-a")?.map { it.content } + ) + assertEquals( + listOf("only for bob"), + bob?.chats?.getValue("peer-b")?.map { it.content } + ) + } + } + + @Test + fun `outgoing callback is suppressed when durable storage fails`() { + val failingRepository = ConversationRepository( + context = context, + dispatcher = Dispatchers.Unconfined, + databaseName = "failing-$databaseName", + storageCipher = object : ConversationStorageCipher { + override fun encrypt( + plaintext: ByteArray, + associatedData: ByteArray + ): ByteArray = error("storage unavailable") + + override fun decrypt( + envelope: ByteArray, + associatedData: ByteArray + ): ByteArray = error("storage unavailable") + + override fun destroyKey() = Unit + } + ) + repositoriesToClose += failingRepository + AppStateStore.setConversationRepositoryForTest(failingRepository) + val state = ChatState(TestScope()) + val manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(context), + noiseSessionDelegate = testNoiseDelegate() + ) + var callbackInvoked = false + + val sent = runBlocking { + manager.sendPrivateMessageDurably( + "lost", + "peer-a", + "alice", + "me", + "self" + ) { _, _, _, _ -> callbackInvoked = true } + } + + assertFalse(sent) + assertFalse(callbackInvoked) + assertTrue(state.getPrivateChatsValue().isEmpty()) + } + + private fun privateMessage(id: String) = BitchatMessage( + id = id, + sender = "alice", + content = "secret", + timestamp = Date(1L), + isPrivate = true, + senderPeerID = "peer-a" + ) + + private fun testNoiseDelegate() = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String) = false + override fun initiateHandshake(peerID: String) = Unit + override fun getMyPeerID() = "self" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt new file mode 100644 index 00000000..48cbe2da --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -0,0 +1,217 @@ +package com.bitchat.android.services + +import android.content.Context +import android.os.Build +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageRouterTest { + + private val myPeerID = "1111222233334444" + private val peerID = "aaaabbbbccccdddd" + private val noiseKey = ByteArray(32) { 0x0B } + + private lateinit var mesh: MeshService + private lateinit var router: MessageRouter + private var fakeTime = 1_000_000L + private val expired = mutableListOf() + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences( + "message-router-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + val identityManager = SecureIdentityStateManager(prefs, testOnly = true) + ContactDirectory.identityManagerProvider = { identityManager } + + mesh = mock() + whenever(mesh.myPeerID).thenReturn(myPeerID) + whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "peer")) + + ContactDirectory.initialize(context) { mesh } + + MessageRouter.disableSchedulerForTesting = true + MessageRouter.resetForTesting() + fakeTime = 1_000_000L + expired.clear() + + router = MessageRouter.getInstance(context, mesh) + router.clock = { fakeTime } + router.onMessageExpired = { expired.add(it) } + } + + @After + fun tearDown() { + MessageRouter.resetForTesting() + MessageRouter.disableSchedulerForTesting = false + ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } + } + + @Test + fun `queued message flushes after peer returns and session establishes`() { + peerOffline() + val result = router.sendPrivate("hello", peerID, "peer", "msg-1") + + assertEquals(MessageRouter.RouteResult.QUEUED, result) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + verify(mesh, never()).initiateNoiseHandshake(any()) + + // Peer reappears without a session: handshake kicked immediately + peerConnectedNoSession() + router.onPeersUpdated(listOf(peerID)) + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + + // Session established: queued message is sent + peerReady() + router.onSessionEstablished(peerID) + verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-1") + } + + @Test + fun `scheduler retries handshake with capped backoff`() { + peerConnectedNoSession() + val result = router.sendPrivate("hello", peerID, "peer", "msg-1") + assertEquals(MessageRouter.RouteResult.QUEUED, result) + verify(mesh, times(1)).initiateNoiseHandshake(peerID) // immediate kick at enqueue + clearInvocations(mesh) + + router.tickOutbox() // backoff (5s) not yet elapsed + verify(mesh, never()).initiateNoiseHandshake(any()) + + fakeTime += 6_000 + router.tickOutbox() // attempt 2, next in 15s + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + + fakeTime += 7_000 + router.tickOutbox() // too early + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + + fakeTime += 9_000 + router.tickOutbox() // attempt 3, next in 30s + verify(mesh, times(2)).initiateNoiseHandshake(peerID) + + fakeTime += 31_000 + router.tickOutbox() // attempt 4, next in 60s + verify(mesh, times(3)).initiateNoiseHandshake(peerID) + + fakeTime += 61_000 + router.tickOutbox() // attempt 5, capped at 60s + verify(mesh, times(4)).initiateNoiseHandshake(peerID) + } + + @Test + fun `expired entries are dropped and reported`() { + peerOffline() + router.sendPrivate("old message", peerID, "peer", "msg-old") + + fakeTime += 86_400_001L + router.tickOutbox() + + assertEquals(listOf("msg-old"), expired) + + // Nothing left to flush even when the peer becomes reachable + peerReady() + router.tickOutbox() + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + } + + @Test + fun `outbox cap evicts oldest and preserves order`() { + peerOffline() + repeat(101) { i -> + router.sendPrivate("content-$i", peerID, "peer", "msg-$i") + } + + assertEquals(listOf("msg-0"), expired) + + peerReady() + router.onSessionEstablished(peerID) + verify(mesh, times(100)).sendPrivateMessage(any(), eq(peerID), any(), any()) + verify(mesh, times(1)).sendPrivateMessage("content-1", peerID, "peer", "msg-1") + verify(mesh, times(1)).sendPrivateMessage("content-100", peerID, "peer", "msg-100") + verify(mesh, never()).sendPrivateMessage(eq("content-0"), any(), any(), anyOrNull()) + } + + @Test + fun `peer reappearance without pending messages does not kick handshake`() { + peerConnectedNoSession() + router.onPeersUpdated(listOf(peerID)) + verify(mesh, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `established session flushes directly without handshake retry state`() { + peerReady() + val result = router.sendPrivate("direct", peerID, "peer", "msg-direct") + assertEquals(MessageRouter.RouteResult.MESH, result) + verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct") + verify(mesh, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `scheduler stops with the mesh service and restarts on rebind`() { + MessageRouter.disableSchedulerForTesting = false + MessageRouter.resetForTesting() + val context = RuntimeEnvironment.getApplication() + val running = MessageRouter.getInstance(context, mesh) + assertTrue(running.isSchedulerRunning) + + running.stopOutboxScheduler() + assertFalse(running.isSchedulerRunning) + + val rebound = MessageRouter.getInstance(context, mesh) + assertTrue(rebound.isSchedulerRunning) + } + + private fun peerOffline() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = false)) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false) + } + + private fun peerConnectedNoSession() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true)) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false) + } + + private fun peerReady() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true)) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true) + } + + private fun peerInfo(isConnected: Boolean) = PeerInfo( + id = peerID, + nickname = "peer", + isConnected = isConnected, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = ByteArray(32) { 0x0A }, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt new file mode 100644 index 00000000..feecea6a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt @@ -0,0 +1,141 @@ +package com.bitchat.android.services.meshgraph + +import org.junit.Assert.* +import org.junit.Test +import org.junit.Before + +class MeshGraphServiceTest { + + private lateinit var service: MeshGraphService + + @Before + fun setUp() { + // Use the test-only API to reset the singleton state safely + MeshGraphService.resetForTesting() + service = MeshGraphService.getInstance() + } + + @Test + fun testUpdateFromAnnouncement_AddsNeighbors() { + val origin = "PeerA" + val neighbors = listOf("PeerB", "PeerC") + val timestamp = 100UL + + service.updateFromAnnouncement(origin, "Alice", neighbors, timestamp) + + val snapshot = service.graphState.value + // Verify nodes + assertTrue(snapshot.nodes.any { it.peerID == "PeerA" }) + assertTrue(snapshot.nodes.any { it.peerID == "PeerB" }) + assertTrue(snapshot.nodes.any { it.peerID == "PeerC" }) + + // Verify edges (unconfirmed because B and C haven't announced A) + // A -> B + val edgeAB = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") } + assertNotNull(edgeAB) + assertFalse(edgeAB!!.isConfirmed) + assertEquals("PeerA", edgeAB.confirmedBy) + + // A -> C + val edgeAC = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") } + assertNotNull(edgeAC) + assertFalse(edgeAC!!.isConfirmed) + assertEquals("PeerA", edgeAC.confirmedBy) + } + + @Test + fun testUpdateFromAnnouncement_NewerTimestampReplacesNeighbors() { + val origin = "PeerA" + + // Initial state: A -> {B, C} + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL) + + // Update: A -> {B, D} (newer timestamp) + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerD"), 200UL) + + val snapshot = service.graphState.value + + // Verify Edge A-B exists + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }) + + // Verify Edge A-D exists + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") }) + + // Verify Edge A-C does NOT exist + assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") }) + } + + @Test + fun testUpdateFromAnnouncement_OlderTimestampIsIgnored() { + val origin = "PeerA" + + // Initial state: A -> {B, C} at ts=200 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL) + + // Old Update: A -> {D} at ts=100 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerD"), 100UL) + + val snapshot = service.graphState.value + + // Should still be {B, C} + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }) + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") }) + assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") }) + } + + @Test + fun testUpdateFromAnnouncement_NullNeighborsClearsList_TheFix() { + val origin = "PeerA" + + // Initial state: A -> {B, C} at ts=100 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL) + + // Update with NULL neighbors (omitted TLV) at ts=200 + service.updateFromAnnouncement(origin, "Alice", null, 200UL) + + val snapshot = service.graphState.value + + // All edges from A should be gone + val edgesFromA = snapshot.edges.filter { it.a == "PeerA" || it.b == "PeerA" } + assertTrue("Edges from PeerA should be empty after null update", edgesFromA.isEmpty()) + + // Nodes B and C might still exist if they were added to the node list, but connected edges are gone. + // Actually, publishSnapshot collects nodes from nicknames and announcements. + // Since we provided nicknames for PeerA, it should be there. + // PeerB and PeerC were only in announcements. Since A's announcement is cleared, and B/C never announced, + // they might disappear from the node list if 'nicknames' doesn't contain them. + // Let's check edges primarily as that's what routing cares about. + } + + @Test + fun testUpdateFromAnnouncement_NullNeighborsWithOlderTimestampIsIgnored() { + val origin = "PeerA" + + // Initial state: A -> {B, C} at ts=200 + service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL) + + // Old Update with NULL neighbors at ts=100 + service.updateFromAnnouncement(origin, "Alice", null, 100UL) + + val snapshot = service.graphState.value + + // Should still be {B, C} because the null update was older + assertFalse(snapshot.edges.isEmpty()) + assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }) + } + + @Test + fun removePeer_RemovesInboundAndOutboundGraphReferences() { + service.updateFromAnnouncement("PeerA", "Alice", listOf("PeerB"), 100UL) + service.updateFromAnnouncement("PeerB", "Bob", listOf("PeerA", "PeerC"), 100UL) + service.updateFromAnnouncement("PeerC", "Carol", listOf("PeerB"), 100UL) + + service.removePeer("PeerB") + + val snapshot = service.graphState.value + assertFalse(snapshot.nodes.any { it.peerID == "PeerB" }) + assertFalse(snapshot.edges.any { it.a == "PeerB" || it.b == "PeerB" }) + assertTrue(snapshot.nodes.any { it.peerID == "PeerA" }) + assertTrue(snapshot.nodes.any { it.peerID == "PeerC" }) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt new file mode 100644 index 00000000..3a9bfb62 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt @@ -0,0 +1,86 @@ +package com.bitchat.android.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class GCSFilterTest { + + @Test + fun testGCSFilterBasic() { + val random = Random(42) + val ids = List(20) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Build filter with plenty of bytes (no trimming) + val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01) + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + for (id in ids) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + assertTrue("Filter should contain all encoded IDs", GCSFilter.contains(sorted, nonZeroV)) + } + } + + @Test + fun testGCSFilterWithTrimming() { + val random = Random(42) + // 50 IDs + val ids = List(50) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Force trimming by setting maxBytes to a very small value (e.g., 20 bytes) + val maxBytes = 20 + val params = GCSFilter.buildFilter(ids, maxBytes = maxBytes, targetFpr = 0.01) + + // Ensure some trimming actually happened + assertTrue("Params data size should be <= maxBytes", params.data.size <= maxBytes) + + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + // Let's verify that the first trimmedN elements in ids are all matched + val trimmedN = (params.m ushr params.p).toInt() + assertTrue("At least some elements should have been encoded", trimmedN > 0) + + val retainedIds = ids.take(trimmedN) + for (id in retainedIds) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + assertTrue("Retained ID should be found in filter", GCSFilter.contains(sorted, nonZeroV)) + } + } + + @Test + fun testGCSFilterHandlesCollisionsAndZeroBucket() { + val random = Random(42) + // Generate a large number of IDs to guarantee collisions (mapping to the same bucket) and some zero-bucket mapping + val ids = List(200) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Build GCS filter - this should complete successfully without throwing repeat count exceptions or negative-count crashes + val params = GCSFilter.buildFilter(ids, maxBytes = 100, targetFpr = 0.05) + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + // Verify elements are successfully stored and found (including those mapping to 0) + var foundCount = 0 + for (id in ids) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + if (GCSFilter.contains(sorted, nonZeroV)) { + foundCount++ + } + } + assertTrue("Should successfully decode and find elements after deduplication", foundCount > 0) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt new file mode 100644 index 00000000..4351b7c0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt @@ -0,0 +1,278 @@ +package com.bitchat.android.ui + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.R +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.ApkDownloadFailureReason +import com.bitchat.android.util.GitHubReleaseClient +import com.bitchat.android.util.LatestReleaseProvider +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ApkDownloadViewModelTest { + private lateinit var application: Application + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + application = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `local apk is shareable before release metadata starts`() = runTest { + val manager = managerWithLocalApk() + val downloader = FakeDownloader() + lateinit var viewModel: ApkDownloadViewModel + val statusWhenMetadataStarted = CompletableDeferred() + val metadata = object : LatestReleaseProvider { + override suspend fun latestRelease(): Result { + statusWhenMetadataStarted.complete(viewModel.state.value.apkStatus) + return Result.failure(IllegalStateException("synthetic offline response")) + } + } + viewModel = ApkDownloadViewModel(application, manager, downloader, metadata) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + val ready = awaitReady(viewModel) + + assertEquals("1.7.5", ready.version) + assertTrue(statusWhenMetadataStarted.await() is ApkPreparationStatus.Ready) + } + + @Test + fun `notification cancellation returning idle restores shareable fallback`() = runTest { + val manager = managerWithLocalApk() + val downloader = FakeDownloader() + val viewModel = ApkDownloadViewModel( + application, + manager, + downloader, + offlineMetadata() + ) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + val originalReady = awaitReady(viewModel) + viewModel.onEvent(ApkUiEvent.PrepareRowClicked) + viewModel.onEvent(ApkUiEvent.ConfirmDownload) + + val downloading = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading + assertSame(originalReady, downloading.shareableFallback) + assertEquals(1, downloader.startCount) + + downloader.emit(ApkDownloader.DownloadState.Idle) + assertEquals(originalReady, awaitReady(viewModel)) + } + + @Test + fun `rate limits remain stable failures without countdowns`() = runTest { + val manager = mock() + whenever(manager.getCachedApkInfo()).thenReturn(null) + val downloader = FakeDownloader() + val viewModel = ApkDownloadViewModel( + application, + manager, + downloader, + offlineMetadata() + ) + + downloader.emit( + ApkDownloader.DownloadState.Failed( + reason = ApkDownloadFailureReason.RateLimited, + messageArgs = listOf("GitHub Releases"), + resumablePercent = null + ) + ) + + val failure = awaitError(viewModel).failure + assertEquals(R.string.prepare_apk_error_rate_limited, failure.messageRes) + assertEquals(listOf("GitHub Releases"), failure.messageArgs) + } + + @Test + fun `non-rate failures keep their own message`() = runTest { + val manager = mock() + whenever(manager.getCachedApkInfo()).thenReturn(null) + val downloader = FakeDownloader() + val viewModel = ApkDownloadViewModel( + application, + manager, + downloader, + offlineMetadata() + ) + + downloader.emit( + ApkDownloader.DownloadState.Failed( + reason = ApkDownloadFailureReason.AllSourcesFailed, + messageArgs = emptyList(), + resumablePercent = null + ) + ) + + val failure = awaitError(viewModel).failure + assertEquals(R.string.prepare_apk_error_all_sources, failure.messageRes) + assertEquals(emptyList(), failure.messageArgs) + } + + @Test + fun `a download already running when the ViewModel starts still exposes the local apk`() = + runTest { + // Process death during a transfer leaves WorkManager running and the ViewModel fresh, + // so the observer builds Downloading out of Loading and has no Ready to carry. Without + // a fallback the row and both sharing actions vanish for the rest of the download. + val manager = managerWithLocalApk() + val downloader = FakeDownloader( + ApkDownloader.DownloadState.Downloading( + progressPercent = 30, + phase = ApkDownloader.DownloadPhase.Transferring + ) + ) + + val viewModel = ApkDownloadViewModel( + application, + manager, + downloader, + offlineMetadata() + ) + + val restored = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading + assertNull(restored.shareableFallback) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + + val adopted = awaitFallback(viewModel) + assertEquals("1.7.5", adopted.version) + assertEquals(UniversalApkManager.ApkSource.INSTALLED, adopted.source) + } + + @Test + fun `adopting a local apk never displaces the fallback a download already carries`() = runTest { + val manager = managerWithLocalApk() + val downloader = FakeDownloader() + val viewModel = ApkDownloadViewModel( + application, + manager, + downloader, + offlineMetadata() + ) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + val originalReady = awaitReady(viewModel) + viewModel.onEvent(ApkUiEvent.PrepareRowClicked) + viewModel.onEvent(ApkUiEvent.ConfirmDownload) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + + val downloading = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading + assertSame(originalReady, downloading.shareableFallback) + } + + + private suspend fun awaitFallback( + viewModel: ApkDownloadViewModel + ): ApkPreparationStatus.Ready = withTimeout(5_000L) { + while (true) { + (viewModel.state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?.let { return@withTimeout it } + delay(1L) + } + error("unreachable") + } + + private fun offlineMetadata() = object : LatestReleaseProvider { + override suspend fun latestRelease(): Result = + Result.failure(IllegalStateException("synthetic offline response")) + } + + private suspend fun managerWithLocalApk(): UniversalApkManager { + val manager = mock() + whenever(manager.prepareLocalApkInfo()).thenReturn( + UniversalApkManager.ApkInfo( + version = "1.7.5", + downloadDate = 1_700_000_000_000L, + size = 12L * 1024 * 1024, + file = File(application.cacheDir, "synthetic-shareable.apk"), + source = UniversalApkManager.ApkSource.INSTALLED, + variant = ShareableApkVariant.UNIVERSAL, + downloadSourceId = null + ) + ) + whenever(manager.getPartialDownloadProgress()).thenReturn(null) + return manager + } + + private suspend fun awaitReady( + viewModel: ApkDownloadViewModel + ): ApkPreparationStatus.Ready = withTimeout(5_000L) { + while (true) { + (viewModel.state.value.apkStatus as? ApkPreparationStatus.Ready) + ?.let { return@withTimeout it } + delay(1L) + } + error("unreachable") + } + + private suspend fun awaitError( + viewModel: ApkDownloadViewModel + ): ApkPreparationStatus.Error = withTimeout(5_000L) { + while (true) { + (viewModel.state.value.apkStatus as? ApkPreparationStatus.Error) + ?.let { return@withTimeout it } + delay(1L) + } + error("unreachable") + } + + private class FakeDownloader( + initial: ApkDownloader.DownloadState = ApkDownloader.DownloadState.Idle + ) : ApkDownloader { + private val mutableState = MutableStateFlow(initial) + override val downloadState = mutableState.asStateFlow() + var startCount = 0 + + override fun startDownload() { + startCount += 1 + mutableState.value = ApkDownloader.DownloadState.Downloading( + progressPercent = 0, + phase = ApkDownloader.DownloadPhase.SelectingSource + ) + } + + override fun cancelDownload() = Unit + + fun emit(state: ApkDownloader.DownloadState) { + mutableState.value = state + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt new file mode 100644 index 00000000..2e6128a1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/ConversationSummaryTest.kt @@ -0,0 +1,269 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.services.PrivateMessageArrivalOrder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class ConversationSummaryTest { + @After + fun tearDown() { + PrivateMessageArrivalOrder.clear() + } + + @Test + fun `read offline conversation remains present`() { + val message = incoming("message", "alice", 100L) + PrivateMessageArrivalOrder.record(message.id) + + val conversations = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf("alice-peer" to listOf(message)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ) + + assertEquals(1, conversations.size) + assertEquals("alice-peer", conversations.single().conversationID) + assertEquals(0, conversations.single().unreadCount) + assertFalse(conversations.single().isConnected) + } + + @Test + fun `canonical aliases yield one conversation with unique messages`() { + val first = incoming("first", "alice", 300L) + val second = incoming("second", "alice", 100L) + PrivateMessageArrivalOrder.record(first.id) + PrivateMessageArrivalOrder.record(second.id) + + val conversations = buildConversationSummaries( + unreadConversationIDs = setOf("mesh-alias"), + privateChats = mapOf( + "mesh-alias" to listOf(first), + "nostr_alias" to listOf(first, second) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { "contact_alice" }, + isMessageRead = { false } + ) + + assertEquals(1, conversations.size) + assertEquals(2, conversations.single().unreadCount) + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + conversations.single().identityAliases + ) + assertEquals("alice", conversations.single().displayName) + assertEquals(DirectMessageTransport.NOSTR, conversations.single().transport) + } + + @Test + fun `online conversations sort before newer offline conversations`() { + val offline = summary( + id = "offline", + isConnected = false, + unreadCount = 3, + activity = 300L + ) + val onlineRead = summary( + id = "online-read", + isConnected = true, + unreadCount = 0, + activity = 100L + ) + val onlineUnread = summary( + id = "online-unread", + isConnected = true, + unreadCount = 1, + activity = 50L + ) + + assertEquals( + listOf("online-unread", "online-read", "offline"), + sortConversationSummaries(listOf(offline, onlineRead, onlineUnread)) + .map { it.conversationID } + ) + } + + @Test + fun `preview follows local arrival order instead of remote timestamp`() { + val first = incoming( + id = "first", + sender = "alice", + timestamp = 900L, + content = "older arrival" + ) + val second = incoming( + id = "second", + sender = "alice", + timestamp = 100L, + content = " latest\nmessage " + ) + PrivateMessageArrivalOrder.record(first.id) + PrivateMessageArrivalOrder.record(second.id) + + val conversation = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf("alice-peer" to listOf(first, second)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ).single() + + assertEquals("latest message", conversation.latestMessagePreview) + assertEquals(BitchatMessageType.Message, conversation.latestMessageType) + } + + @Test + fun `file preview contains filename without local path`() { + val message = incoming( + id = "file", + sender = "alice", + timestamp = 100L, + content = "/private/conversations/quarterly report.pdf", + type = BitchatMessageType.File + ) + + val conversation = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf("alice-peer" to listOf(message)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ).single() + + assertEquals("quarterly report.pdf", conversation.latestMessagePreview) + assertEquals(BitchatMessageType.File, conversation.latestMessageType) + } + + @Test + fun `case variants cannot create duplicate conversations or misclassify outgoing messages`() { + val outgoing = incoming( + id = "outgoing", + sender = "Me", + timestamp = 200L, + content = "sent by me" + ) + + val conversations = buildConversationSummaries( + unreadConversationIDs = emptySet(), + privateChats = mapOf( + "CONTACT_ALICE" to listOf(outgoing), + "contact_alice" to listOf(outgoing) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { true } + ) + + assertEquals(1, conversations.size) + assertEquals(setOf("contact_alice"), conversations.single().identityAliases) + assertEquals(true, conversations.single().latestMessageIsOutgoing) + } + + @Test + fun `outgoing messages remain outgoing after local nickname changes`() { + val outgoing = incoming( + id = "outgoing-before-rename", + sender = "old nickname", + timestamp = 200L, + content = "sent before rename" + ).copy(senderPeerID = "my-stable-peer-id") + + val conversation = buildConversationSummaries( + unreadConversationIDs = setOf("contact_alice"), + privateChats = mapOf("contact_alice" to listOf(outgoing)), + currentUserIdentifiers = setOf("new nickname", "my-stable-peer-id"), + canonicalize = { it }, + isMessageRead = { false }, + persistedUnreadCounts = emptyMap() + ).single() + + assertTrue(conversation.latestMessageIsOutgoing) + assertEquals(0, conversation.unreadCount) + } + + @Test + fun `connected nickname overrides cached conversation names`() { + assertEquals( + "Alice Renamed", + resolveConversationDisplayName( + fallbackName = "Alice From Message", + connectedPeerID = "peer-a", + peerNicknames = mapOf("peer-a" to "Alice Renamed"), + resolvedContactName = "Alice Favorite Snapshot", + persistedDisplayName = "Alice Persisted" + ) + ) + } + + @Test + fun `persisted nickname is used when no live or contact name remains`() { + assertEquals( + "Alice Persisted", + resolveConversationDisplayName( + fallbackName = "Alice From Message", + connectedPeerID = null, + peerNicknames = emptyMap(), + resolvedContactName = null, + persistedDisplayName = "Alice Persisted" + ) + ) + } + + @Test + fun `summary-only startup uses persisted unread count`() { + val latest = incoming("latest", "alice", 300L) + + val conversation = buildConversationSummaries( + unreadConversationIDs = setOf("contact_alice"), + privateChats = mapOf("contact_alice" to listOf(latest)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { false }, + persistedUnreadCounts = mapOf("contact_alice" to 47) + ).single() + + assertEquals(47, conversation.unreadCount) + } + + private fun incoming( + id: String, + sender: String, + timestamp: Long, + content: String = "hello", + type: BitchatMessageType = BitchatMessageType.Message + ) = BitchatMessage( + id = id, + sender = sender, + content = content, + type = type, + timestamp = Date(timestamp), + isPrivate = true + ) + + private fun summary( + id: String, + isConnected: Boolean, + unreadCount: Int, + activity: Long + ) = ConversationSummary( + conversationID = id, + displayName = id, + unreadCount = unreadCount, + latestMessageAt = activity, + latestActivityOrder = activity, + latestMessageType = BitchatMessageType.Message, + latestMessagePreview = "hello", + transport = DirectMessageTransport.MESH, + nostrPubkey = null, + identityAliases = setOf(id), + isConnected = isConnected + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/LanguageCatalogContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/LanguageCatalogContractTest.kt new file mode 100644 index 00000000..b8720e19 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/LanguageCatalogContractTest.kt @@ -0,0 +1,72 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class LanguageCatalogContractTest { + private val resourcesDirectory = File("src/main/res") + + @Test + fun `locale config contains every shipped locale exactly once`() { + val shippedLanguageTags = resourcesDirectory + .listFiles() + .orEmpty() + .filter { it.isDirectory && it.name.startsWith("values-") } + .mapNotNull { directory -> directory.name.toLanguageTagOrNull() } + .toSet() + + val configuredLanguageTags = Regex("""android:name="([^"]+)"""") + .findAll(File(resourcesDirectory, "xml/locales_config.xml").readText()) + .map { match -> match.groupValues[1] } + .toList() + + assertEquals( + "Locale configuration must not contain duplicate entries", + configuredLanguageTags.toSet().size, + configuredLanguageTags.size, + ) + assertEquals(shippedLanguageTags, configuredLanguageTags.toSet()) + } + + @Test + fun `language picker strings exist in every locale pack`() { + val requiredKeys = setOf( + "about_language", + "about_app_language", + "about_system_default", + "about_select_language", + ) + + resourcesDirectory + .listFiles() + .orEmpty() + .filter { directory -> + directory.isDirectory && + (directory.name == "values" || directory.name.toLanguageTagOrNull() != null) + } + .forEach { directory -> + val strings = File(directory, "strings.xml").readText() + requiredKeys.forEach { key -> + assertTrue( + "$key is missing from ${directory.name}/strings.xml", + """name="$key""" in strings, + ) + } + } + } + + private fun String.toLanguageTagOrNull(): String? { + if (!startsWith("values-") || this == "values-night") return null + return removePrefix("values-") + .split("-") + .joinToString("-") { qualifier -> + if (qualifier.startsWith("r") && qualifier.length == 3) { + qualifier.drop(1) + } else { + qualifier + } + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt new file mode 100644 index 00000000..51204f30 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -0,0 +1,461 @@ +package com.bitchat.android.ui + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.mesh.PreparedPrivateMediaTransfer +import com.bitchat.android.mesh.PrivateMediaPreparation +import com.bitchat.android.mesh.PrivateMediaWireMode +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactIdentityResolver +import com.bitchat.android.services.ConversationRepository +import com.bitchat.android.services.InMemoryConversationStorageCipher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class MediaSendingManagerMigrationTest { + private val peerID = "8877665544332211" + private lateinit var state: ChatState + private lateinit var mesh: MeshService + private lateinit var manager: MediaSendingManager + private lateinit var file: File + private lateinit var conversationRepository: ConversationRepository + private lateinit var conversationDatabaseName: String + + @Before + fun setup() { + state = ChatState(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) + state.setNickname("me") + conversationDatabaseName = "media-send-${UUID.randomUUID()}.db" + conversationRepository = ConversationRepository( + context = org.robolectric.RuntimeEnvironment.getApplication(), + dispatcher = Dispatchers.Unconfined, + databaseName = conversationDatabaseName, + storageCipher = InMemoryConversationStorageCipher() + ) + AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(conversationRepository) + mesh = mock() + whenever(mesh.myPeerID).thenReturn("0011223344556677") + whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "old peer")) + manager = MediaSendingManager( + state, + MessageManager(state), + mock(), + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mediaWorkDispatcher = Dispatchers.Unconfined, + getMeshService = { mesh } + ) + file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply { + writeBytes(ByteArray(128) { it.toByte() }) + } + } + + @After + fun tearDown() { + AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(null) + conversationRepository.closeForTest() + org.robolectric.RuntimeEnvironment.getApplication() + .deleteDatabase(conversationDatabaseName) + file.delete() + } + + @Test + fun `preflight rejection creates only a visible failure and no file echo or send`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.Rejected("too many fragments")) + + manager.sendImageNote(peerID, null, file.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(1, messages.size) + assertTrue(messages.single().content.contains("too many fragments")) + assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + verify(mesh, never()).sendFilePrivate(any(), any()) + } + + @Test + fun `private preparation runs on the configured media worker`() { + val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "private-media-test-worker") + } + val dispatcher = executor.asCoroutineDispatcher() + try { + val preparationThread = AtomicReference() + val prepared = CountDownLatch(1) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { + preparationThread.set(Thread.currentThread().name) + prepared.countDown() + PrivateMediaPreparation.Rejected("test complete") + } + val asynchronousManager = MediaSendingManager( + state, + MessageManager(state), + mock(), + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mediaWorkDispatcher = dispatcher, + getMeshService = { mesh } + ) + + asynchronousManager.sendImageNote(peerID, null, file.absolutePath) + + assertTrue(prepared.await(5, TimeUnit.SECONDS)) + assertTrue(preparationThread.get().contains("private-media-test-worker")) + } finally { + dispatcher.close() + executor.shutdownNow() + } + } + + @Test + fun `pinned downgrade rejection is visible without a file echo or send`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn( + PrivateMediaPreparation.Rejected( + "Encrypted private media was previously pinned, but this session proved no support; send blocked" + ) + ) + + manager.sendImageNote(peerID, null, file.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(1, messages.size) + assertTrue(messages.single().content.contains("previously pinned")) + assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + verify(mesh, never()).sendFilePrivate(any(), any()) + } + + @Test + fun `missing Noise session retains first send and commits after proof resolution`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.NeedsHandshake) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + + manager.retryPendingPrivateMedia(peerID) + + assertEquals(1, commits.get()) + assertEquals(1, state.privateChats.value[peerID]?.size) + verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + verify(mesh, never()).sendFilePrivate(any(), any()) + } + + @Test + fun `contact conversation resolves to live mesh peer for voice image and file sends`() { + val noisePublicKey = ByteArray(32) { (it + 1).toByte() } + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(noisePublicKey) + whenever(mesh.getPeerInfo(peerID)).thenReturn( + PeerInfo( + id = peerID, + nickname = "old peer", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noisePublicKey, + signingPublicKey = null, + isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) + ) + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + val genericFile = kotlin.io.path.createTempFile("private-media", ".txt").toFile().apply { + writeText("private attachment") + } + try { + manager.sendVoiceNote(conversationID, null, file.absolutePath) + manager.sendImageNote(conversationID, null, file.absolutePath) + manager.sendFileNote(conversationID, null, genericFile.absolutePath) + + assertEquals(3, commits.get()) + assertEquals( + listOf(BitchatMessageType.Audio, BitchatMessageType.Image, BitchatMessageType.File), + state.privateChats.value[conversationID].orEmpty().map { it.type } + ) + verify(mesh, times(3)) + .prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + verify(mesh, never()) + .prepareFilePrivate(eq(conversationID), any(), any(), any()) + } finally { + genericFile.delete() + } + } + + @Test + fun `mesh policy callback retries contact conversation using live peer ID`() { + val noisePublicKey = ByteArray(32) { (it + 1).toByte() } + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(noisePublicKey) + whenever(mesh.getPeerInfo(peerID)).thenReturn( + PeerInfo( + id = peerID, + nickname = "old peer", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noisePublicKey, + signingPublicKey = null, + isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) + ) + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.NeedsHandshake) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendVoiceNote(conversationID, null, file.absolutePath) + manager.retryPendingPrivateMedia(peerID) + + assertEquals(1, commits.get()) + assertEquals(BitchatMessageType.Audio, state.privateChats.value[conversationID]?.single()?.type) + verify(mesh).initiateNoiseHandshake(peerID) + verify(mesh, never()).initiateNoiseHandshake(conversationID) + } + + @Test + fun `awaiting peer state retains send and watchdog resolution offers legacy consent`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.AwaitingPeerState) + .thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning")) + + manager.sendImageNote(peerID, null, file.absolutePath) + + verify(mesh, never()).initiateNoiseHandshake(peerID) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + + manager.retryPendingPrivateMedia(peerID) + + assertNotNull(manager.legacyPrivateMediaConsent.value) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + } + + @Test + fun `reentrant proof resolution during preparation cannot lose first send intent`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { + manager.retryPendingPrivateMedia(peerID) + PrivateMediaPreparation.AwaitingPeerState + } + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + assertEquals(1, commits.get()) + assertEquals(1, state.privateChats.value[peerID]?.size) + verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + } + + @Test + fun `legacy consent is one shot rechecks policy and echoes only after approval`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning")) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(true))) + .thenAnswer { invocation -> + val transferId = invocation.getArgument(2) + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = transferId, + // Simulate the capability becoming authenticated while + // the consent dialog was open: recheck must upgrade. + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + val request = manager.legacyPrivateMediaConsent.value + assertNotNull(request) + + manager.approveLegacyPrivateMedia(request!!.requestId) + manager.approveLegacyPrivateMedia(request.requestId) + + assertEquals(1, commits.get()) + assertEquals(1, state.privateChats.value[peerID]?.size) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(true)) + } + + @Test + fun `prepared transfer ID mismatch aborts before local echo or commit`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn( + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = "wrong-transfer-id", + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + ) + + manager.sendImageNote(peerID, null, file.absolutePath) + + assertEquals(0, commits.get()) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + } + + @Test + fun `failed prepared commit keeps a retryable failed local file echo`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + false + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(2, messages.size) + val fileEcho = messages.single { it.type == BitchatMessageType.Image } + assertTrue(fileEcho.deliveryStatus is DeliveryStatus.Failed) + assertTrue( + messages.single { it.sender == "system" } + .content.contains("could not be committed") + ) + } + + @Test + fun `oversized file failure is posted to the private conversation and nothing is sent`() { + val bigFile = kotlin.io.path.createTempFile("oversized-private", ".jpg").toFile() + try { + bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 }) + + manager.sendImageNote(peerID, null, bigFile.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(1, messages.size) + assertTrue(messages.single().content.contains("too large")) + assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + assertTrue(state.getMessagesValue().none { it.content.contains("too large") }) + verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any()) + } finally { + bigFile.delete() + } + } + + @Test + fun `oversized file failure is posted to the channel and nothing is sent`() { + val bigFile = kotlin.io.path.createTempFile("oversized-channel", ".jpg").toFile() + try { + bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 }) + + manager.sendImageNote(null, "#test", bigFile.absolutePath) + + val channelMessages = state.getChannelMessagesValue()["#test"].orEmpty() + assertEquals(1, channelMessages.size) + assertTrue(channelMessages.single().content.contains("too large")) + assertTrue(state.getMessagesValue().none { it.content.contains("too large") }) + verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any()) + } finally { + bigFile.delete() + } + } + + @Test + fun `cancelled consent cannot later send or echo`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning")) + + manager.sendImageNote(peerID, null, file.absolutePath) + val request = manager.legacyPrivateMediaConsent.value!! + manager.cancelLegacyPrivateMedia(request.requestId) + manager.approveLegacyPrivateMedia(request.requestId) + + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + verify(mesh, never()).prepareFilePrivate(eq(peerID), any(), any(), eq(true)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt new file mode 100644 index 00000000..1bd4f61c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -0,0 +1,199 @@ +package com.bitchat.android.ui + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Date +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class MeshDelegateHandlerStateContractTest { + private lateinit var state: ChatState + private lateinit var messages: MessageManager + private lateinit var channels: ChannelManager + private lateinit var privateChats: PrivateChatManager + private lateinit var notifications: NotificationManager + private lateinit var mesh: MeshService + private lateinit var handler: MeshDelegateHandler + private lateinit var haptics: AtomicInteger + private lateinit var locallyReadMessageIDs: MutableList + + @Before + fun setUp() { + val scope = TestScope(UnconfinedTestDispatcher()) + state = ChatState(scope) + state.setNickname("Résumé") + messages = MessageManager(state) + channels = mock() + privateChats = mock() + notifications = mock() + mesh = mock() + haptics = AtomicInteger() + locallyReadMessageIDs = mutableListOf() + handler = MeshDelegateHandler( + state = state, + messageManager = messages, + channelManager = channels, + privateChatManager = privateChats, + notificationManager = notifications, + coroutineScope = scope, + onHapticFeedback = { haptics.incrementAndGet() }, + getMyPeerID = { "self" }, + getMeshService = { mesh }, + markMessageReadLocally = locallyReadMessageIDs::add + ) + } + + @Test + fun `peer arrival deduplicates list and final departure restores disconnected state`() { + handler.didUpdatePeerList(listOf("peer-a", "peer-a", "peer-b")) + + assertEquals(listOf("peer-a", "peer-b"), state.connectedPeers.value) + assertTrue(state.isConnected.value) + verify(channels).cleanupDisconnectedMembers(listOf("peer-a", "peer-b"), "self") + + handler.didUpdatePeerList(emptyList()) + + assertTrue(state.connectedPeers.value.isEmpty()) + assertFalse(state.isConnected.value) + } + + @Test + fun `delivery and read callbacks advance visible status monotonically`() { + val outgoing = message( + id = "outgoing", + sender = "me", + deliveryStatus = DeliveryStatus.Sending + ) + state.setMessages(listOf(outgoing)) + + handler.didReceiveDeliveryAck("outgoing", "peer-a") + assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Delivered) + + handler.didReceiveReadReceipt("outgoing", "peer-a") + assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read) + + handler.didReceiveDeliveryAck("outgoing", "peer-a") + assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read) + } + + @Test + fun `focused private message schedules receipt and records local read independently`() { + val peerID = "1122334455667788" + val incoming = BitchatMessage( + id = "focused-private-message", + sender = "alice", + content = "hello", + timestamp = Date(1), + isPrivate = true, + senderPeerID = peerID + ) + whenever(notifications.getAppBackgroundState()).thenReturn(false) + whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID) + whenever(mesh.getPeerInfo(peerID)).thenReturn( + PeerInfo( + id = peerID, + nickname = "alice", + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(32) { 1 }, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true) + + handler.didReceiveMessage(incoming) + + verify(mesh).sendReadReceipt(incoming.id, peerID, "Résumé") + assertEquals(listOf(incoming.id), locallyReadMessageIDs) + } + + @Test + fun `focused private message remains locally read when transport is disconnected`() { + val peerID = "1122334455667788" + val incoming = BitchatMessage( + id = "focused-private-message-offline", + sender = "alice", + content = "hello", + timestamp = Date(1), + isPrivate = true, + senderPeerID = peerID + ) + whenever(notifications.getAppBackgroundState()).thenReturn(false) + whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID) + whenever(mesh.getPeerInfo(peerID)).thenReturn(null) + + handler.didReceiveMessage(incoming) + + verify(mesh, never()).sendReadReceipt(any(), any(), any()) + assertEquals(listOf(incoming.id), locallyReadMessageIDs) + } + + @Test + fun `unicode mention notifies once and duplicate transport delivery is suppressed`() { + val incoming = message( + id = "incoming", + sender = "alice", + content = "hello @résumé", + senderPeerID = "peer-a" + ) + + handler.didReceiveMessage(incoming) + handler.didReceiveMessage(incoming) + + assertEquals(1, haptics.get()) + verify(notifications, times(1)).showMeshMentionNotification( + senderNickname = eq("alice"), + messageContent = eq("hello @résumé"), + senderPeerID = eq("peer-a") + ) + } + + @Test + fun `channel inbound increments unread only when conversation is not focused`() { + state.setJoinedChannels(setOf("#room")) + val incoming = message(id = "channel-1", channel = "#room") + + handler.didReceiveMessage(incoming) + assertEquals(1, state.unreadChannelMessages.value["#room"]) + + state.setCurrentChannel("#room") + handler.didReceiveMessage(incoming.copy(id = "channel-2")) + assertEquals(1, state.unreadChannelMessages.value["#room"]) + } + + private fun message( + id: String, + sender: String = "alice", + content: String = id, + senderPeerID: String? = null, + channel: String? = null, + deliveryStatus: DeliveryStatus? = null + ) = BitchatMessage( + id = id, + sender = sender, + content = content, + timestamp = Date(1), + senderPeerID = senderPeerID, + channel = channel, + deliveryStatus = deliveryStatus + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt new file mode 100644 index 00000000..7d8051de --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt @@ -0,0 +1,109 @@ +package com.bitchat.android.ui + +import com.bitchat.android.R +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The row body and its trailing icon button are two doors into the same actions, and the trailing + * buttons no longer carry visible labels. If this mapping is wrong the affordance simply vanishes, + * so each status is pinned down here rather than left to the composable. + */ +class PrepareRowTapActionTest { + + private fun ready( + variant: ShareableApkVariant, + source: UniversalApkManager.ApkSource = UniversalApkManager.ApkSource.INSTALLED + ) = ApkPreparationStatus.Ready( + version = "1.7.5", + sizeMB = 12, + source = source, + variant = variant + ) + + private fun error() = ApkPreparationStatus.Error( + ApkFailureMessage(messageRes = R.string.prepare_apk_error_generic) + ) + + @Test + fun `an arm64-only build offers the universal download`() { + // The only entry point besides the trailing icon, which has no label to explain itself. + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ready(ShareableApkVariant.ARM64)) + ) + } + + @Test + fun `a standalone installed universal apk can optionally be replaced from github`() { + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL)) + ) + } + + @Test + fun `a current downloaded universal apk leaves the row inert`() { + assertNull( + prepareRowTapAction( + ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED) + ) + ) + } + + @Test + fun `a stale downloaded apk opens the update dialog without blocking sharing`() { + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction( + ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED), + ApkReleaseStatus.Known( + version = "1.7.6", + sizeMB = 24, + isNewerThanSharedApk = true, + fromStaleCache = false + ) + ) + ) + } + + @Test + fun `a missing apk asks before spending the bytes`() { + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ApkPreparationStatus.NotDownloaded) + ) + } + + @Test + fun `an interrupted or failed download resumes without asking again`() { + // The user already consented to the download; re-prompting would be noise. + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction( + ApkPreparationStatus.Resumable( + 43, + ApkFailureMessage(R.string.prepare_apk_download_interrupted) + ) + ) + ) + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction(error()) + ) + } + + @Test + fun `a download in flight is not restartable by tapping the row`() { + // Otherwise a stray tap behind the stop button would queue a second download. + ApkDownloader.DownloadPhase.entries.forEach { phase -> + assertNull(prepareRowTapAction(ApkPreparationStatus.Downloading(phase))) + } + assertNull(prepareRowTapAction(ApkPreparationStatus.Loading)) + } + +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt new file mode 100644 index 00000000..d8371690 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -0,0 +1,234 @@ +package com.bitchat.android.ui + +import android.os.Build +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactIdentityResolver +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.Date + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class PrivateChatManagerTest { + private lateinit var state: ChatState + private lateinit var manager: PrivateChatManager + + @Before + fun setUp() { + AppStateStore.clear() + state = ChatState(TestScope()) + manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock() + ) + } + + @After + fun tearDown() { + AppStateStore.clear() + } + + @Test + fun `nostr message is stored after sender alias canonicalizes to contact id`() { + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 7 }) + val message = BitchatMessage( + id = "nostr-message", + sender = "alice", + content = "hello over nostr", + timestamp = Date(1), + isPrivate = true, + senderPeerID = conversationID + ) + + manager.handleIncomingPrivateMessage( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + + assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID]) + } + + @Test + fun `headless Nostr processing stores messages without retaining UI unread work`() { + val headlessManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock(), + trackUnreadMessages = false + ) + val message = BitchatMessage( + id = "background-nostr-message", + sender = "alice", + content = "background", + timestamp = Date(1), + isPrivate = true, + senderPeerID = "nostr_background" + ) + + headlessManager.handleIncomingPrivateMessage( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + + assertEquals(listOf(message), AppStateStore.privateMessages.value["nostr_background"]) + assertTrue(state.getUnreadPrivateMessagesValue().isEmpty()) + } + + @Test + fun `canonical conversation sends read receipt through live mesh peer id`() { + val noiseKey = ByteArray(32) { 9 } + val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey) + val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey) + val message = BitchatMessage( + id = "mesh-message", + sender = "alice", + content = "hello over mesh", + timestamp = Date(1), + isPrivate = true, + senderPeerID = meshPeerID + ) + val meshService = mock() + val peerInfo = PeerInfo( + id = meshPeerID, + nickname = "alice", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + state.setNickname("bob") + state.setPrivateChats(mapOf(conversationID to listOf(message))) + whenever(meshService.getPeerInfo(meshPeerID)).thenReturn(peerInfo) + whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true) + + manager.sendReadReceiptsForPeer( + conversationID = conversationID, + meshPeerID = meshPeerID, + meshService = meshService + ) + + verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob") + } + + @Test + fun `opening canonical unread conversation clears all source aliases`() { + val canonicalID = "contact_alice" + val nostrAlias = "nostr_0123456789abcdef" + val meshAlias = "0123456789abcdef" + val unrelatedConversation = "other-contact" + val meshService = mock() + state.setUnreadPrivateMessages( + setOf(canonicalID, nostrAlias, meshAlias, unrelatedConversation) + ) + + manager.startPrivateChat( + peerID = canonicalID, + meshService = meshService, + unreadAliases = setOf(canonicalID, nostrAlias, meshAlias) + ) + + assertEquals( + setOf(unrelatedConversation), + state.getUnreadPrivateMessagesValue() + ) + } + + @Test + fun `opening chat skips messages whose receipt send already completed`() { + val noiseKey = ByteArray(32) { 8 } + val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey) + val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey) + val oldMessage = BitchatMessage( + id = "already-read", + sender = "alice", + content = "old", + timestamp = Date(1), + isPrivate = true, + senderPeerID = meshPeerID + ) + val unreadMessage = oldMessage.copy( + id = "still-unread", + content = "new", + timestamp = Date(2) + ) + val meshService = mock() + manager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock(), + hasReadReceiptBeenSent = { it == oldMessage.id } + ) + state.setNickname("bob") + state.setPrivateChats(mapOf(conversationID to listOf(oldMessage, unreadMessage))) + whenever(meshService.getPeerInfo(meshPeerID)).thenReturn( + PeerInfo( + id = meshPeerID, + nickname = "alice", + isConnected = true, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = null, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true) + + manager.sendReadReceiptsForPeer(conversationID, meshPeerID, meshService) + + verify(meshService, never()).sendReadReceipt(oldMessage.id, meshPeerID, "bob") + verify(meshService).sendReadReceipt(unreadMessage.id, meshPeerID, "bob") + } + + @Test + fun `canonical conversation send does not require resolved nickname`() { + val conversationID = + ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 }) + var callbackInvoked = false + + manager.sendPrivateMessage( + content = "hello", + peerID = conversationID, + recipientNickname = null, + senderNickname = "bob", + myPeerID = "self" + ) { content, recipientID, nickname, _ -> + callbackInvoked = true + assertEquals("hello", content) + assertEquals(conversationID, recipientID) + assertEquals("", nickname) + } + + assertTrue(callbackInvoked) + assertEquals( + "hello", + state.getPrivateChatsValue()[conversationID]?.single()?.content + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt new file mode 100644 index 00000000..2b5f4613 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt @@ -0,0 +1,137 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Date + +class UnreadConversationSummaryTest { + @Test + fun `unread conversations survive missing presence and sort by latest unread`() { + val older = incoming( + id = "older", + sender = "alice", + timestamp = 100 + ) + val newer = incoming( + id = "newer", + sender = "bob", + timestamp = 200 + ) + + val rows = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("alice-peer", "bob-peer"), + privateChats = mapOf( + "alice-peer" to listOf(older), + "bob-peer" to listOf(newer) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { false } + ) + + assertEquals(listOf("bob-peer", "alice-peer"), rows.map { it.conversationID }) + assertEquals(listOf("bob", "alice"), rows.map { it.displayName }) + } + + @Test + fun `canonical aliases produce one unread conversation row`() { + val message = incoming( + id = "message", + sender = "alice", + timestamp = 100 + ) + + val rows = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("mesh-alias", "nostr_alias"), + privateChats = mapOf( + "mesh-alias" to listOf(message), + "nostr_alias" to listOf(message) + ), + currentUserIdentifiers = setOf("me"), + canonicalize = { "contact_alice" }, + isMessageRead = { false } + ) + + assertEquals(1, rows.size) + assertEquals("contact_alice", rows.single().conversationID) + assertEquals(DirectMessageTransport.NOSTR, rows.single().transport) + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + rows.single().identityAliases + ) + } + + @Test + fun `only unseen incoming messages contribute to unread count`() { + val read = incoming( + id = "read", + sender = "alice", + timestamp = 100 + ) + val unread = incoming( + id = "unread", + sender = "alice", + timestamp = 200 + ) + val outgoing = incoming( + id = "outgoing", + sender = "me", + timestamp = 300 + ) + + val row = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("alice-peer"), + privateChats = mapOf("alice-peer" to listOf(read, unread, outgoing)), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { it.id == "read" } + ).single() + + assertEquals(1, row.unreadCount) + assertEquals(200, row.latestMessageAt) + } + + @Test + fun `unread key without hydrated messages still produces a row`() { + val row = buildUnreadConversationSummaries( + unreadConversationIDs = setOf("orphan-peer"), + privateChats = emptyMap(), + currentUserIdentifiers = setOf("me"), + canonicalize = { it }, + isMessageRead = { false } + ).single() + + assertEquals("orphan-peer", row.conversationID) + assertEquals(1, row.unreadCount) + assertTrue(row.displayName.isNotBlank()) + } + + @Test + fun `canonical unread lookup returns every matching source alias`() { + val aliases = matchingUnreadAliases( + unreadConversationIDs = setOf("mesh-alias", "nostr_alias", "other-contact"), + canonicalConversationID = "contact_alice", + canonicalize = { unreadID -> + if (unreadID == "other-contact") unreadID else "contact_alice" + } + ) + + assertEquals( + setOf("mesh-alias", "nostr_alias", "contact_alice"), + aliases + ) + } + + private fun incoming( + id: String, + sender: String, + timestamp: Long + ) = BitchatMessage( + id = id, + sender = sender, + content = "hello", + timestamp = Date(timestamp) + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeGeometryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeGeometryTest.kt new file mode 100644 index 00000000..122e1235 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeGeometryTest.kt @@ -0,0 +1,62 @@ +package com.bitchat.android.ui.globe + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobeGeometryTest { + + private val square = listOf( + DiscPt(-0.5f, -0.5f, front = true), + DiscPt(0.5f, -0.5f, front = true), + DiscPt(0.5f, 0.5f, front = true), + DiscPt(-0.5f, 0.5f, front = true) + ) + + @Test + fun closedFullyVisibleRing_connectsLastPointToFirst() { + val run = buildFrontRuns(square, closed = true).single() + + assertEquals(square.size + 1, run.size) + assertEquals(run.first(), run.last()) + } + + @Test + fun openFullyVisibleLine_doesNotConnectLastPointToFirst() { + val run = buildFrontRuns(square, closed = false).single() + + assertEquals(square.size, run.size) + assertNotEquals(run.first(), run.last()) + } + + @Test + fun polygonContains_distinguishesInsideAndOutsidePoints() { + val polygon = listOf( + -10f to -10f, + 10f to -10f, + 10f to 10f, + -10f to 10f + ) + + assertTrue(polygonContains(polygon, 0f, 0f)) + assertFalse(polygonContains(polygon, 20f, 0f)) + } + + @Test + fun ringContainsLocation_distinguishesInsideAndOutsideCoordinates() { + val ring = LandData.Ring( + coords = floatArrayOf( + -10f, -10f, + -10f, 10f, + 10f, 10f, + 10f, -10f + ), + size = 4 + ) + + assertTrue(ringContainsLocation(ring, latitude = 0.0, longitude = 0.0)) + assertFalse(ringContainsLocation(ring, latitude = 20.0, longitude = 0.0)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeInteractionPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeInteractionPolicyTest.kt new file mode 100644 index 00000000..c4e59cd3 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeInteractionPolicyTest.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.ui.globe + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobeInteractionPolicyTest { + + @Test + fun fullDetail_preservesAllGlobeFeatures() { + val detail = globeFrameDetail(GlobeMotionDetail.FULL) + + assertEquals(1, detail.landPointStride) + assertTrue(detail.showBorders) + assertNull(detail.cityMaxRank) + assertTrue(detail.showCityLabels) + assertTrue(detail.showGeohashGrid) + assertTrue(detail.showNeighborCells) + } + + @Test + fun balancedDetail_preservesOrientationAndSelection() { + val detail = globeFrameDetail(GlobeMotionDetail.BALANCED) + + assertEquals(2, detail.landPointStride) + assertTrue(detail.showBorders) + assertEquals(1, detail.cityMaxRank) + assertFalse(detail.showCityLabels) + assertTrue(detail.showGeohashGrid) + assertFalse(detail.showNeighborCells) + } + + @Test + fun fastDetail_usesMinimumMovingFrameWork() { + val detail = globeFrameDetail(GlobeMotionDetail.FAST) + + assertEquals(2, detail.landPointStride) + assertFalse(detail.showBorders) + assertEquals(-1, detail.cityMaxRank) + assertFalse(detail.showCityLabels) + assertFalse(detail.showGeohashGrid) + } + + @Test + fun adaptiveDetail_degradesOnSlowFramesAndRecoversWithHysteresis() { + assertEquals( + GlobeMotionDetail.BALANCED, + nextGlobeMotionDetail(GlobeMotionDetail.FULL, averageFrameMillis = 24f) + ) + assertEquals( + GlobeMotionDetail.FAST, + nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 32f) + ) + assertEquals( + GlobeMotionDetail.BALANCED, + nextGlobeMotionDetail(GlobeMotionDetail.FAST, averageFrameMillis = 17f) + ) + assertEquals( + GlobeMotionDetail.FULL, + nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 17f) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMathTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMathTest.kt new file mode 100644 index 00000000..0f39572a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeMathTest.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.ui.globe + +import kotlin.math.cos +import kotlin.math.sin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobeMathTest { + + @Test + fun preparedProjector_matchesReferenceProjection() { + val centers = listOf( + 0.0 to 0.0, + 45.25 to 12.5, + -72.0 to 179.5 + ) + val points = listOf( + 0.0 to 0.0, + 51.5074 to -0.1278, + -33.8688 to 151.2093, + 89.0 to -179.9 + ) + + for ((centerLat, centerLon) in centers) { + val projector = GlobeMath.PreparedProjector(centerLat, centerLon) + for ((lat, lon) in points) { + val latRadians = Math.toRadians(lat) + val lonRadians = Math.toRadians(lon) + val terms = floatArrayOf( + sin(latRadians).toFloat(), + cos(latRadians).toFloat(), + sin(lonRadians).toFloat(), + cos(lonRadians).toFloat() + ) + val actual = FloatArray(3) + projector.project(terms, 0, actual, 0) + val expected = GlobeMath.projectRaw(lat, lon, centerLat, centerLon) + + assertEquals(expected.x, actual[0], 0.000_002f) + assertEquals(expected.y, actual[1], 0.000_002f) + assertEquals(expected.cosC, actual[2], 0.000_002f) + } + } + } + + @Test + fun zoomForPrecision_staysWithinInteractiveBounds() { + for (precision in 1..GlobeMath.MAX_PRECISION) { + val zoom = GlobeMath.zoomForPrecision( + precision = precision, + baseRadiusPx = 400f, + screenMinPx = 900f + ) + + assertTrue(zoom >= GlobeMath.MIN_ZOOM) + assertTrue(zoom <= GlobeMath.MAX_ZOOM) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt new file mode 100644 index 00000000..e14a968d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -0,0 +1,250 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.IOException + +class ApkDownloadSourceTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val source = ApkDownloadSource( + id = "mirror-one", + displayName = "Mirror One", + latestApkUrl = "https://mirror.example/bitchat-universal.apk" + ) + private val now = 1_700_000_000_000L + + @Test + fun `default source downloads the stable latest universal asset directly`() { + assertEquals( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk", + DefaultApkDownloadSources.all.single().latestApkUrls.first() + ) + assertEquals( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/app-universal-release.apk", + DefaultApkDownloadSources.all.single().latestApkUrls[1] + ) + } + + @Test + fun `transient HTTP failures are retryable but ordinary client errors are not`() { + assertTrue(httpError(408).retryable) + assertTrue(httpError(500).retryable) + assertTrue(httpError(503).retryable) + assertFalse(httpError(400).retryable) + assertFalse(httpError(404).retryable) + } + + @Test + fun `compatibility URL is only tried when the preferred asset is absent`() { + assertTrue(shouldTryNextSourceUrl(httpError(404), hasMoreUrls = true)) + assertFalse(shouldTryNextSourceUrl(httpError(404), hasMoreUrls = false)) + assertFalse(shouldTryNextSourceUrl(httpError(429), hasMoreUrls = true)) + assertFalse(shouldTryNextSourceUrl(httpError(503), hasMoreUrls = true)) + } + + @Test + fun `rate limit response retains the server deadline without exposing a countdown`() { + val failure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 429, + responseMessage = "Too Many Requests", + retryAfter = "120", + rateLimitRemaining = null, + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + + assertFalse(failure.retryable) + assertEquals(now + 120_000L, failure.retryAtMillis) + assertEquals(ApkDownloadFailureReason.RateLimited, failure.reason) + assertEquals(listOf(source.displayName), failure.messageArgs) + } + + @Test + fun `403 is only treated as a limit when response headers say so`() { + val permissionsFailure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = "not-a-date", + rateLimitRemaining = "42", + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + val quotaFailure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = null, + rateLimitRemaining = "0", + rateLimitResetEpochSeconds = (now / 1000L + 300L).toString(), + nowMillis = now + ) + + assertNull(permissionsFailure.retryAtMillis) + assertEquals(ApkDownloadFailureReason.HttpFailure, permissionsFailure.reason) + assertEquals( + listOf(source.displayName, "403", "Forbidden"), + permissionsFailure.messageArgs + ) + assertEquals(now + 300_000L, quotaFailure.retryAtMillis) + assertEquals(ApkDownloadFailureReason.RateLimited, quotaFailure.reason) + } + + @Test + fun `a reset header alone does not make a 403 a rate limit`() { + // GitHub sends X-RateLimit-Reset on every response, so a permissions failure carries one + // while the quota is untouched. Reading it as a limit would park the route in a cooldown + // and serve stale metadata until a window the failure has nothing to do with. + val failure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = null, + rateLimitRemaining = "4999", + rateLimitResetEpochSeconds = (now / 1000L + 1_800L).toString(), + nowMillis = now + ) + + assertEquals(ApkDownloadFailureReason.HttpFailure, failure.reason) + assertNull(failure.retryAtMillis) + assertEquals( + listOf(source.displayName, "403", "Forbidden"), + failure.messageArgs + ) + } + + @Test + fun `a secondary limit is still caught by its Retry-After`() { + // The quota is intact, so only Retry-After marks this one. It has to keep working, or + // tightening the reset-header case would blind the client to secondary limits. + val failure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = "90", + rateLimitRemaining = "4999", + rateLimitResetEpochSeconds = (now / 1000L + 1_800L).toString(), + nowMillis = now + ) + + assertEquals(ApkDownloadFailureReason.RateLimited, failure.reason) + assertEquals(now + 90_000L, failure.retryAtMillis) + } + + @Test + fun `invalid or overflowing retry headers never crash error mapping`() { + assertNull( + ApkDownloadHttpErrors.retryAtMillis( + retryAfter = Long.MAX_VALUE.toString(), + rateLimitResetEpochSeconds = Long.MAX_VALUE.toString(), + nowMillis = now + ) + ) + } + + @Test + fun `content ranges validate resume offsets and totals`() { + assertEquals( + ContentRange(start = 1_024L, endInclusive = 2_047L, total = 4_096L), + parseContentRange("bytes 1024-2047/4096") + ) + assertEquals(4_096L, parseUnsatisfiedContentRangeTotal("bytes */4096")) + assertNull(parseContentRange("bytes nope")) + assertNull(parseContentRange("bytes 20-10/100")) + assertNull(parseContentRange("bytes 90-100/100")) + } + + @Test + fun `a full response discards bytes from the release that was being resumed`() { + val tempFile = temporaryFolder.newFile("download-temp.apk") + tempFile.writeBytes("old-release-prefix".toByteArray()) + + prepareApkTempFileForResponse(tempFile, appendResponse = false) + tempFile.appendBytes("new-release".toByteArray()) + + assertEquals("new-release", tempFile.readText()) + } + + @Test + fun `a valid partial response keeps resumable bytes`() { + val tempFile = temporaryFolder.newFile("download-temp.apk") + tempFile.writeBytes("first-".toByteArray()) + + prepareApkTempFileForResponse(tempFile, appendResponse = true) + tempFile.appendBytes("second".toByteArray()) + + assertEquals("first-second", tempFile.readText()) + } + + @Test + fun `version comparison is host independent`() { + assertTrue(AppVersion.isNewer("1.7.4", "1.7.5")) + assertFalse(AppVersion.isNewer("1.7.5", "1.7.4")) + assertFalse(AppVersion.isNewer("v1.7.5", "1.7.5")) + assertTrue(AppVersion.isNewer("1.7", "1.7.1")) + } + + @Test + fun `worker policy allows exactly three total attempts`() { + val transient = IOException("offline") + + assertTrue(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 0, transient)) + assertTrue(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 1, transient)) + assertFalse(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 2, transient)) + assertFalse( + ApkDownloadRetryPolicy.shouldRetry( + runAttemptCount = 0, + ApkDownloadException( + message = "invalid APK", + reason = ApkDownloadFailureReason.Generic, + retryable = false + ) + ) + ) + } + + @Test + fun `a failure reason survives the round trip through its key`() { + // WorkManager keeps failed records across app updates, so the key written by one build is + // read by the next. Resource ids are reassigned per build and would resolve to the wrong + // string; the name does not move. + ApkDownloadFailureReason.entries.forEach { reason -> + assertEquals(reason, ApkDownloadFailureReason.fromKey(reason.name)) + } + } + + @Test + fun `an absent or retired reason falls back instead of resolving nothing`() { + assertEquals( + ApkDownloadFailureReason.Generic, + ApkDownloadFailureReason.fromKey(null) + ) + assertEquals( + ApkDownloadFailureReason.Generic, + ApkDownloadFailureReason.fromKey("ReasonFromAFutureBuild") + ) + } + + private fun httpError(code: Int): ApkDownloadException { + return ApkDownloadHttpErrors.fromResponse( + source = source, + code = code, + responseMessage = "test", + retryAfter = null, + rateLimitRemaining = null, + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt new file mode 100644 index 00000000..c9efe3d6 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt @@ -0,0 +1,78 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +class DistributionInfoProviderTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `arm64-only APK is not universal`() { + val apk = createApk("lib/arm64-v8a/libbitchat.so") + + assertFalse(DistributionInfoProvider.isUniversalApk(apk)) + assertEquals( + ShareableApkVariant.ARM64, + DistributionInfoProvider.shareableApkVariant(apk) + ) + } + + @Test + fun `APK containing every release ABI is universal`() { + val apk = createApk( + "lib/arm64-v8a/libbitchat.so", + "lib/armeabi-v7a/libbitchat.so", + "lib/x86_64/libbitchat.so", + "lib/x86/libbitchat.so" + ) + + assertTrue(DistributionInfoProvider.isUniversalApk(apk)) + assertEquals( + ShareableApkVariant.UNIVERSAL, + DistributionInfoProvider.shareableApkVariant(apk) + ) + } + + @Test + fun `APK without native libraries is architecture independent`() { + val apk = createApk("classes.dex") + + assertTrue(DistributionInfoProvider.isUniversalApk(apk)) + assertEquals( + ShareableApkVariant.UNIVERSAL, + DistributionInfoProvider.shareableApkVariant(apk) + ) + } + + @Test + fun `other architecture-only APK is not offered for sharing`() { + val apk = createApk("lib/x86_64/libbitchat.so") + + assertNull(DistributionInfoProvider.shareableApkVariant(apk)) + } + + private fun createApk(vararg entries: String): File { + val apk = temporaryFolder.newFile("test-${System.nanoTime()}.apk") + ZipOutputStream(apk.outputStream()).use { zip -> + entries.forEach { path -> + zip.putNextEntry(ZipEntry(path)) + zip.write(byteArrayOf(1)) + zip.closeEntry() + } + } + return apk + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt new file mode 100644 index 00000000..7e5c7d6e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The phase crosses a WorkManager `Data` boundary as a plain string, so it has to survive a + * round trip and degrade sensibly when it does not. + */ +class DownloadPhaseTest { + + @Test + fun `every phase survives the round trip through its key`() { + ApkDownloader.DownloadPhase.entries.forEach { phase -> + assertEquals(phase, ApkDownloader.DownloadPhase.fromKey(phase.name)) + } + } + + @Test + fun `an absent or unrecognised key falls back to the transfer`() { + // Work enqueued by an older build, or progress read before the first phase is published. + assertEquals( + ApkDownloader.DownloadPhase.Transferring, + ApkDownloader.DownloadPhase.fromKey(null) + ) + assertEquals( + ApkDownloader.DownloadPhase.Transferring, + ApkDownloader.DownloadPhase.fromKey("SomePhaseFromAFutureBuild") + ) + } + + @Test + fun `phase keys from queued work created by the old downloader still map correctly`() { + assertEquals( + ApkDownloader.DownloadPhase.SelectingSource, + ApkDownloader.DownloadPhase.fromKey("ResolvingRelease") + ) + assertEquals( + ApkDownloader.DownloadPhase.VerifyingSignature, + ApkDownloader.DownloadPhase.fromKey("VerifyingChecksum") + ) + } + + @Test + fun `a queued retry is not reported as a connectivity wait`() { + // WorkManager returns a retry to ENQUEUED for the backoff even while the device is online, + // so attempt count is the only thing separating the two waits. + assertEquals( + ApkDownloader.DownloadPhase.AwaitingConnectivity, + queuedPhase(runAttemptCount = 0) + ) + assertEquals(ApkDownloader.DownloadPhase.Retrying, queuedPhase(runAttemptCount = 1)) + assertEquals(ApkDownloader.DownloadPhase.Retrying, queuedPhase(runAttemptCount = 2)) + } + + @Test + fun `only the transfer claims measurable progress`() { + assertTrue(ApkDownloader.DownloadPhase.Transferring.hasMeasurableProgress) + + val unmeasurable = ApkDownloader.DownloadPhase.entries + .filterNot { it == ApkDownloader.DownloadPhase.Transferring } + assertFalse(unmeasurable.isEmpty()) + unmeasurable.forEach { + assertFalse("$it has no percentage to report", it.hasMeasurableProgress) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt new file mode 100644 index 00000000..6377752d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -0,0 +1,223 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GitHubReleaseClientTest { + private lateinit var context: Context + private lateinit var server: MockWebServer + private var nowMillis = 1_700_000_000_000L + private var route = OkHttpProvider.Route.DIRECT + + /** How far the clock advances while awaitRoute() waits for Tor to finish bootstrapping. */ + private var routeWaitMillis = 0L + + /** How far the clock advances after the server responds but before the client observes it. */ + private var responseWaitMillis = 0L + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences("apk_release_metadata", Context.MODE_PRIVATE) + .edit().clear().commit() + context.getSharedPreferences("apk_network_cooldowns", Context.MODE_PRIVATE) + .edit().clear().commit() + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun `cached metadata is conditionally refreshed with its etag`() = runTest { + server.enqueue(successResponse(etag = "release-v1")) + val client = client() + + val first = client.latestRelease().getOrThrow() + assertEquals("1.7.6", first.release.versionName) + assertFalse(first.isStale) + + nowMillis += 31 * 60_000L + server.enqueue( + MockResponse.Builder() + .code(304) + .build() + ) + val refreshed = client.latestRelease().getOrThrow() + + assertFalse(refreshed.isStale) + server.takeRequest() + assertEquals("release-v1", server.takeRequest().headers["If-None-Match"]) + } + + @Test + fun `rate limit serves stale metadata and suppresses repeated requests`() = runTest { + server.enqueue(successResponse(etag = "release-v1")) + val client = client() + client.latestRelease().getOrThrow() + + nowMillis += 31 * 60_000L + server.enqueue( + MockResponse.Builder() + .code(429) + .build() + ) + val stale = client.latestRelease().getOrThrow() + val stillStale = client.latestRelease().getOrThrow() + + assertTrue(stale.isStale) + assertTrue(stillStale.isStale) + assertEquals(2, server.requestCount) + } + + @Test + fun `cooldown follows the actual client route`() = runTest { + server.enqueue( + MockResponse.Builder() + .code(429) + .addHeader("Retry-After", "120") + .build() + ) + val client = client() + assertTrue(client.latestRelease().isFailure) + assertTrue(client.latestRelease().isFailure) + assertEquals(1, server.requestCount) + + route = OkHttpProvider.Route.TOR + server.enqueue(successResponse(etag = "release-v1")) + assertTrue(client.latestRelease().isSuccess) + assertEquals(2, server.requestCount) + } + + /** + * A Tor cold start can hold the request for the full 60-second route timeout, which is longer + * than the relative delay GitHub asks for. The cooldown has to outlast the wait that preceded + * it, so it is anchored at the response rather than at the start of the attempt. + */ + @Test + fun `a slow route wait does not shorten a relative retry-after cooldown`() = runTest { + routeWaitMillis = 90_000L + server.enqueue( + MockResponse.Builder() + .code(429) + .addHeader("Retry-After", "60") + .build() + ) + val client = client() + + assertTrue(client.latestRelease().isFailure) + routeWaitMillis = 0L + assertTrue(client.latestRelease().isFailure) + + assertEquals(1, server.requestCount) + } + + @Test + fun `a slow route wait does not shorten the header-less fallback cooldown`() = runTest { + routeWaitMillis = 90_000L + server.enqueue(MockResponse.Builder().code(429).build()) + val client = client() + + assertTrue(client.latestRelease().isFailure) + routeWaitMillis = 0L + assertTrue(client.latestRelease().isFailure) + + assertEquals(1, server.requestCount) + } + + @Test + fun `a slow response does not shorten a relative retry-after cooldown`() = runTest { + responseWaitMillis = 90_000L + server.enqueue( + MockResponse.Builder() + .code(429) + .addHeader("Retry-After", "60") + .build() + ) + val client = client() + + assertTrue(client.latestRelease().isFailure) + responseWaitMillis = 0L + assertTrue(client.latestRelease().isFailure) + + assertEquals(1, server.requestCount) + } + + @Test + fun `a cooldown that expires while the route becomes ready does not suppress the request`() = + runTest { + server.enqueue( + MockResponse.Builder() + .code(429) + .addHeader("Retry-After", "60") + .build() + ) + val client = client() + + assertTrue(client.latestRelease().isFailure) + + routeWaitMillis = 90_000L + server.enqueue(successResponse(etag = "release-after-cooldown")) + assertTrue(client.latestRelease().isSuccess) + + assertEquals(2, server.requestCount) + } + + private fun client() = GitHubReleaseClient( + context = context, + apiUrl = server.url("/releases/latest").toString(), + nowMillis = { nowMillis }, + routedClient = { + OkHttpProvider.RoutedClient( + client = OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed(chain.request()).also { + nowMillis += responseWaitMillis + } + } + .build(), + route = route + ) + }, + awaitRoute = { + nowMillis += routeWaitMillis + true + } + ) + + private fun successResponse(etag: String): MockResponse = MockResponse.Builder() + .code(200) + .addHeader("ETag", etag) + .body( + """ + { + "tag_name": "v1.7.6", + "assets": [ + { + "name": "bitchat-android-universal.apk", + "browser_download_url": "https://downloads.example/bitchat-universal.apk", + "size": 25165824 + } + ] + } + """.trimIndent() + ) + .build() +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt new file mode 100644 index 00000000..cf352d7c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.wifiaware + +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class IngressLinkPolicyTest { + @Test + fun `observation resolves only the exact ingress link`() { + val attackerSocket = Any() + val victimSocket = Any() + val links = mapOf( + "attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket), + "victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket) + ) + val current = mapOf( + "provisional-attacker" to attackerSocket, + "provisional-victim" to victimSocket + ) + + val resolved = IngressLinkPolicy.resolve( + ingressLinkID = "victim-link", + relayAddress = "provisional-victim", + links = links, + currentTransportForRelay = current::get + ) + + assertSame(victimSocket, resolved?.transport) + } + + @Test + fun `stale replaced or mismatched ingress links cannot be observed`() { + val completedSocket = Any() + val replacementSocket = Any() + val links = mapOf( + "completed-link" to IngressLinkPolicy.Link("provisional", completedSocket) + ) + + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "missing-link", + relayAddress = "provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "completed-link", + relayAddress = "different-provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + IngressLinkPolicy.resolve( + ingressLinkID = "completed-link", + relayAddress = "provisional", + links = links, + currentTransportForRelay = { replacementSocket } + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt new file mode 100644 index 00000000..ed5cec9f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt @@ -0,0 +1,151 @@ +package com.bitchat.android.wifiaware + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.Socket +import java.util.Collections + +class SyncedSocketContractTest { + @Test + fun `write emits big-endian length payload and empty keepalive frames`() { + val output = ByteArrayOutputStream() + val raw = socket(input = ByteArrayInputStream(byteArrayOf()), output = output) + val synced = SyncedSocket(raw, readTimeoutMs = 1_234) + + synced.write(byteArrayOf(1, 2, 3)) + synced.write(ByteArray(0)) + + assertArrayEquals( + byteArrayOf(0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0), + output.toByteArray() + ) + verify(raw).soTimeout = 1_234 + } + + @Test + fun `readFully reconstructs one-byte partial reads and keepalives`() { + val wire = framed(byteArrayOf(1, 2, 3, 4)) + framed(ByteArray(0)) + val partial = object : FilterInputStream(ByteArrayInputStream(wire)) { + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + super.read(buffer, offset, minOf(1, length)) + } + val synced = SyncedSocket(socket(partial, ByteArrayOutputStream())) + + assertArrayEquals(byteArrayOf(1, 2, 3, 4), synced.read()) + assertArrayEquals(ByteArray(0), synced.read()) + assertNull(synced.read()) + } + + @Test + fun `EOF truncated invalid and oversized frames fail closed`() { + val cases = listOf( + ByteArray(0), + byteArrayOf(0, 0), + byteArrayOf(0, 0, 0, 4, 1, 2), + intPrefix(-1), + intPrefix(65_537) + ) + + cases.forEach { wire -> + val synced = SyncedSocket( + socket(ByteArrayInputStream(wire), ByteArrayOutputStream()) + ) + assertNull(synced.read()) + } + } + + @Test + fun `write exceptions propagate and do not create a partial success`() { + val failingOutput = object : OutputStream() { + override fun write(value: Int) { + throw IOException("scripted write failure") + } + } + val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), failingOutput)) + + assertThrows(IOException::class.java) { + synced.write(byteArrayOf(1)) + } + } + + @Test + fun `concurrent writers produce complete non-interleaved frames`() { + val output = ByteArrayOutputStream() + val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), output)) + val payloads = (0 until 16).map { index -> + ByteArray(index + 1) { index.toByte() } + } + val failures = Collections.synchronizedList(mutableListOf()) + val threads = payloads.map { payload -> + Thread { + runCatching { synced.write(payload) } + .exceptionOrNull() + ?.let(failures::add) + }.also(Thread::start) + } + threads.forEach { thread -> + thread.join(2_000) + assertFalse("Writer thread did not complete", thread.isAlive) + } + assertTrue(failures.isEmpty()) + + val input = DataInputStream(ByteArrayInputStream(output.toByteArray())) + val decoded = mutableListOf() + while (input.available() > 0) { + val length = input.readInt() + decoded += ByteArray(length).also(input::readFully) + } + assertEquals( + payloads.map(ByteArray::toList).toSet(), + decoded.map(ByteArray::toList).toSet() + ) + } + + @Test + fun `close and raw socket status are exposed`() { + val raw = socket(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream()) + org.mockito.kotlin.whenever(raw.isClosed).thenReturn(false, true) + org.mockito.kotlin.whenever(raw.isConnected).thenReturn(true) + val synced = SyncedSocket(raw) + + assertFalse(synced.isClosed()) + assertTrue(synced.isConnected()) + synced.close() + verify(raw).close() + assertTrue(synced.isClosed()) + } + + private fun socket(input: InputStream, output: OutputStream): Socket = mock { + on { getInputStream() } doReturn input + on { getOutputStream() } doReturn output + } + + private fun framed(payload: ByteArray): ByteArray = + ByteArrayOutputStream().also { output -> + DataOutputStream(output).use { data -> + data.writeInt(payload.size) + data.write(payload) + } + }.toByteArray() + + private fun intPrefix(value: Int): ByteArray = + ByteArrayOutputStream().also { output -> + DataOutputStream(output).use { it.writeInt(value) } + }.toByteArray() +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt new file mode 100644 index 00000000..5cd2cbf7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.wifiaware + +import android.net.ConnectivityManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.Socket + +class WifiAwareConnectionTrackerTest { + @Test + fun `compare and rebind rejects stale observed socket after replacement`() { + val tracker = WifiAwareConnectionTracker( + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mock() + ) + val observedSocket = syncedSocket() + val replacementSocket = syncedSocket() + tracker.onClientConnected("provisional", observedSocket) + tracker.onClientConnected("provisional", replacementSocket) + + assertFalse( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = observedSocket + ) + ) + assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) + assertNull(tracker.getSocketForPeer("canonical")) + + assertTrue( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = replacementSocket + ) + ) + assertSame(replacementSocket, tracker.getSocketForPeer("canonical")) + assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) + } + + @Test + fun `observed provisional socket cannot displace existing canonical socket`() { + val tracker = WifiAwareConnectionTracker( + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mock() + ) + val provisionalSocket = syncedSocket() + val canonicalSocket = syncedSocket() + tracker.onClientConnected("provisional", provisionalSocket) + tracker.onClientConnected("canonical", canonicalSocket) + + assertFalse( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = provisionalSocket + ) + ) + assertSame(provisionalSocket, tracker.getSocketForPeer("provisional")) + assertSame(canonicalSocket, tracker.getSocketForPeer("canonical")) + assertTrue("Rejected promotion must not alias the provisional ID", tracker.canonicalPeerId("provisional") == "provisional") + } + + private fun syncedSocket(): SyncedSocket { + val raw = mock { + on { getInputStream() } doReturn ByteArrayInputStream(byteArrayOf()) + on { getOutputStream() } doReturn ByteArrayOutputStream() + } + return SyncedSocket(raw) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifiaware/WifiAwareHotspotHoldTest.kt b/app/src/test/kotlin/com/bitchat/android/wifiaware/WifiAwareHotspotHoldTest.kt new file mode 100644 index 00000000..7805b9ad --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifiaware/WifiAwareHotspotHoldTest.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.wifiaware + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The hold keeps Wi-Fi Aware off the radio while a hotspot uses it. Getting the + * bookkeeping wrong is not cosmetic: release it early and the P2P group cannot form, + * fail to release it and the mesh stays down. + * + * Teardown is asynchronous, so sessions overlap and releases arrive out of order. These + * cover the counting rules that make that safe. + */ +@RunWith(RobolectricTestRunner::class) +class WifiAwareHotspotHoldTest { + + @Before + fun clearAnyLeftoverHolds() { + WifiAwareController.resetHotspotLeasesForTest() + } + + @After + fun clearTestHolds() { + WifiAwareController.resetHotspotLeasesForTest() + } + + @Test + fun `a single session holds and then releases`() { + val lease = WifiAwareController.acquireHotspotLease() + assertTrue(WifiAwareController.isHeldForHotspot()) + + lease.close() + assertFalse(WifiAwareController.isHeldForHotspot()) + } + + @Test + fun `a second session starting before the first finishes keeps the radio held`() { + val first = WifiAwareController.acquireHotspotLease() + val second = WifiAwareController.acquireHotspotLease() + + // The first session's asynchronous teardown completes. + first.close() + + assertTrue( + "the second session still needs the radio", + WifiAwareController.isHeldForHotspot() + ) + + second.close() + assertFalse(WifiAwareController.isHeldForHotspot()) + } + + @Test + fun `closing one lease twice cannot release another session`() { + val first = WifiAwareController.acquireHotspotLease() + val second = WifiAwareController.acquireHotspotLease() + + first.close() + first.close() + assertTrue(WifiAwareController.isHeldForHotspot()) + + second.close() + assertFalse( + "only the matching session can release its radio claim", + WifiAwareController.isHeldForHotspot() + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 44082684..16bf6428 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,13 +1,53 @@ +import org.gradle.api.artifacts.dsl.LockMode + // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false } +val resolveIdeRuntimeClasspathCopyLocks = tasks.register("resolveIdeRuntimeClasspathCopyLocks") { + group = "build setup" + description = "Resolves Android Studio runtime classpath copies when refreshing lock state." +} + +subprojects { + dependencyLocking { + lockAllConfigurations() + lockMode.set(LockMode.STRICT) + } + + pluginManager.withPlugin("com.android.application") { + val resolveIdeRuntimeClasspathCopyLock = tasks.register("resolveIdeRuntimeClasspathCopyLock") { + group = "build setup" + description = "Resolves this module's Android Studio runtime classpath copies." + notCompatibleWithConfigurationCache("Resolves copied configurations at execution time") + doFirst { + check(gradle.startParameter.isWriteDependencyLocks) { + "$path must be run with --write-locks" + } + } + doLast { + listOf("debugRuntimeClasspath", "releaseRuntimeClasspath").forEach { configurationName -> + configurations.getByName(configurationName).copy().resolve() + } + } + } + resolveIdeRuntimeClasspathCopyLocks.configure { + dependsOn(resolveIdeRuntimeClasspathCopyLock) + } + } +} + tasks.whenTaskAdded { if (name.contains("ArtProfile")) { enabled = false } } + +tasks.register("clientRewriteContractTest") { + group = "verification" + description = "Runs the complete compatibility gate for a from-scratch client rewrite." + dependsOn(":app:testDebugUnitTest") +} diff --git a/docs/ANNOUNCEMENT_GOSSIP.md b/docs/ANNOUNCEMENT_GOSSIP.md new file mode 100644 index 00000000..0328df27 --- /dev/null +++ b/docs/ANNOUNCEMENT_GOSSIP.md @@ -0,0 +1,112 @@ +# Announcement Gossip TLV (Direct Neighbors) + +This document specifies an optional TLV extension to the BitChat `ANNOUNCE` message that allows peers to gossip which other peers they are currently connected to directly over Bluetooth. Implementations can use this to build a mesh topology view (nodes = peers, edges = direct connections). + +Status: optional and backward-compatible. + +## Layering Overview + +- Outer packet: BitChat binary packet with `type = 0x01` (ANNOUNCE). Header is unchanged. +- Payload: A sequence of TLVs. Unknown TLVs MUST be ignored for forward compatibility. +- Signature: The packet is signed using the Ed25519 public key carried in TLV + `0x03`. The gossip and capability TLVs are part of the payload and therefore + covered by the signature. Current clients require a valid signature before + applying identity or capability state. + +## TLV Format + +Each TLV uses a compact layout: + +- `type`: 1 byte +- `length`: 1 byte (0..255) +- `value`: `length` bytes + +Existing TLVs (unchanged): + +- `0x01` NICKNAME: UTF‑8 string (≤ 255 bytes) +- `0x02` NOISE_PUBLIC_KEY: Noise static public key bytes (typically 32 bytes for X25519) +- `0x03` SIGNING_PUBLIC_KEY: Ed25519 public key bytes (typically 32 bytes) + +Other optional extension: + +- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Assigned bits + match iOS: 0 prekeys, 1 Wi-Fi bulk, 2 gateway, 3 private groups, 4 board, + 5 vouch, 6 mesh diagnostics, 7 bridge, 8 authenticated Noise private media, + 9 private-media receipts, and 10 reserved non-destructive Noise replacement. + Bit 8's exact value is `00 01`. An empty value is valid and means no + advertised capabilities. For this announcement TLV, unknown bits and bytes + beyond the low 64 bits must be tolerated for forward compatibility. This + tolerance does not apply to the Noise-authenticated peer-state payload + `0x21`, whose capability field must be canonical and 1...8 bytes; see + `PRIVATE_MEDIA_V1.md`. + +New TLV (optional): + +- `0x04` DIRECT_NEIGHBORS: Concatenation of up to 10 peer IDs, each encoded as exactly 8 bytes. There is no inner count; the number of neighbors is `length / 8`. If `length` is not a multiple of 8, trailing partial bytes MUST be ignored. + +### Peer ID Binary Encoding (8 bytes) + +Peer IDs are represented as 8 raw bytes (16 hex chars) in “network order” (left‑to‑right): + +- Take the peer ID hex string, lowercase/truncate to at most 16 hex chars. +- Convert each 2 hex chars to 1 byte from left to right. +- If fewer than 16 hex chars are available, pad the remaining bytes with `0x00` at the end to reach 8 bytes. + +This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in the BitChat packet header. + +## Sender Behavior + +- Build the base announcement payload by emitting TLVs `0x01`..`0x03` as usual. +- Optionally append TLV `0x04` with up to 10 unique, directly connected peer IDs. + - Remove duplicates before encoding. + - Order is arbitrary and not semantically significant. +- Current capable senders also include TLV `0x05` with private-media bit 8 set + in every broadcast and peer-directed announcement. +- Sign the ANNOUNCE packet so all extension TLVs are covered: + - Signature algorithm: Ed25519 using the key in TLV `0x03`. + - Signature input: the binary packet encoding with the signature field omitted and the TTL normalized to `0`. This allows TTL to change during relays without invalidating the signature. +- The payload may be compressed per the base protocol; the gossip TLV is encoded prior to optional compression. + +## Receiver Behavior + +- Decompress payload if the packet’s compression flag is set, then parse TLVs in order. +- Parse TLVs `0x01`..`0x03` as usual; ignore any unknown TLVs. +- Parse TLV `0x05`, when present, as a little-endian bitfield. Absence and an + explicitly empty value remain valid legacy capability states. A + security-sensitive bit is trusted only after the signed announcement key is + bound to the matching remote static key from a live Noise handshake; see + `PRIVATE_MEDIA_V1.md`. +- Capability bits are independent discovery hints. Implementations advertise + only wire features they implement and must not infer support from platform, + app version, or unrelated transports with similar names. +- Private-media receipts and retry (bit 9) require the current Noise + generation's authenticated `0x21` peer state to prove both bits 8 and 9. An + announcement alone never authorizes that behavior. +- If a `0x04` TLV is present: + - Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes). + - Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase). + - De‑duplicate neighbors. +- Topology maintenance guidance (optional, but recommended for consistent behavior): + - Maintain, for each announcing peer A, the last announcement timestamp and the neighbor list from TLV `0x04`. + - When a newer announcement from A arrives (use the 8‑byte unsigned `timestamp` in the BitChat packet header), replace A’s previously recorded neighbor list with the new one. If older or equal, ignore the neighbor update. + - Treat the neighbor list as a set of undirected edges `{A, B}` in your topology visualization; i.e., if A reports direct peers `[B, C]`, add edges A–B and A–C. + +## Limits and Compatibility + +- Max neighbors per TLV: 10. Senders MAY send fewer; receivers MUST accept any number `N ≥ 0` implied by `length / 8` up to the received `length`. +- Omission: If the TLV `0x04` is omitted, the announce remains valid. Peers can still chat and interoperate normally; the topology graph will just not include edges reported by that peer (other peers that include A in their neighbor lists can still introduce edges to A). +- Unknown TLVs MUST be ignored. This makes the extension safe for older implementations. + +## Minimal Example (conceptual) + +ANNOUNCE payload TLVs (concatenated): + +- `01 [len=N] [UTF‑8 nickname]` +- `02 [len=32] [32 bytes X25519 pubkey]` +- `03 [len=32] [32 bytes Ed25519 pubkey]` +- `04 [len=8*M] [peerID1(8) || peerID2(8) || ... || peerIDM(8)]` (optional) +- `05 02 00 01` (optional private-media-v1 capability) + +Where each `peerIDk(8)` is the 8‑byte binary form of the peer ID as specified above. + +That’s the entire change; the outer packet header, message type, and relay/TTL behavior are unchanged. diff --git a/docs/GeohashPresenceSpec.md b/docs/GeohashPresenceSpec.md new file mode 100644 index 00000000..321a491f --- /dev/null +++ b/docs/GeohashPresenceSpec.md @@ -0,0 +1,96 @@ +# Geohash Presence Specification + +## Overview + +The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts. + +## Nostr Protocol + +### Event Kind +A new ephemeral event kind is defined for presence heartbeats: +- **Kind:** `20001` (`GEOHASH_PRESENCE`) +- **Type:** Ephemeral (not stored by relays long-term) + +### Event Structure +The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness". + +```json +{ + "kind": 20001, + "created_at": , + "tags": [ + ["g", ""] + ], + "content": "", + "pubkey": "", + "id": "", + "sig": "" +} +``` + +* **`content`**: Must be empty string. +* **`tags`**: Must include `["g", ""]`. Should NOT include `["n", ""]`. +* **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages). + +## Client Behavior + +### 1. Broadcasting Presence + +Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing. + +* **Global Heartbeat:** + * **Trigger:** Application start / initialization, or whenever location (available geohashes) changes. + * **Frequency:** Randomized loop interval between **40s and 80s** (average 60s). + * **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*. + * **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically: + * **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5). + * **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+). + * **Decorrelation:** Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence. + +### 2. Subscribing to Presence + +Clients must update their Nostr filters to listen for both chat and presence events on geohash channels. + +* **Filter:** + * `kinds`: `[20000, 20001]` + * `#g`: `[""]` + +### 3. Participant Counting + +The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages. + +* **Logic:** + * Maintain a map of `pubkey -> last_seen_timestamp` for each geohash. + * Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event. + * A participant is considered "online" if their `last_seen_timestamp` is within the last **5 minutes**. + +### 4. UI Presentation + +The presentation of the participant count depends on the geohash precision level and data availability. + +* **Standard Display:** For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: `[N people]`. +* **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where: + * Presence broadcasting is disabled (privacy restriction). + * **AND** the detected participant count is `0`. + * **Display:** `[? people]` + * **Reasoning:** Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking). + +### 5. Implementation Details (Android Reference) + +* **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`. +* **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event. +* **`GeohashViewModel`**: + * `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`. + * Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s). + * Filters channels by `precision <= 5` before broadcasting. +* **`GeohashMessageHandler`**: + * Refactored `onEvent` to update participant counts for both Kind 20000 and 20001. +* **`LocationChannelsSheet`**: + * Implements the `[? people]` display logic for high-precision, zero-count channels. + +## Benefits + +* **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages). +* **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays. +* **Consistency:** "Online" status is maintained globally while the app is open. +* **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting. diff --git a/docs/LIVE_VOICE_V1.md b/docs/LIVE_VOICE_V1.md new file mode 100644 index 00000000..0b717646 --- /dev/null +++ b/docs/LIVE_VOICE_V1.md @@ -0,0 +1,204 @@ +# Live voice v1 wire protocol + +This document defines the interoperable live push-to-talk protocol used by +Android, Wear OS, and iOS clients. Live packets are ephemeral and best-effort; +the ordinary voice note sent when the user releases the microphone remains the +reliable, persistent fallback. + +All multi-byte integers in this document use network byte order (big-endian). + +## Type assignments + +| Layer | Name | Value | Purpose | +| --- | --- | --- | --- | +| Outer mesh packet | `MessageType.VOICE_FRAME` | `0x29` | Signed public-mesh burst packet | +| Noise inner payload | `NoisePayloadType.VOICE_FRAME` | `0x08` | Encrypted direct-message burst packet | +| Outer private packet | `MessageType.NOISE_ENCRYPTED` | `0x11` | Existing recipient-directed Noise envelope | + +These values are canonical. Implementations must not emit live voice under a +different outer or inner type. + +## Transport envelopes + +### Public mesh + +The sender emits a normal mesh packet with: + +- version `1`; +- type `VOICE_FRAME (0x29)`; +- a broadcast recipient (either no recipient field or the eight-byte all-`FF` + broadcast value; receivers accept both encodings); +- the encoded `VoiceBurstPacket` as its payload; +- origin TTL `7`; and +- an Ed25519 signature over the canonical outer packet signing bytes. + +Receivers accept public live voice only from a known peer whose announcement +has established a verified nickname and signing key. They must reject an +unsigned packet, an invalid signature, a non-broadcast recipient, or a packet +whose timestamp is more than 30 seconds from the receiver's current time. A +rejected public voice packet must not be relayed. + +Public live voice is not added to gossip sync. Older clients ignore the unknown +`0x29` type and therefore remain compatible. + +### Direct message + +The sender requires an established Noise session. It encodes: + +```text +[NoisePayloadType.VOICE_FRAME: 0x08][VoiceBurstPacket] +``` + +and encrypts that value in a recipient-directed `NOISE_ENCRYPTED (0x11)` mesh +packet. The existing Noise session provides confidentiality and authentication. +Intermediate relays see only the ordinary Noise envelope and route it according +to the existing directed-packet rules. + +There are no delivery acknowledgements or retransmissions for live packets. +If no established session exists when recording begins, the client records and +sends only the ordinary voice note. + +## `VoiceBurstPacket` + +Every packet in one press-and-hold gesture shares an opaque, randomly generated +eight-byte burst ID. + +```text ++------------+-------------+-------------+------------------+ +| burstID | sequence | flags | payload | +| 8 bytes | UInt16 BE | UInt8 | variant-specific | ++------------+-------------+-------------+------------------+ +``` + +The fixed header is 11 bytes. `flags` is a complete discriminator, not a +bitset that may combine values. Unknown or combined flag values are invalid. + +### START (`flags = 0x01`) + +```text +[codec: UInt8] +``` + +Current senders use sequence `0`. The only v1 codec is: + +| Codec | Value | Encoded frames | +| --- | --- | --- | +| AAC-LC, 16 kHz, mono | `0x01` | Raw AAC access units without ADTS headers | + +Receivers must reject unsupported codec values. Because START can be lost on a +best-effort mesh, a receiver may establish a v1 assembly from a valid data +packet and use the sole v1 codec. + +### Data (`flags = 0x00`) + +The payload contains one to eight length-prefixed AAC access units: + +```text +[length: UInt16 BE][AAC access unit] ... +``` + +Each length must be non-zero and must not extend past the packet. A sender uses +sequence `1` for the first data packet and increments the value for each later +data packet. The sequence counts data packets, not individual AAC frames. + +The v1 sender budget is 210 bytes for the entire `VoiceBurstPacket`. This keeps +one live packet below the transport's fragmentation threshold after the Noise +type byte, authentication tag, and BLE padding are applied. An encoded frame +that cannot fit this budget is dropped rather than fragmented. + +### END (`flags = 0x02`) + +```text +[totalDataPackets: UInt16 BE][durationMs: UInt32 BE] +``` + +`totalDataPackets` is the number of data packets emitted for the burst; it does +not include START or END. `durationMs` describes the encoded audio duration. +The END packet uses the next sequence value after the final data packet. +Receivers use the total to account for tail loss before finalizing playback. + +### CANCELED (`flags = 0x04`) + +CANCELED has no payload and uses the next sequence value. A receiver stops live +playback, discards buffered audio, removes the transient message, and does not +wait for a finalized voice note. + +## Sender lifecycle + +A successful live gesture follows this order: + +1. Generate one eight-byte burst ID. +2. Emit START at sequence `0` after the first encoded AAC access unit becomes + available. +3. Emit data packets starting at sequence `1`. +4. On release, flush pending data and emit END. +5. Send the finalized M4A through the existing voice-note transfer path. + +The finalized file name is `voice_.m4a`, where the hexadecimal +component is the 16 lowercase characters representing the same eight-byte +burst ID. This lets the receiver replace the transient live capture with the +reliable final voice note without adding another wire field. + +If capture is canceled or does not produce a valid recording, the sender emits +CANCELED and does not send a final note. + +AAC-LC at 16 kHz uses 1,024 samples per access unit, so each encoded frame +represents 64 ms of audio. Live access units are ADTS-less; receivers may add an +ADTS header locally for streaming playback or temporary-file assembly. + +## Receiver safety and ordering + +Receivers must fail closed on malformed framing and must bound concurrent +assemblies, buffered out-of-order packets, bytes per burst, and inbound byte +rate. The Android/Wear OS v1 bounds are: + +- 8 concurrent assemblies; +- 384 KiB per burst; +- 6,000 inbound bytes per second per assembly, with a two-second initial + allowance; and +- 128 buffered out-of-order packets. + +Duplicate and already-delivered sequence values are ignored. Android/Wear OS +wait 550 ms for a sequence gap before skipping it and finalize an idle partial +burst after 3 seconds. These timers are receiver policy rather than additional +wire fields. + +## Relay behavior + +Public `VOICE_FRAME` packets use the normal mesh relay path only after signature, +sender, timestamp, recipient, and burst validation succeeds. Relays add a small +8–25 ms jitter. In a mesh larger than six peers, the relayed TTL is capped at +five after the normal per-hop decrement. Live packets remain excluded from +gossip sync and file-transfer retransmission. + +Private frames retain the existing `NOISE_ENCRYPTED` relay behavior because an +intermediate node cannot inspect the `0x08` inner type. + +## Golden vectors + +For burst ID `01 02 03 04 05 06 07 08`, the canonical encodings are: + +```text +# START, sequence 0, AAC-LC/16 kHz/mono +01 02 03 04 05 06 07 08 00 00 01 01 + +# One data packet, sequence 1, one three-byte frame DE AD BE +01 02 03 04 05 06 07 08 00 01 00 00 03 DE AD BE + +# END, sequence 42, 41 data packets, duration 2688 ms +01 02 03 04 05 06 07 08 00 2A 02 00 29 00 00 0A 80 + +# CANCELED, sequence 3 +01 02 03 04 05 06 07 08 00 03 04 +``` + +The corresponding executable Android vectors live in +`VoiceBurstPacketTest.encodedGoldenVectorsMatchIos`. + +## Implementation references + +- `app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt` +- `app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt` +- `app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt` +- `app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt` +- `app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt` diff --git a/docs/NOISE_PEER_ID_BINDING.md b/docs/NOISE_PEER_ID_BINDING.md new file mode 100644 index 00000000..a6c23a0c --- /dev/null +++ b/docs/NOISE_PEER_ID_BINDING.md @@ -0,0 +1,62 @@ +# Noise peer-ID binding + +Mesh wire peer IDs are exactly 16 lowercase hexadecimal characters derived as +`hex(SHA-256(noiseStaticPublicKey)[0..<8])`. Android enforces that binding at +both identity entry points: + +- A verified announcement must carry a 32-byte Noise static key whose derived + ID matches both the packet sender and routed sender. +- A Noise XX initiator or responder must authenticate a remote static key whose + derived ID matches the claimed session key before transport ciphers are + exposed or an authentication callback runs. + +Inbound rehandshakes use a separate responder candidate. An established +session remains active until the candidate completes and passes the binding; +failure or mismatch destroys only the candidate. BLE mappings, Wi-Fi socket +rebinds, gossip, sync, and peer-last-seen effects run only after announcement +validation succeeds. A Wi-Fi discovery identity is not destructively rebound +from a self-signed announce. A direct announce creates only a provisional claim +and starts a fresh replacement handshake on that exact transport generation. +The alias is promoted only when the same challenged, still-active socket +delivers the Noise frame that completes bound authentication. A peer-ID-only, +unsolicited, cross-link, or stale-socket callback cannot authorize that rebind; +the expected-socket comparison and alias mutation are atomic with socket +replacement. +Promotion also refuses to displace a different live socket already authenticated +under the canonical peer ID. + +Leave packets use the existing signed wire format and are accepted only when +the signature matches the key learned from a verified announcement and the +timestamp is within the five-minute security window. Invalid, stale, future, +or unsigned leaves therefore cannot evict the claimed peer or be relayed. A +valid leave removes the peer through the normal peer-manager path, which also +clears its active Noise session. + +Announcements no longer write fingerprint mappings. Those mappings are created +only by the authenticated Noise-session callback. A known peer's signing key +also cannot change based on an announcement or merely because some session for +that peer ID is active. Rotation requires an authenticated peer-state proof +tied to the exact Noise channel; ambient session presence is not enough. +The same authenticated callback restores any existing Noise-key-to-Nostr +relationship under the canonical 16-hex mesh ID; unproven announcements never +write that routing index. + +Generation-sensitive consumers use the 32-byte Noise handshake hash as a local +session token. The hash is cloned before handshake-state zeroization, and +session lookup, decrypt, expected-token encrypt, and leased identity mutation +share the Noise manager lock. A same-static replacement therefore cannot reuse +the previous generation's proof or destroy a session while a bound mutation is +in progress. + +## Remaining TOFU boundary + +The first public announcement is still self-signed trust-on-first-use. An +attacker can copy a public Noise key and self-sign an announcement, but cannot +complete the bound Noise handshake for that ID. Public-mesh identity admission +is intentionally not gated behind an automatic handshake in this change; doing +so is a separate availability/protocol decision. + +Consequently, discovery metadata or capability bits in an announcement are +hints, not proof of Noise-key possession. Security-sensitive capabilities must +be confirmed inside the authenticated Noise channel before they are pinned or +used to authorize a downgrade-sensitive behavior. diff --git a/docs/PRIVATE_MEDIA_V1.md b/docs/PRIVATE_MEDIA_V1.md new file mode 100644 index 00000000..5fbe0b95 --- /dev/null +++ b/docs/PRIVATE_MEDIA_V1.md @@ -0,0 +1,168 @@ +# Private media v1 interoperability and migration + +Private media reuses the canonical `BitchatFilePacket` TLV. A capable sender +wraps the complete encoded file TLV as Noise payload type `0x20`, encrypts it +for the recipient, and only then fragments the final outer packet. + +## Encrypted wire contract + +- Outer packet type: `MessageType.NOISE_ENCRYPTED` (`0x11`). +- Decrypted Noise payload type: `NoisePayloadType.FILE_TRANSFER` (`0x20`). +- Noise payload data: one complete encoded `BitchatFilePacket`. +- Outer recipient: the target peer ID; never broadcast for private media. + +Prerelease iOS builds of #1434 briefly emitted the inner file type as `0x09`. +Android accepts that value on decode and immediately canonicalizes it to +`NoisePayloadType.FILE_TRANSFER`; every Android encode remains `0x20`. Do not +allocate or emit a second Noise payload type for this format. + +The decode-only `0x09` alias may be removed only after every TestFlight/internal +build that emitted it has expired and the project's minimum-supported-client +policy excludes those builds. Track that release criterion explicitly; do not +remove the alias on an arbitrary calendar date. + +## Discovery hint + +Identity announcement TLV `0x05` is a minimal little-endian bitfield. Bit 8 +means the peer implements private-media v1, so its exact encoding is: + +```text +05 02 00 01 +| | |----- capability bytes: 0x0100 little-endian +| |-------- value length +|----------- capabilities TLV +``` + +Current Android builds include this TLV in broadcast and peer-directed +announcements over both BLE and Wi-Fi Aware. Older clients safely skip the +unknown TLV. Its absence, or a present TLV with bit 8 clear, does not invalidate +the announcement. + +Decoders retain unknown low-64-bit capability bits and unknown announcement +TLVs so a decode/re-encode cycle does not erase newer extensions. + +The announcement bit is discovery metadata only. A self-signed announcement +does not prove possession of its public Noise key, so it never authorizes +encrypted media, creates a capability pin, or satisfies a pending send. + +## Authenticated peer state (`0x21`) + +Every newly authenticated Noise generation, including a rekey with the same +static key, exchanges a fresh peer-state proof. Its decrypted Noise payload +type is `0x21`, followed by this canonical byte sequence: + +```text +01 version +01 capabilities, minimal little-endian +02 20 <32 bytes> Ed25519 signing public key +``` + +Both known TLVs are required exactly once. Decoders reject an unknown version, +truncated TLV, duplicate known TLV, a capability length outside `1...8`, a +non-minimal capability value, or an Ed25519 key whose length is not 32. Unknown +TLVs are skipped for forward compatibility, and the two known TLVs may arrive +in either order. + +Each endpoint sends `0x21` when the generation authenticates and echoes its +local state at most once after accepting the peer's first valid proof for that +generation. Repeated identical proofs are idempotent; a different second proof +cannot replace the first within that generation. A five-second generation +watchdog distinguishes an older client that ignores `0x21` from a new client +that supplied a proof. Persisted state from a previous connection never +satisfies the fresh-generation watchdog. + +Locally, the Noise handshake hash is the generation token. Decryption returns +that token with the plaintext, coordinator mutations hold a lease on that exact +session, and private-media encryption accepts the policy decision only while +the same token remains active. A same-static rekey therefore cannot reuse an +older proof or race policy into encrypting on an unproved generation. + +The proof is bound by the Noise channel to the exact authenticated 32-byte +remote static key and canonical peer ID. Store its capabilities and 32-byte +Ed25519 key under the SHA-256 fingerprint of that static key in encrypted +identity state. A proof with bit 8 creates an HSTS-style private-media pin; a +later no-bit proof does not erase that history. Panic wipe clears both records +and prevents an in-flight pre-wipe controller from restoring them. + +The persisted Ed25519 key is consulted before accepting later announcements. +This lets a valid proof recover from a copied-static, wrong-Ed preannouncement, +while preventing that preannouncement from winning again after restart. A +fresh proof in a later Noise generation may intentionally rotate the Ed25519 +key; an announcement by itself cannot. + +## Mixed-client send policy + +- No live authenticated Noise remote-static key: retain the first send intent, + emit no media or local echo, and initiate one Noise handshake. +- Live generation waiting for `0x21`: retain that same intent while the + five-second peer-state watchdog runs. +- Fresh proof with bit 8: automatically retry the retained intent and send + encrypted `0x11` / `0x20` after final-packet admission. +- Fresh proof without bit 8, or a five-second no-proof timeout for an unpinned + old client: retry the retained intent by showing the existing explicit + one-shot legacy consent prompt. +- A previously pinned identity that proves no bit, or fails to prove state in + the current generation, is visibly blocked. It cannot silently fall back. + +The exact automatic intent is reserved before its first policy evaluation, so a +proof or timeout callback racing that evaluation cannot be lost. It is bounded +and singular, and expires after 15 seconds with a visible system message. +Retries are serialized and always re-run current policy and exact packet +admission. No waiting, rejected, expired, or cancelled attempt creates a local +file echo or transmits raw media; terminal rejection is shown as a system +message rather than failing silently. + +The legacy consent path sends a recipient-directed raw +`MessageType.FILE_TRANSFER` (`0x22`) packet. Its contents are visible to relays, +so the UI must say that it is not end-to-end encrypted. The final routed packet +must carry a valid Ed25519 signature over the canonical packet bytes; signing +failure aborts the send. Receivers reject unsigned or invalid signed directed +raw files. + +Consent is consumed at most once. On approval the sender re-runs the policy and +packet admission checks. If a bit-8 proof arrived while the dialog was open, +the send upgrades to encrypted mode. Cancellation, duplicate approval, panic +wipe, and changed security state cannot cause a later send. + +## Final-packet admission + +Before creating a local echo or progress mapping, the sender builds the exact +encrypted-or-legacy packet, attaches its final source route, signs it, and +creates the exact transport fragment plan. Commit sends that prepared plan +without rebuilding it. + +One packet may use at most 256 fragments. This limit is checked after route, +signature, encryption, and envelope overhead are known; therefore there is no +single safe file-byte estimate for every route. Fragment totals and indices +must also fit their unsigned 16-bit wire fields without truncation. A rejected +plan creates no local echo and sends no fragments. + +The 256-fragment limit is a transport/reassembly safety bound, not a capability +negotiated through bit 8. A future larger transfer protocol needs a separate +capability and bounded streaming design. + +## Compatibility summary + +- New Android to new iOS/Android: generation-scoped authenticated `0x21` + exchange, then encrypted Noise `0x20`. +- Prerelease iOS to new Android: encrypted Noise `0x09` is decoded, + canonicalized to `0x20`, and delivered during the migration window. +- New Android to an older client: the unknown `0x21` is ignored; after the + five-second watchdog, an unpinned identity may use one explicitly consented, + relay-visible signed raw `0x22` transfer. +- Old clients receiving a new announcement: ignore TLV `0x05` and continue + operating normally. Old clients receiving `0x21` drop the unknown inner type + without affecting their existing Noise session or private messages. +- A previously capable authenticated identity cannot force a silent downgrade + by omitting `0x21`, clearing the bit, or changing only its announcement. + +Do not remove the `0x21` watchdog/legacy-consent migration path until the +minimum-supported Android and iOS versions both emit an authenticated bit-8 +`0x21` proof on every Noise generation, and the released legacy population has +aged out under the project's explicit support policy. Merely observing an +announcement bit or waiting for an arbitrary date is not sufficient. The HSTS +pin remains necessary after that point; removing old-client consent must not +re-enable a raw automatic fallback. + +Public media remains signed broadcast `MessageType.FILE_TRANSFER` (`0x22`) and +is outside this private-media capability. diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md new file mode 100644 index 00000000..1fdd2c6f --- /dev/null +++ b/docs/SOURCE_ROUTING.md @@ -0,0 +1,142 @@ +# Source-Based Routing for BitChat Packets (v2) + +This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays. + +**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). + +--- + +## 1. Protocol Versioning & Layering + +To support source routing and larger payloads, the packet format has been upgraded to **Version 2**. + +* **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags. +* **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing. + +**Key Rule:** The `HAS_ROUTE (0x08)` flag is **only valid** if the packet `version >= 2`. Relays receiving a v1 packet must ignore this flag even if set. + +--- + +## 2. Packet Structure Comparison + +The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet. + +### V1 Packet (Legacy) +```text ++-------------------+---------------------------------------------------------+ +| Fixed Header (14) | Variable Sections | ++-------------------+----------+-------------+------------------+-------------+ +| Ver: 1 (1B) | SenderID | RecipientID | Payload | Signature | +| Type, TTL, etc. | (8B) | (8B) | (Length in Head) | (64B) | +| Len: 2 Bytes | | (Optional) | | (Optional) | ++-------------------+----------+-------------+------------------+-------------+ +``` + +### V2 Packet (Source Routed) +```text ++-------------------+-----------------------------------------------------------------------------+ +| Fixed Header (16) | Variable Sections | ++-------------------+----------+-------------+-----------------------+------------------+-------------+ +| Ver: 2 (1B) | SenderID | RecipientID | SOURCE ROUTE | Payload | Signature | +| Type, TTL, etc. | (8B) | (8B) | (Variable) | (Length in Head) | (64B) | +| Len: 4 Bytes | | (Required*) | Only if HAS_ROUTE=1 | | (Optional) | ++-------------------+----------+-------------+-----------------------+------------------+-------------+ +``` + +**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set). + +### Fixed Header Differences + +| Field | Size (v1) | Size (v2) | Description | +|---|---|---|---| +| **Version** | 1 byte | 1 byte | `0x01` vs `0x02` | +| **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. | +| **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. | + +--- + +## 3. Source Route Specification + +The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse. + +* **Location:** Immediately follows `RecipientID`. +* **Structure:** + * `Count` (1 byte): Number of intermediate hops (`N`). + * `Hops` (`N * 8` bytes): Sequence of Peer IDs. + +### Intermediate Hops Only +The route list MUST contain **only** the intermediate relays between the sender and the recipient. +* **DO NOT** include the `SenderID` (it is already in the packet). +* **DO NOT** include the `RecipientID` (it is already in the packet). + +**Example:** +Topology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)` +* Packet `SenderID`: Alice +* Packet `RecipientID`: Dave +* Packet `Route`: `[Bob, Charlie]` (Count = 2) + +--- + +## 4. Topology Discovery (Gossip) + +To calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet. + +* **Mechanism:** `IdentityAnnouncement` packets contain a TLV (Type-Length-Value) payload. +* **New TLV Type:** `0x04` (Direct Neighbors). +* **Content:** A list of Peer IDs that the announcing node is directly connected to. + +**TLV Structure (Type 0x04):** +```text +[Type: 0x04] [Length: 1B] [Count: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ... +``` +Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors. + +### Edge Verification (Two-Way Handshake) + +To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification: + +* **Unconfirmed Edge:** If Peer A announces Peer B, but Peer B does *not* announce Peer A, the connection is treated as **unconfirmed**. Unconfirmed edges are visualized as dotted lines in debug tools but are **excluded** from route calculations. +* **Confirmed Edge:** An edge is only valid for routing when **both** peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives. + +--- + +## 5. Fragmentation & Source Routing + +When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation: + +1. **Version Inheritance:** All fragments MUST be marked as **Version 2**. +2. **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet. + +**Why?** If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers. + +--- + +## 6. Security & Signing + +Source routing is fully secured by the existing Ed25519 signature scheme. + +* **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload). +* **Verification:** The receiver verifies the signature against the `SenderID`'s public key. +* **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination. + +**Signature Input Construction:** +Serialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes. + +--- + +## 7. Relay Logic + +When a node receives a packet **not** addressed to itself: + +1. **Check Route:** + * Is `Version >= 2`? + * Is `HAS_ROUTE` flag set? + * Is the route list non-empty? +2. **If YES (Source Routed):** + * Find local Peer ID in the route list at index `i`. + * **Next Hop:** The peer at `i + 1`. + * **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`. + * **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop. + * **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery. +3. **If NO (Standard):** + * Flood the packet to all connected neighbors (subject to TTL and probability rules). diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md new file mode 100644 index 00000000..05d5e1ad --- /dev/null +++ b/docs/client-rewrite-contracts.md @@ -0,0 +1,68 @@ +# Client rewrite compatibility contracts + +This document defines the behavior a from-scratch BitChat client must preserve. +The executable source of truth is the JVM test suite under +`app/src/test/**/contracts`, together with the pre-existing protocol, security, +mesh, and state tests. + +The remaining implementation work and milestone progress are tracked in +[test-implementation-plan.md](test-implementation-plan.md). + +## Required contract layers + +| Layer | Compatibility promise | Primary tests | +|---|---|---| +| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` | +| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` | +| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | +| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` | +| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | +| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | +| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` | + +## Golden-vector policy + +Golden vectors compare literal externally visible bytes or hashes. Do not update +them merely because an implementation changed. Update a vector only when the +wire protocol is intentionally versioned and interoperating clients are updated +together. + +Round-trip tests remain useful but are not sufficient on their own: an encoder +and decoder can share the same defect. Each critical wire format therefore has +at least one literal vector. + +NIP-17 receivers should reserve safety slack beyond the maximum timestamp +randomization used by senders. Android caps outbound seal and gift-wrap +randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour +subscription window, while retaining its 48-hour receive lookback. + +## Rewrite acceptance gate + +From a configured Android development environment, run: + +```sh +./gradlew clientRewriteContractTest +``` + +The task runs the new golden vectors and the complete existing unit suite. A +rewrite is compatible only when this gate passes. Tests should be ported +unchanged when package boundaries change; adapter façades are preferable to +weakening assertions. + +## Device-only acceptance + +Local JVM tests cannot prove Android radio and lifecycle behavior. Before +shipping a rewrite, run the following on at least two physical devices: + +1. BLE discovery, connection, disconnect, reconnect, and multi-hop relay. +2. Runtime permission denial/retry for Bluetooth, location, notifications, and + microphone. +3. Foreground-service survival with the screen off and after process recreation. +4. Cross-client Android/iOS exchange for announce, public/private text, delivery + and read receipts, image/audio/file transfer, sync replay, and Nostr fallback. +5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments. +6. Identity rotation, verification continuity, downgrade rejection, and recovery + after stale Noise sessions. + +Those scenarios belong in instrumented tests or a two-device interoperability +harness; they must not be represented as passing JVM mocks. diff --git a/docs/device-transport-test-matrix.md b/docs/device-transport-test-matrix.md new file mode 100644 index 00000000..8ada4018 --- /dev/null +++ b/docs/device-transport-test-matrix.md @@ -0,0 +1,113 @@ +# Physical transport validation matrix + +This is the device-only companion to the deterministic Milestone 4 transport +suite. It validates that the fake adapters, Robolectric behavior, and pure +state machines match Android framework behavior. It is also consumed by the +Milestone 10 release gate. + +## Required device set + +- Two physical Android devices from different manufacturers. +- At least one Android 13+ device for `NEARBY_WIFI_DEVICES`. +- At least one device that supports Wi-Fi Aware. +- Bluetooth LE central and peripheral support on both devices. +- A build from the exact commit under test installed on both devices. +- Clean app data before the first run; retain a second run for restart tests. + +Record device models, API levels, build commit, negotiated MTUs, and timestamps +in the release artifact. Do not record user names, device serials, Bluetooth +addresses, IP addresses, peer IDs, message contents, or other identifying +values. + +## BLE discovery and recovery + +- [ ] Start both clients and confirm each begins scanning and advertising. +- [ ] Stop and restart the foreground service; confirm exactly one scanner and + advertiser generation remains active. +- [ ] Toggle Bluetooth off during scanning, then on; confirm scanning, + advertising, announcements, and peer discovery recover without process + restart. +- [ ] Disable and re-enable the BLE debug transport; confirm the same service + instance can recover without duplicate callbacks. +- [ ] Rotate the observed BLE address by restarting advertising; confirm the + canonical peer remains singular. +- [ ] Trigger a transient scan failure or Android Bluetooth process restart; + confirm bounded retry and watchdog recovery. +- [ ] Confirm permission denial reports unavailable state without a crash, + prompt loop, or active radio work. + +## GATT setup and teardown + +- [ ] Connect in both directions simultaneously and confirm one canonical link + survives. +- [ ] Record the negotiated MTU and repeat at 23, 247, and 517 where the device + or test peripheral allows it. +- [ ] Remove the service, characteristic, or CCCD in a test peripheral and + confirm setup fails closed. +- [ ] Reject notification registration and descriptor writes; confirm the peer + is never published ready. +- [ ] Disconnect during MTU negotiation, service discovery, subscription, + client write, and server notification; confirm no stale ready callback. +- [ ] Leave setup incomplete for more than 30 seconds; confirm timeout and + resource closure. +- [ ] Connect beyond configured client, server, and total limits; confirm + deterministic oldest-link eviction. + +## Packet delivery and fragmentation + +- [ ] Send directed and broadcast packets over client and server roles. +- [ ] Saturate each link faster than radio completion callbacks; confirm one + outstanding operation, bounded backpressure, and no reordered frames. +- [ ] Inject a failed `onCharacteristicWrite` and `onNotificationSent`; confirm + queued work is discarded and the failed generation is cleaned up. +- [ ] Transfer payloads immediately below and above the fragmentation boundary. +- [ ] Transfer a maximum admitted private-media payload at negotiated MTU 517. +- [ ] At MTU 247 and 23, confirm oversized frames are rejected rather than + partially sent. Adaptive per-link fragmentation remains tracked as + `TDB-023`. +- [ ] Disconnect and reconnect halfway through a fragmented transfer; confirm + incomplete state expires and a fresh transfer can finish. +- [ ] Cancel a queued transfer and stop the service during another; confirm no + later fragments or progress callbacks. + +## Wi-Fi Aware + +- [ ] Confirm unsupported hardware and temporarily unavailable radio states are + distinct. +- [ ] Deny and grant `NEARBY_WIFI_DEVICES`; confirm publish/subscribe work only + after grant. +- [ ] Start and stop publish and subscribe sessions repeatedly; confirm no + duplicate discovery callbacks. +- [ ] Authenticate a provisional socket and promote it to the canonical peer. +- [ ] Replace a socket while authentication is in flight; confirm the stale + socket cannot promote or deliver. +- [ ] Toggle Wi-Fi, location, and airplane mode; confirm rediscovery and bounded + reconnect after availability returns. +- [ ] Stop the service with active sockets, server sockets, and network + callbacks; confirm all are closed or unregistered. + +## Unified transport and failover + +- [ ] Connect the same peer over BLE and Wi-Fi Aware; confirm one peer-list row. +- [ ] Send with both transports active; confirm the preferred transport is used. +- [ ] Drop the preferred transport during a transfer and confirm defined + failover behavior without duplicate application delivery. +- [ ] Relay between transports and confirm TTL decreases once per hop. +- [ ] Reflect a bridged packet back over the other transport; confirm loop and + duplicate suppression. +- [ ] Stop the foreground service; confirm scans, advertisements, sessions, + sockets, operation queues, transfer jobs, and callbacks all terminate. + +## Evidence template + +| Field | Value | +|---|---| +| Commit | | +| Device/API classes | | +| BLE central/peripheral | Pass / Fail | +| MTU cases | Pass / Fail / Unsupported | +| BLE recovery | Pass / Fail | +| Wi-Fi Aware lifecycle | Pass / Fail / Unsupported | +| Cross-transport failover | Pass / Fail | +| Shutdown leak check | Pass / Fail | +| Bugs filed | | diff --git a/docs/device_manager.md b/docs/device_manager.md new file mode 100644 index 00000000..c42cd2c5 --- /dev/null +++ b/docs/device_manager.md @@ -0,0 +1,112 @@ +# Device Monitoring Manager — Design and Integration + +This change introduces a lean DeviceMonitoringManager to strictly manage BLE device connections while keeping the existing code structure intact. + +## Goals + +- Maintain a blocklist of device MAC addresses to deny incoming/outgoing connections. +- Drop and block connections that never ANNOUNCE within 15 seconds of establishment. +- Drop and block connections that go silent (no packets) for over 60 seconds. +- Block devices that experience 5 error disconnects within a 5-minute window. +- Auto-unblock devices after 15 minutes. + +## Implementation Overview + +File: `app/src/main/java/com/bitchat/android/mesh/DeviceMonitoringManager.kt` + +- Thread-safe maps with coroutine-based timers. +- Minimal surface area: a few clearly named entry points to hook into existing flows. +- Callbacks to perform disconnects without coupling to GATT APIs. + +Key logic: +- `isBlocked(address)`: check if a MAC is blocked (auto-clears on expiry). +- `block(address, reason)`: add MAC to blocklist (15m), disconnect via callback, auto-unblock later. +- `onConnectionEstablished(address)`: start 15s “first ANNOUNCE” timer and a 60s inactivity timer. +- `onAnnounceReceived(address)`: cancel the 15s ANNOUNCE timer for that device. +- `onAnyPacketReceived(address)`: refresh 60s inactivity timer. +- `onDeviceDisconnected(address, status)`: track error disconnects and block on 5 within 5 minutes. + +Timers: +- ANNOUNCE timer: 15 seconds from connection establishment. +- Inactivity timer: resets on any packet; fires after 60 seconds of silence. +- Blocklist TTL: 15 minutes per device (auto-unblock job per entry). + +## Wiring Points (Minimal Changes) + +1) Connection Manager +- File: `BluetoothConnectionManager.kt` +- Added a `DeviceMonitoringManager` instance and provided a `disconnectCallback` that: + - disconnects client GATT connections via `BluetoothConnectionTracker`. + - cancels server connections via `BluetoothGattServer.cancelConnection`. +- Updated `componentDelegate.onPacketReceived` to notify per-device activity to the monitor. + +2) GATT Client +- File: `BluetoothGattClientManager.kt` +- Constructor now receives `deviceMonitor`. +- Before attempting any outgoing connection (from scan or direct connect), deny if blocked. +- On connection setup complete (after CCCD enable), call `deviceMonitor.onConnectionEstablished(addr)`. +- On incoming packet (`onCharacteristicChanged`), call `deviceMonitor.onAnyPacketReceived(addr)`. +- On disconnect, call `deviceMonitor.onDeviceDisconnected(addr, status)` to track error bursts. + +3) GATT Server +- File: `BluetoothGattServerManager.kt` +- Constructor now receives `deviceMonitor`. +- On incoming connection, immediately deny (cancelConnection) if blocked, before tracking it. +- On connection setup complete (descriptor enable) and also after initial connect, start monitoring via `onConnectionEstablished(addr)`. +- On packet write, call `deviceMonitor.onAnyPacketReceived(addr)`. +- On disconnect, call `deviceMonitor.onDeviceDisconnected(addr, status)`. + +4) ANNOUNCE Binding +- File: `BluetoothMeshService.kt` (in the ANNOUNCE handler where we first map device → peer) + +## Behavior Summary + +- Blocked devices: + - Outgoing: client will not initiate connections. + - Incoming: server cancels the connection immediately. + - Existing connection: monitor disconnects instantly and blocks for 15 minutes. + +- No ANNOUNCE within 15s of connection: + - Connection is dropped and device is blocked for 15 minutes. + +- No packets for >60s: + - Connection is dropped and device is blocked for 15 minutes. + +- >=5 error disconnects within 5 minutes: + - Device is blocked for 15 minutes. + +- Auto-unblock: + - Every block entry automatically expires after 15 minutes. + +## Debug Logging + +- The manager emits chat-visible debug messages through `DebugSettingsManager` (SystemMessage), e.g.: + - Blocking decisions and reasons + - Auto-unblock events + - ANNOUNCE wait start/cancel + - Inactivity timer set and inactivity-triggered blocks + - Burst error disconnect threshold reached +- Additional enforcement logs are added in GATT client/server when a blocked device is denied. +- Logs appear in the chat when verbose logging is enabled in Debug settings. + +## Panic Triple-Tap + +- Triple-tapping the title now also clears the device blocklist and all device tracking: + - Calls `BluetoothMeshService.clearAllInternalData()` which triggers `BluetoothConnectionManager.clearDeviceMonitoringAndTracking()`. + - This disconnects active connections, clears the monitor’s blocklist and timers, and resets the `BluetoothConnectionTracker` state. + +## Notes and Rationale + +- The monitoring manager is intentionally decoupled from GATT specifics via a disconnect callback. This keeps responsibilities separate and avoids plumbing GATT instances through unrelated classes. +- Packet activity is captured in both client and server data paths as early as possible to ensure the inactivity timer is accurate even before higher-level processing. +- The “first ANNOUNCE” check uses the same mapping event that sets `addressPeerMap` to avoid false positives on unverified announces. + +## Touched Files + +- Added: `mesh/DeviceMonitoringManager.kt` +- Updated: `mesh/BluetoothConnectionManager.kt` +- Updated: `mesh/BluetoothGattClientManager.kt` +- Updated: `mesh/BluetoothGattServerManager.kt` +- Updated: `mesh/BluetoothMeshService.kt` + +These changes are small, local, and respect existing structure without broad refactors. diff --git a/docs/file_transfer.md b/docs/file_transfer.md new file mode 100644 index 00000000..8422b77a --- /dev/null +++ b/docs/file_transfer.md @@ -0,0 +1,504 @@ +# Bitchat Bluetooth File Transfer: Images, Audio, and Generic Files (with Interactive Features) + +This document is the exhaustive implementation guide for Bitchat’s Bluetooth file transfer protocol for voice notes (audio) and images, including interactive features like waveform seeking. It describes the on‑wire packet format (both v1 and v2), fragmentation/progress/cancellation, sender/receiver behaviors, and the complete UX we implemented in the Android client so that other implementers can interoperate and match the user experience precisely. + +**Protocol Versions:** +- **v1**: Original envelope with a 2-byte payload length. +- **v2**: Extended envelope with a 4-byte payload length. +- Public and legacy raw file-transfer envelopes use v2. A Noise-encrypted + private envelope may use v1 when its ciphertext fits, and source routing + upgrades the final envelope to v2. +- The payload-length field is not the practical transfer limit. Private-media + admission is limited to 256 final fragments after route, signature, + encryption, and envelope overhead. Generic/public outbound fragmentation + retains the UInt16 wire range; receivers may impose a lower safety bound. + +**Interactive Features:** +- **Waveform Seeking**: Tap anywhere on audio waveforms to jump to that playback position +- **Bounded Transfer Support**: v2 removes the 16-bit envelope-length limit, + while bounded fragmentation protects receivers from unbounded reassembly. +- **Unified Experience**: Identical UX between platforms with enhanced user control + +The guide is organized into: + +- Protocol overview (BitchatPacket + File Transfer payload) +- Fragmentation, progress reporting, and cancellation +- Receive path, validation, and persistence +- Sender path (audio + images) +- Interactive features (audio waveform seeking) +- UI/UX behavior (recording, sending, playback, image rendering) +- File inventory (source files and their roles) + + +--- + +## 1) Protocol Overview + +Bitchat BLE transport carries application messages inside the common `BitchatPacket` envelope. File transfer reuses the same envelope as public and private messages, with a distinct `type` and a TLV‑encoded payload. + +### 1.1 BitchatPacket envelope + +Fields (subset relevant to file transfer): + +- `version: UByte` — protocol version (`1` for v1, `2` for v2 with extended payload length). +- `type: UByte` — public file transfer uses `MessageType.FILE_TRANSFER (0x22)`; + private file transfer uses outer `MessageType.NOISE_ENCRYPTED (0x11)`. +- `senderID: ByteArray (8)` — 8‑byte binary peer ID. +- `recipientID: ByteArray (8)` — 8‑byte recipient. For public: `SpecialRecipients.BROADCAST (0xFF…FF)`; for private: the target peer’s 8‑byte ID. +- `timestamp: ULong` — milliseconds since epoch. +- `payload: ByteArray` — the public form contains the file TLV directly. The + private form contains Noise ciphertext which authenticates payload type + `FILE_TRANSFER (0x20)` followed by the same file TLV. +- `signature: ByteArray?` — packet signature added by the mesh send path. +- `ttl: UByte` — hop TTL (we use `MAX_TTL` for broadcast, `7` for private). + +Envelope creation and broadcast paths are implemented in: + +- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt) +- `app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt) +- `app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt) + +Private sends encrypt the complete file TLV as Noise payload `0x20` before +outer fragmentation. See `PRIVATE_MEDIA_V1.md` for the capability and mixed- +client interoperability contract. + +### 1.2 Binary Protocol Extensions (v2) + +#### v2 Header Format Changes + +**v1 Format (original):** +``` +Header (13 bytes): +Version: 1 byte +Type: 1 byte +TTL: 1 byte +Timestamp: 8 bytes +Flags: 1 byte +PayloadLength: 2 bytes (big-endian, max 64 KiB) +``` + +**v2 Format (extended):** +``` +Header (15 bytes): +Version: 1 byte (set to 2 for v2 packets) +Type: 1 byte +TTL: 1 byte +Timestamp: 8 bytes +Flags: 1 byte +PayloadLength: 4 bytes (big-endian, max ~4 GiB) +``` + +- **Header Size**: Increased from 13 to 15 bytes. +- **Payload Length Field**: Extended from 16 bits (2 bytes) to 32 bits (4 + bytes). That is the field's theoretical range, not the permitted mesh + reassembly size. +- **Backward Compatibility**: Clients must support both v1 and v2 decoding. +- **Implementation**: See `BinaryProtocol.kt` with `getHeaderSize(version)` logic. + +#### Use cases for v2 + +v2 carries file payloads that exceed the v1 envelope-length field and carries +source-route metadata. It does not imply multi-gigabyte mesh transfer support. + +#### Interoperability Requirements +- Clients receiving v2 packets must decode 4-byte `PayloadLength` fields. +- Clients sending file transfers should preferentially use v2 format. +- Fragmentation still applies. Each serialized mesh fragment fits the 512-byte + transport threshold; the data portion is at most 469 bytes and becomes + smaller when recipient or source-route overhead is present. + +#### Compressed expansion rollout gate (resolved) + +Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer +message type. It also requires a non-empty compressed body, an exact declared output size, and a +complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a larger ceiling: it is +attacker-controlled before packet signature verification, and the current receive pipeline must +inflate before it can perform that verification. + +New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode +any payload above the receiver ceiling; exceeding the cap surfaces a user-visible error in chat. +Support for legacy >10 MiB compressed transfers is explicitly ended: legacy Android senders can +still produce a highly compressible public file between 10 MiB and their 50 MiB UI limit that the +bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type check: inflation +allocates the declared payload and +`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak +for a maximum-size transfer. + +Before enabling that legacy range, receive processing needs an authenticated admission decision +made before large allocation plus streaming inflation/TLV parsing into a bounded temporary file (or +another ownership-preserving design that avoids the second full-size copy). The sender limit and a +wire capability/version transition must then be coordinated so old and new clients fail predictably. + +### 1.3 File Transfer TLV payload (BitchatFilePacket) + +The file payload is a TLV structure with mixed length field sizes to support large contents efficiently. + +- Defined in `app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt) + +Canonical TLVs (v2 spec): + +- `0x01 FILE_NAME` — UTF‑8 bytes + - Encoding: `type(1) + len(2) + value` +- `0x02 FILE_SIZE` — 4 bytes (UInt32, big‑endian) + - Encoding: `type(1) + len(2=4) + value(4)` + - Note: v1 used 8 bytes (UInt64). v2 standardizes to 4 bytes. See Legacy Compatibility below. +- `0x03 MIME_TYPE` — UTF‑8 bytes (e.g., `image/jpeg`, `audio/mp4`, `application/pdf`) + - Encoding: `type(1) + len(2) + value` +- `0x04 CONTENT` — raw file bytes + - Encoding: `type(1) + len(4) + value(len)` + - Exactly one CONTENT TLV per file payload in v2 (no TLV‑level chunking); overall packet fragmentation happens at the transport layer. + +Encoding rules: + +- Standard TLVs use `1 byte type + 2 bytes big‑endian length + value`. +- CONTENT uses a 4‑byte big‑endian length to allow payloads well beyond 64 KiB. +- With the v2 envelope (4-byte payload length), CONTENT can exceed 64 KiB, but + sender and receiver fragmentation policies still bound practical transfers. +- Implementations should validate TLV boundaries; decoding should fail fast on malformed structures. + +Decoding rules (v2): + +- Accept the canonical TLVs above. Unknown TLVs should be ignored or cause failure per implementation policy (current Android rejects unknown types). +- FILE_SIZE expects `len=4` and is parsed as UInt32; receivers may upcast to 64‑bit internally. +- CONTENT expects a 4‑byte length field and a single occurrence; if multiple CONTENT TLVs are present, concatenate in order (defensive tolerance). +- If FILE_SIZE is missing, receivers may fall back to `content.size`. +- If MIME_TYPE is missing, default to `application/octet-stream`. + +Legacy Compatibility (optional, for mixed‑version meshes): + +- FILE_SIZE (0x02): Some legacy senders used 8‑byte UInt64. Decoders MAY accept `len=8` and clamp to 32‑bit if needed. +- CONTENT (0x04): Legacy payloads might have used a 2‑byte TLV length with multiple CONTENT chunks. Decoders MAY support concatenating multiple CONTENT TLVs with 2‑byte lengths if encountered. + + +--- + +## 2) Fragmentation, Progress, and Cancellation + +### 2.1 Fragmentation + +File transfers reuse the mesh broadcaster’s fragmentation logic: + +- `BluetoothPacketBroadcaster` checks if the serialized envelope exceeds the configured MTU and splits it into fragments via `FragmentManager`. +- Fragments are sent with a short inter-fragment delay (currently 20 ms). +- When only one fragment is needed, send as a single packet. +- Android receivers reject fragment sets declaring more than 256 fragments. + Private senders use that same 256-fragment limit and calculate it from the + exact final routed, signed, and encrypted packet before creating a local + echo. Generic/public outbound planning retains its prior UInt16 count range, + so this private-media migration does not silently change public sending. + +### 2.2 Transfer ID and progress events + +We derive a deterministic transfer ID to track progress: + +- `transferId = sha256Hex(packet.payload)` (hex string of the file TLV payload). + +The broadcaster emits progress events to a shared flow: + +- `TransferProgressManager.start(id, totalFragments)` +- `TransferProgressManager.progress(id, sent, totalFragments)` +- `TransferProgressManager.complete(id, totalFragments)` + +The UI maps `transferId → messageId`, then updates `DeliveryStatus.PartiallyDelivered(sent, total)` as events arrive; when `complete`, switches to `Delivered`. + +### 2.3 Cancellation + +Transfers are cancellable mid‑flight: + +- The broadcaster keeps a `transferId → Job` map and cancels the job to stop sending remaining fragments. +- API path: + - `BluetoothPacketBroadcaster.cancelTransfer(transferId)` + - Exposed via `BluetoothConnectionManager.cancelTransfer` and `BluetoothMeshService.cancelFileTransfer`. + - `ChatViewModel.cancelMediaSend(messageId)` resolves `messageId → transferId` and cancels. +- UX: tapping the “X” on a sending media removes the message from the timeline immediately. + +Implementation files: + +- `app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt) +- `app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt) +- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt) +- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt) + + +--- + +## 3) Receive Path and Persistence + +Receiver dispatch is in `MessageHandler`: + +- For both broadcast and private paths we try `BitchatFilePacket.decode(payload)`. If it decodes: + - The file is persisted under app files with type‑specific subfolders: + - Audio: `files/voicenotes/incoming/` + - Image: `files/images/incoming/` + - Other files: `files/files/incoming/` + - Filename strategy: + - Prefer the transmitted `fileName` when present; sanitize path separators. + - Ensure uniqueness by appending `" (n)"` before the extension when a name exists already. + - If `fileName` is absent, derive from MIME with a sensible default extension. + - MIME determines extension hints (`.m4a`, `.mp3`, `.wav`, `.ogg` for audio; `.jpg`, `.png`, `.webp` for images; otherwise based on MIME or `.bin`). +- A synthetic chat message is created with content markers pointing to the local path: + - Audio: `"[voice] /abs/path/to/file"` + - Image: `"[image] /abs/path/to/file"` + - Other: `"[file] /abs/path/to/file"` + - `senderPeerID` is set to the origin, `isPrivate` set appropriately. + +Files: + +- `app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt) + + +--- + +## 4) Sender Path + +### 4.1 Audio (Voice Notes) + +1) Capture + - Hold‑to‑record mic button starts `MediaRecorder` with AAC in MP4 (`audio/mp4`). + - Sample rate: 44100 Hz, channels: mono, bitrate: ~32 kbps (to reduce payload size for BLE). + - On release, we pad 500 ms before stopping to avoid clipping endings. + - Files saved under `files/voicenotes/outgoing/voice_YYYYMMDD_HHMMSS.m4a`. + +2) Local echo + - Public/channel sends create their timeline entry before dispatch. + - Private sends first finish capability policy and exact final-fragment + admission. They create the local echo and progress mapping only for an + admitted plan, then atomically commit that prepared plan. + +3) Packet creation + - Build a `BitchatFilePacket`: + - `fileName`: basename (e.g., `voice_… .m4a`) + - `fileSize`: file length + - `mimeType`: `audio/mp4` + - `content`: full bytes; final-packet admission determines whether it fits + the 256-fragment limit. + - Encode TLV; compute `transferId = sha256Hex(payload)`. + - Map `transferId → messageId` for UI progress. + +4) Send + - Public: `BluetoothMeshService.sendFileBroadcast(filePacket)`. + - Private UI: `prepareFilePrivate(...)`, followed by one-shot commit of a + ready plan. The non-interactive `sendFilePrivate(...)` entry point commits + only encrypted-ready plans and never silently chooses a legacy downgrade. + - Broadcaster handles fragmentation and progress emission. + +5) Mixed-client private migration + - A verified legacy recipient with no private-media capability requires a + user warning and one-shot consent. + - Approval rechecks policy. The fallback is recipient-directed raw `0x22`, + visible to relays, and must have a valid Ed25519 packet signature. + - No authenticated Noise identity starts a handshake without a local echo + or media send; the user retries after it completes. Signing failure or a + private plan above 256 final fragments aborts without an echo or send. + +6) Waveform + - We extract a 120‑bin waveform from the recorded file (the same extractor used for the receiver) and cache by file path, so sender and receiver waveforms are identical. + +Core files: + +- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` (sendVoiceNote) (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt) +- `app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt) +- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt) +- `app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt) +- `app/src/main/java/com/bitchat/android/features/voice/Waveform.kt` (cache + extractor) (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt) + +### 4.2 Images + +1) Selection and processing + - System picker (Storage Access Framework) with `GetContent()` (`image/*`). No storage permission required. + - Selected image is downscaled so longest edge is 512 px; saved as JPEG (85% quality) under `files/images/outgoing/img_.jpg`. + - Helper: `ImageUtils.downscaleAndSaveToAppFiles(context, uri, maxDim=512)`. + +2) Local echo + - Insert a message with `"[image] "` in the current context (public/channel/private). + +3) Packet creation + - Build `BitchatFilePacket` with mime `image/jpeg` and file content. + - Encode TLV + compute `transferId` and map to `messageId`. + +4) Send + - Same paths as audio (broadcast/private), including fragmentation and progress emission. + +Core files: + +- `app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt) +- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` (sendImageNote) (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt) +- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt) + + +--- + +## 5) UI / UX Details + +This section specifies exactly what users see and how inputs behave, so alternative clients can match the experience. + +### 5.1 Message input area + +- The input field remains mounted at all times to prevent the IME (keyboard) from collapsing during long‑press interactions (recording). We overlay recording UI atop the text field rather than replacing it. +- While recording, the text caret (cursor) is hidden by setting a transparent cursor brush. +- Mentions and slash commands are styled with a monospace look and color coding. + +Files: + +- `app/src/main/java/com/bitchat/android/ui/InputComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/InputComponents.kt) + +### 5.2 Recording UX + +- Hold the mic button to start recording. Recording runs until release, then we pad 500 ms and stop. +- While recording, a dense, real‑time scrolling waveform overlays the input showing live audio; a timer is shown to the right. + - Component: `RealtimeScrollingWaveform` (dense bars, ~240 columns, ~20 FPS) in `app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt`. + - The keyboard stays visible; the caret is hidden. +- On release, we immediately show a local echo message for the voice note and start sending. + +### 5.3 Voice note rendering + +- Displayed with a header (nickname + timestamp) then the waveform + controls row. +- Waveform + - A 120‑bin static waveform is rendered per file, identical for sender and receiver, extracted from the actual audio file. + - During send, the waveform fills left→right in blue based on fragment progress. + - During playback, the waveform fills left→right in green based on player progress. +- Controls + - Play/Pause toggle to the left of the waveform; duration text to the right. +- Cancel sending + - While sending a voice note, a round “X” cancel button appears to the right of the controls. Tapping cancels the transfer mid‑flight. + +Files: + +- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt) +- `app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt) +- `app/src/main/java/com/bitchat/android/features/voice/Waveform.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt) + +### 5.4 Image sending UX + +- A circular “+” button next to the mic opens the system image picker. After selection, we downscale to 512 px longest edge and show a local echo; the send begins immediately. +- Progress visualization + - Instead of a linear progress bar, we reveal the image block‑by‑block (modem‑era homage). + - The image is divided into a constant grid (default 24×16), and the blocks are rendered in order based on fragment progress; there are no gaps between tiles. + - The cancel “X” button overlays the top‑right corner during sending. +- On cancel, the message is removed from the chat immediately. + +Files: + +- `app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt) +- `app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt) +- `app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt) +- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt) + +### 5.5 Image receiving UX + +- Received images render fully with rounded corners and are left‑aligned like text messages. +- Tapping an image opens a fullscreen viewer with an option to save to the device Downloads via `MediaStore`. + +Files: + +- `app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt) + + +--- + +## 5.6 Interactive Audio Features + +### 5.6.1 Waveform Seeking + +- Audio waveforms in chat messages are fully interactive: users can tap anywhere on the waveform to jump to that position in the audio playback. +- On tap, the seek position is calculated as a fraction of the waveform width (0.0 = beginning, 1.0 = end). +- This works for both playing and paused audio states. +- The MediaPlayer is seeked to the calculated position immediately, with visual feedback via progress bar update. +- Tapping provides precise control - e.g., tap 25% through waveform jumps to 25% through audio. +- No haptic feedback or visual indicator; the progress bar update serves as immediate feedback. + +Waveform Canvas Implementation: +- `WaveformCanvas` uses `pointerInput` with `detectTapGestures` to capture tap events. +- Tap position is converted to a fraction: `position.x / size.width.toFloat()`. +- Clamped to 0.0-1.0 range for safety. +- `onSeek` callback is invoked with the calculated position fraction. +- Only enabled when `onSeek` is provided (disabled for sending in progress). + +VoiceNotePlayer Seeking: +- Accepts position fraction (0.0-1.0) and converts to milliseconds: `seekMs = (position * durationMs).toInt()`. +- Calls `MediaPlayer.seekTo(seekMs)` to jump to the exact position. +- Updates progress state immediately for UI responsiveness even before playback reaches the new position. + +Files: +- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt) — VoiceNotePlayer with seekTo function +- `app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt) — Interactive WaveformCanvas with tap handling + +--- + +## 6) Edge Cases and Notes + +- Filename collisions on receiver: prefer the sender‑supplied name if present; always uniquify with a ` (n)` suffix before the extension to prevent overwrites. +- Path markers in messages + - We use simple content markers: `"[voice] ", "[image] ", "[file] "` for local rendering. These are not sent on the wire; the actual file bytes are inside the TLV payload. +- Progress math for images relies on `(sent / total)` from `TransferProgressManager` (fragment‑level granularity). The block grid density can be tuned; currently 24×16. +- Private vs public: both use the same file TLV. Private media wraps it in a + Noise payload of type `0x20`; public media carries it directly in a `0x22` + packet. +- BLE timing: there is a 20 ms inter-fragment delay. Adjust as needed for your radio stack while maintaining compatibility. + + +--- + +## 7) File Inventory (Added/Changed) + +Core protocol and transport: + +- `app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt` — TLV payload model + encode/decode. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt) +- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` — packet creation and broadcast for file messages. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt) +- `app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt` — fragmentation, progress, cancellation via transfer jobs. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt) +- `app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt` — progress events bus. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt) +- `app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt` — receive path: decode, persist to files, create chat messages. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt) + +Audio capture and waveform: + +- `app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt` — MediaRecorder wrapper. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt) +- `app/src/main/java/com/bitchat/android/features/voice/Waveform.kt` — cache + extractor + resampler. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt) +- `app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt` — Compose waveform preview components. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt) + +Image pipeline: + +- `app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt` — downscale and save to app files. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt) +- `app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt` — SAF picker button. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt) +- `app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt` — block‑reveal progress renderer (no gaps, dense grid). (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt) + +Recording overlay: + +- `app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt` — dense, real‑time scrolling waveform during recording. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt) + +UI composition and view model coordination: + +- `app/src/main/java/com/bitchat/android/ui/InputComponents.kt` — input field, overlays (recording), picker button, mic. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/InputComponents.kt) +- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` — message rendering for text/audio/images including progress UIs and cancel overlays. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt) +- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` — sendVoiceNote/sendImageNote, progress mapping, cancelMediaSend. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt) +- `app/src/main/java/com/bitchat/android/ui/MessageManager.kt` — add/remove/update messages across main, private, and channels. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageManager.kt) + +Fullscreen image: + +- `app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt` — fullscreen viewer + save to Downloads. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt) + + +--- + +## 8) Implementation Checklist for Other Clients + +1. **Implement v2 protocol support**: Support both v1 (2-byte payload length) and v2 (4-byte payload length) packet decoding. Use v2 format for file transfer packets to enable large file transfers. +2. Implement `BitchatFilePacket` TLV exactly as specified: + - FILE_NAME and MIME_TYPE: `type(1) + len(2) + value` + - FILE_SIZE: `type(1) + len(2=4) + value(4, UInt32 BE)` + - CONTENT: `type(1) + len(4) + value` +3. For public media, embed the TLV in `FILE_TRANSFER (0x22)`. For private media, + prefix the TLV with Noise payload type `0x20`, encrypt it for the recipient, + and put the ciphertext in `NOISE_ENCRYPTED (0x11)`. +4. Fragment, send, and report progress using a transfer ID derived from `sha256(payload)` so the UI can map progress to a message. +5. Support cancellation at the fragment sender: stop sending remaining fragments and propagate a cancel to the UI (we remove the message). +6. On receive, decode TLV, persist to an app directory (separate audio/images/other), and create a chat message with content marker `"[voice] path"`, `"[image] path"`, or `"[file] path"` for local rendering. +7. Audio sender and receiver should use the same waveform extractor so visuals match; a 120‑bin histogram is a good balance. +8. **Implement interactive waveform seeking**: Tap waveforms to jump to that audio position. Calculate tap position as fraction (0.0-1.0) of waveform width. +9. For images, optionally downscale to keep TLV small; JPEG 85% at 512 px longest edge is a good baseline. +10. Mirror the UX: + - Recording overlay that does not collapse the IME; hide the caret while recording; add 500 ms end padding. + - Voice: waveform fill for send/playback; cancel overlay; **tap-to-seek support**. + - Images: dense block‑reveal with no gaps during sending; cancel overlay; fullscreen viewer with save. + - Generic files: render as a file pill with icon + filename; support open/save via the host OS. + +Following the above should produce an interoperable and matching experience across platforms. diff --git a/docs/maintainer-release-guide.md b/docs/maintainer-release-guide.md new file mode 100644 index 00000000..5bcb4742 --- /dev/null +++ b/docs/maintainer-release-guide.md @@ -0,0 +1,609 @@ +# Android maintainer release guide + +This is the operational runbook for publishing a Bitchat Android release to +GitHub and Google Play. Follow it from top to bottom for every release. + +The central rule is: + +> GitHub Actions builds and attests the unsigned release. A maintainer signs +> those exact files locally. Nothing is rebuilt after the tag. + +No keystore or password is stored in GitHub, GitHub Actions, the repository, +release notes, or workflow artifacts. + +This runbook releases both the phone and Wear OS apps under the shared Play +application ID `com.bitchat.droid`. Wear releases use the independent version +code range beginning at `1000000001`; every phone and Wear artifact uploaded to +one Play listing must have a unique version code. + +For the technical trust model and third-party verification instructions, see +[Reproducible builds](reproducible-builds.md). + +## Release flow + +| Stage | Where it happens | Result | +|---|---|---| +| Approve source | Pull requests and the release gate | One reviewed commit on `main` | +| Create tag | Maintainer machine | Signed `vX.Y.Z` tag | +| Build twice | GitHub Actions | Two identical unsigned APK/AAB builds | +| Promote build | GitHub Actions | Attested `verified-unsigned-release` artifact | +| Sign | Maintainer machine | Four installable APKs and two Play upload AABs | +| Test Play build | Play Console internal track | Play-generated APKs tested before public rollout | +| Prepare release | Maintainer machine and GitHub draft | Checksummed public release assets | +| Publish | GitHub Releases and Play Console | Public GitHub release and promoted Play rollout | + +## What the signing identities mean + +These are four separate signing identities: + +1. **Git tag signature**: identifies the maintainer who approved the source + commit. It is created locally by `git tag -s`. +2. **GitHub APK signature**: lets Android install and update the APKs published + on GitHub. Use the existing GitHub release key. Its certificate SHA-256 must + match `BITCHAT_GITHUB_RELEASE_CERT_SHA256` in `gradle.properties`. +3. **Play upload signature**: the maintainer signs both AABs with the Play + upload key so Play will accept them. +4. **Play app signature**: Google generates device APKs and signs them with the + separate Play app-signing key. + +APK and AAB signatures are embedded in those files. Do not create or publish +detached `.sig` files. GitHub build-provenance attestations are stored by GitHub +and verified with `gh attestation verify`; they are not release asset files. + +Never request, export, or use Google's Play app-signing private key during this +process. + +## Prerequisites + +### Access + +The maintainer needs: + +- permission to push a release tag and create GitHub Releases; +- a GitHub CLI login authorized for + `permissionlesstech/bitchat-android`; +- Play Console permission to create and promote releases for + `com.bitchat.droid`; and +- access to the project's release approval record. + +Check the GitHub login: + +```bash +gh auth status +``` + +### Local tools + +Install: + +- Git; +- [GitHub CLI](https://cli.github.com/); +- the exact JDK in `tools/reproducible-builds/TOOLCHAIN.env`; +- Android SDK Build Tools 37.0.0, including `apksigner` and `zipalign`; and +- Docker with Linux/amd64 support if doing the optional independent rebuild. + +Android Studio's SDK Manager can install the required Build Tools version. +Select **SDK Tools**, enable **Show Package Details**, and install 37.0.0. + +Load the toolchain pins and check the local tools: + +```bash +export JAVA_HOME=/secure/path/to/jdk-21 +export ANDROID_SDK_ROOT=/secure/path/to/android-sdk +source tools/reproducible-builds/TOOLCHAIN.env +gh --version +"$JAVA_HOME/bin/java" -version +"$ANDROID_SDK_ROOT/build-tools/$ANDROID_BUILD_TOOLS_VERSION/apksigner" version +"$ANDROID_SDK_ROOT/build-tools/$ANDROID_BUILD_TOOLS_VERSION/zipalign" -h +``` + +The JDK output must match `JAVA_VERSION`; the Android path must use +`ANDROID_BUILD_TOOLS_VERSION`. + +### Local key material + +Keep these in a password manager or encrypted offline storage: + +- the existing GitHub APK release keystore, alias, store password, and key + password; +- the Play upload keystore, alias, store password, and key password; and +- the maintainer's configured Git tag signing key. + +The GitHub APK key and Play upload key may be different. Treat them as different +credentials even if the project's historical setup placed them in one +keystore. + +Do not put a keystore inside the repository checkout. Do not place passwords on +a command line, in a shell profile, or in a release-notes file. + +## 1. Prepare and approve the release commit + +1. Update `versionCode` and `versionName` in `app/build.gradle.kts`. For a Wear + release, also update them in `wear/build.gradle.kts` using the reserved Wear + version-code range. Every code must be unique across both form factors. + The phone `versionName` must match the release tag without the leading `v`. +2. Merge all intended release changes into `main`. +3. Confirm required CI checks are green. +4. Complete the + [physical-device and cross-client release gate](release-gate-runbook.md). + Keep its privacy-reviewed evidence in the release approval record; do not + attach raw device identifiers or logs to the public release. +5. Confirm the release notes and user-visible Play changelog are ready. + +Start from a clean, current checkout: + +```bash +git switch main +git pull --ff-only +git status --short +git log -1 --oneline +``` + +`git status --short` must print nothing. + +Set shell variables for the rest of the release: + +```bash +export REPOSITORY=permissionlesstech/bitchat-android +export TAG=vX.Y.Z +export VERSION_CODE=NN +export WEAR_VERSION_CODE=1000000001 +export RELEASE_DIR="release-${TAG#v}" +``` + +Confirm the source version: + +```bash +grep -nE 'versionCode|versionName' app/build.gradle.kts +test "$(sed -n 's/.*versionName = "\([^"]*\)".*/\1/p' app/build.gradle.kts)" = "${TAG#v}" +test "$(sed -n 's/.*versionCode = \([0-9][0-9]*\).*/\1/p' app/build.gradle.kts)" = "$VERSION_CODE" +test "$(sed -n 's/.*versionCode = \([0-9_][0-9_]*\).*/\1/p' wear/build.gradle.kts | tr -d _)" = "$WEAR_VERSION_CODE" +``` + +Stop if either `test` command fails. + +## 2. Create and push the release tag + +Create a signed annotated tag on the approved commit: + +```bash +git tag -s "$TAG" -m "Bitchat Android $TAG" +git tag -v "$TAG" +git push origin "$TAG" +``` + +Do not create the GitHub Release yet. Pushing the tag starts the `Release` +workflow, which checks out that exact tag. + +Never move or replace a release tag after pushing it. If source must change, +increment `versionCode`, create a new version, and use a new tag. + +## 3. Wait for the reproducible GitHub build + +Find the `Release` workflow run: + +```bash +gh run list \ + --repo "$REPOSITORY" \ + --workflow release.yml \ + --limit 20 +``` + +Copy the run ID for `$TAG`, then: + +```bash +export RUN_ID=123456789 +gh run watch "$RUN_ID" --repo "$REPOSITORY" --exit-status +gh run view "$RUN_ID" --repo "$REPOSITORY" +``` + +The run is successful only when: + +- both independent unsigned build jobs pass; +- the byte-for-byte comparison passes; +- provenance attestation succeeds; and +- `verified-unsigned-release` is uploaded. + +If the tag already existed and the workflow must be dispatched manually, run +it against the tag ref: + +```bash +gh workflow run release.yml \ + --repo "$REPOSITORY" \ + --ref "$TAG" \ + -f tag="$TAG" +``` + +The workflow rejects a dispatch from a different ref because that would produce +incorrect provenance. + +### Accessing the artifacts + +With the CLI: + +```bash +test ! -e "$RELEASE_DIR" +gh run download "$RUN_ID" \ + --repo "$REPOSITORY" \ + --name verified-unsigned-release \ + --dir "$RELEASE_DIR" +``` + +In the GitHub web interface: + +1. Open **Actions** in the repository. +2. Open the **Release** workflow run for `$TAG`. +3. Scroll to **Artifacts**. +4. Download **verified-unsigned-release**. + +Do not sign `unsigned-release-a` or `unsigned-release-b`. Those are the two +replicas retained for diagnostics. Only `verified-unsigned-release` passed the +comparison gate. Workflow artifacts expire after 30 days, so finish the release +before then. + +## 4. Verify the downloaded unsigned release + +The directory must initially contain exactly these canonical files: + +- `BUILDINFO.json` +- `SHA256SUMS.unsigned` +- `bitchat-android-arm64-unsigned.apk` +- `bitchat-android-armv7-unsigned.apk` +- `bitchat-android-universal-unsigned.apk` +- `bitchat-android-x86-unsigned.apk` +- `bitchat-android-x86_64-unsigned.apk` +- `bitchat-android-release-unsigned.aab` +- `bitchat-android-wear-unsigned.apk` +- `bitchat-android-wear-release-unsigned.aab` + +Verify the checksum manifest: + +```bash +( + cd "$RELEASE_DIR" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c SHA256SUMS.unsigned + else + shasum -a 256 -c SHA256SUMS.unsigned + fi +) +``` + +Verify that `BUILDINFO.json` identifies the tag commit: + +```bash +TAG_COMMIT="$(git rev-list -n 1 "$TAG")" +ARTIFACT_COMMIT="$( + sed -n 's/.*"sourceCommit": *"\([^"]*\)".*/\1/p' \ + "$RELEASE_DIR/BUILDINFO.json" +)" +test "$TAG_COMMIT" = "$ARTIFACT_COMMIT" +``` + +Verify GitHub's provenance for every unsigned APK and AAB: + +```bash +for artifact in \ + "$RELEASE_DIR"/*-unsigned.apk \ + "$RELEASE_DIR"/*-unsigned.aab +do + gh attestation verify "$artifact" --repo "$REPOSITORY" +done +``` + +Stop immediately if a checksum, commit, or attestation check fails. + +## 5. Sign the GitHub APKs locally + +Set the SDK and GitHub release-key locations. Load passwords from the password +manager without placing their values in shell history: + +```bash +export ANDROID_SDK_ROOT=/secure/path/to/android-sdk +export BITCHAT_GITHUB_KEYSTORE=/secure/path/to/github-release.jks +export BITCHAT_GITHUB_KEY_ALIAS=release-key-alias + +printf 'GitHub keystore password: ' +IFS= read -r -s BITCHAT_GITHUB_KEYSTORE_PASSWORD +printf '\nGitHub key password: ' +IFS= read -r -s BITCHAT_GITHUB_KEY_PASSWORD +printf '\n' +export BITCHAT_GITHUB_KEYSTORE_PASSWORD +export BITCHAT_GITHUB_KEY_PASSWORD +``` + +Run: + +```bash +tools/reproducible-builds/sign-release.sh "$RELEASE_DIR" +``` + +The helper: + +- re-verifies `SHA256SUMS.unsigned`; +- uses the pinned Android Build Tools; +- signs each selected APK twice and requires identical results; +- verifies the embedded APK signatures; +- rejects a key whose certificate fingerprint does not match the pinned + GitHub release certificate; and +- writes `SHA256SUMS`. + +It creates: + +- `bitchat-android-arm64.apk` +- `bitchat-android-universal.apk` +- `bitchat-android-wear.apk` +- `bitchat-android-x86_64.apk` + +The unsigned armv7 and x86 APKs remain available for reproducibility, but are +not published as signed install targets under the current release policy. + +If the helper stops after creating any signed file, do not continue or overwrite +files manually. Start again in a new directory downloaded from the same +successful workflow run. + +## 6. Sign the Google Play AABs locally + +Set the pinned JDK and Play upload-key locations, then load the passwords: + +```bash +export JAVA_HOME=/secure/path/to/jdk-21 +export BITCHAT_PLAY_UPLOAD_KEYSTORE=/secure/path/to/play-upload.jks +export BITCHAT_PLAY_UPLOAD_KEY_ALIAS=upload-key-alias + +printf 'Play upload keystore password: ' +IFS= read -r -s BITCHAT_PLAY_KEYSTORE_PASSWORD +printf '\nPlay upload key password: ' +IFS= read -r -s BITCHAT_PLAY_KEY_PASSWORD +printf '\n' +export BITCHAT_PLAY_KEYSTORE_PASSWORD +export BITCHAT_PLAY_KEY_PASSWORD +``` + +Run: + +```bash +tools/reproducible-builds/sign-play-bundle.sh "$RELEASE_DIR" +``` + +The helper creates `bitchat-android-play-upload.aab` and +`bitchat-android-wear-play-upload.aab`, verifies their JAR signatures, proves +that every non-signature payload entry matches the corresponding canonical +unsigned AAB, and updates `SHA256SUMS`. + +These are the only files to upload to Play Console: + +```text +release-X.Y.Z/bitchat-android-play-upload.aab +release-X.Y.Z/bitchat-android-wear-play-upload.aab +``` + +Do not upload an APK or either `*-release-unsigned.aab` to Play. Do not open +Android Studio and rebuild the bundles. + +Remove passwords from the environment after both signing steps: + +```bash +unset BITCHAT_GITHUB_KEYSTORE_PASSWORD +unset BITCHAT_GITHUB_KEY_PASSWORD +unset BITCHAT_PLAY_KEYSTORE_PASSWORD +unset BITCHAT_PLAY_KEY_PASSWORD +``` + +## 7. Prepare all GitHub Release assets + +Run: + +```bash +tools/reproducible-builds/prepare-github-release.sh "$RELEASE_DIR" +``` + +The helper verifies all checksums and renames the public build information and +checksum manifests. It refuses missing or pre-existing release files. + +The final GitHub Release must contain all 17 files below: + +| Asset | Signed? | Why it is published | +|---|---:|---| +| `bitchat-android-arm64.apk` | APK release key | Primary direct-install APK | +| `bitchat-android-universal.apk` | APK release key | Fallback direct-install APK | +| `bitchat-android-x86_64.apk` | APK release key | x86_64 install APK | +| `bitchat-android-wear.apk` | APK release key | Wear OS direct-install APK | +| Five phone `bitchat-android-*-unsigned.apk` files | No | Reproducibility inputs for every phone ABI target | +| `bitchat-android-release-unsigned.aab` | No | Canonical reproducible Play input | +| `bitchat-android-play-upload.aab` | Play upload key | Exact bundle uploaded to Play | +| `bitchat-android-wear-unsigned.apk` | No | Canonical reproducible Wear install input | +| `bitchat-android-wear-release-unsigned.aab` | No | Canonical reproducible Wear Play input | +| `bitchat-android-wear-play-upload.aab` | Play upload key | Exact bundle uploaded to the Wear OS track | +| `BITCHAT_BUILDINFO.json` | GitHub attestation | Source commit and pinned toolchain | +| `BITCHAT_SHA256SUMS.unsigned` | GitHub attestation | Original canonical CI manifest | +| `BITCHAT_SHA256SUMS` | No detached signature | SHA-256 for every published asset | + +Run the public checksum verification once more: + +```bash +( + cd "$RELEASE_DIR" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c BITCHAT_SHA256SUMS + else + shasum -a 256 -c BITCHAT_SHA256SUMS + fi +) +``` + +Do not add keystores, certificate exports, passwords, raw release-gate logs, +local paths, or device/user identifiers to this directory. + +## 8. Create release notes and a draft GitHub Release + +Create a local release-notes file. At minimum it must contain: + +```markdown +## Bitchat Android vX.Y.Z + +- Version code: NN +- Wear version: 0.1.0 (code 1000000001) +- Source tag: vX.Y.Z +- GitHub APK signing certificate SHA-256: FINGERPRINT +- Play upload certificate SHA-256: FINGERPRINT +- Play app-signing certificate SHA-256: FINGERPRINT + +Checksums, canonical unsigned APK/AAB inputs, the exact Play upload AABs, and +build information are attached. See `docs/reproducible-builds.md` for public +verification instructions. + +## Changes + +- User-visible change +``` + +The GitHub APK fingerprint comes from `gradle.properties`. Obtain both Play +certificate fingerprints from **Play Console > Setup > App integrity**. Publish +only the SHA-256 fingerprints, not certificate subject details or private-key +material. + +Create a draft release and upload every prepared asset: + +```bash +gh release create "$TAG" "$RELEASE_DIR"/* \ + --repo "$REPOSITORY" \ + --verify-tag \ + --draft \ + --title "Bitchat Android $TAG" \ + --notes-file "release-notes-$TAG.md" +``` + +`--verify-tag` prevents `gh` from silently creating a tag at the wrong commit. +GitHub automatically exposes source archives for the tag; do not upload separate +source ZIP or tar files. + +Inspect the draft: + +```bash +gh release view "$TAG" --repo "$REPOSITORY" --web +``` + +Keep it as a draft until the Play internal-track checks below pass. + +## 9. Upload and test the AABs in Google Play + +1. Open Play Console and select `com.bitchat.droid`. +2. Open **Test and release > Testing > Internal testing**. +3. Create a new release. +4. Upload exactly: + `release-X.Y.Z/bitchat-android-play-upload.aab`. +5. Confirm Play accepts the upload signature and reports the expected package, + `versionCode`, and `versionName`. +6. Add the user-visible Play release notes. +7. Resolve blocking Play checks, save the release, and start the internal + rollout. +8. Install the build through the internal-test opt-in link on a representative + physical device. Confirm startup, upgrade from the previous public release, + networking, and the release-critical scenarios. +9. In **App bundle explorer**, select the uploaded version. If the account + permits downloading the original AAB, download it and verify that its digest + matches `bitchat-android-play-upload.aab` in `BITCHAT_SHA256SUMS`. +10. In **Setup > App integrity**, confirm the Play app-signing certificate + SHA-256 is the value recorded in the GitHub release notes. + +For the initial Wear release, open **Test and release > Advanced settings > +Form factors**, add Wear OS, upload the required Wear screenshot, and use the +dedicated **Wear OS only** test track. Upload exactly +`bitchat-android-wear-play-upload.aab`, confirm version code `1000000001`, and +complete the Wear OS opt-in and review flow. Promote the already-tested Wear +artifact on its dedicated track; do not add it to the mobile track. + +Promote these same tested Play releases from their test tracks to their +respective production tracks. Do not rebuild or upload replacement AABs for +production. Use staged production rollouts when appropriate. + +Google signs the device APKs with the Play app-signing key, so Play-delivered +APKs will not be byte-identical to the GitHub APKs. That is expected. + +## 10. Publish GitHub and promote Play + +After both Play test builds pass and the GitHub draft has all 17 assets: + +```bash +gh release edit "$TAG" \ + --repo "$REPOSITORY" \ + --draft=false \ + --latest +``` + +Then complete or schedule the production promotion in Play Console. If managed +publishing is enabled, send the approved changes for review and publish them at +the coordinated release time. + +Do not replace assets after the GitHub Release is public. If any published +binary is wrong, create a new version and release. + +## 11. Verify the public release + +From a clean checkout of the tag: + +```bash +git checkout "$TAG" +tools/reproducible-builds/verify-github-release.sh "$TAG" --no-rebuild +``` + +For the strongest check, omit `--no-rebuild` and let the pinned container +rebuild and compare all unsigned artifacts: + +```bash +tools/reproducible-builds/verify-github-release.sh "$TAG" +``` + +Also verify: + +- the GitHub release is marked **Latest**; +- the four signed APKs install and show the expected version; +- the Play listing shows the intended production version and rollout state; +- a Play-installed build is signed by the app-signing certificate recorded in + the release notes; and +- the final GitHub URL and Play status are added to the internal release + approval record. + +## Failure and retry rules + +- **A GitHub build or comparison fails:** do not sign anything. Fix the source, + increment the version, and create a new tag. Rerun the same tag only for an + infrastructure-only failure that did not change source. +- **A checksum, provenance, or commit check fails:** stop. Do not publish. +- **A local signing helper fails:** use a fresh download directory. Do not + overwrite partially created signed artifacts. +- **Play rejects the upload key:** stop and resolve the registered upload key in + Play Console. Never substitute the app-signing key. +- **Play has accepted the version code but the binary must change:** increment + `versionCode` and create a new release. +- **A GitHub draft is wrong:** keep it private while correcting it from a fresh, + verified artifact directory. +- **A public release is wrong:** do not move the tag or replace assets. Publish + a new version. + +## Final checklist + +- [ ] Version name and version code updated and approved +- [ ] Required CI and physical-device release gate passed +- [ ] Signed tag pushed from the approved commit +- [ ] `Release` workflow and two-build comparison passed +- [ ] `verified-unsigned-release` downloaded by run ID +- [ ] Checksums, source commit, and attestations verified +- [ ] GitHub APKs signed locally with the pinned certificate +- [ ] Phone and Wear Play AABs signed locally with the registered upload key +- [ ] `BITCHAT_SHA256SUMS` verifies all 17 release assets +- [ ] GitHub draft created with certificate fingerprints and all assets +- [ ] Exact signed phone and Wear AABs uploaded to and tested on their tracks +- [ ] GitHub Release published +- [ ] Same tested Play release promoted to production +- [ ] Public GitHub and Play verification completed + +## Official references + +- [GitHub CLI: download workflow artifacts](https://cli.github.com/manual/gh_run_download) +- [GitHub CLI: create a release](https://cli.github.com/manual/gh_release_create) +- [Google Play App Signing](https://support.google.com/googleplay/android-developer/answer/9842756) +- [Play Console App bundle explorer](https://support.google.com/googleplay/android-developer/answer/9859152) +- [Package and distribute Wear OS apps](https://developer.android.com/training/wearables/packaging) +- [Manage form-factor releases on dedicated tracks](https://support.google.com/googleplay/android-developer/answer/13295490) diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md new file mode 100644 index 00000000..8de1e063 --- /dev/null +++ b/docs/release-gate-runbook.md @@ -0,0 +1,348 @@ +# Physical-device and cross-client release gate + +This runbook turns Milestone 10 into a repeatable release procedure. The gate +uses a host-side CLI and USB/ADB as its control channel, so control traffic +never shares BLE, Wi-Fi Aware, Nostr, or Tor with the system under test. + +The gate cannot pass without the required physical devices and counterpart +clients. A pending or blocked result is useful diagnostic evidence, but it is +not release approval. + +## Safety and privacy rules + +- Use only disposable lab app data, identities, nicknames, messages, and files. +- Never use a personal Nostr account or a production relay. +- Do not put device serials, UDIDs, Bluetooth/MAC/IP addresses, peer IDs, + usernames, email addresses, local home paths, or message contents in a + result, trace, filename, issue, commit, or release artifact. +- Device selectors may be supplied to ADB commands as ephemeral inputs. The + tooling emits only logical aliases such as `android-current`. +- Models, manufacturer classes, Android API levels, negotiated MTU classes, + client versions, commit hashes, aggregate counts, durations, and stable + failure reason codes are allowed. +- Do not archive raw logcat. Convert observations to the structured, + privacy-checked trace format, and keep any raw diagnostic capture local until + it has been reviewed and sanitized. + +The validator rejects known identifying fields and values before a passing +bundle can be created. + +## Required lab + +Prepare: + +- At least three physical Android devices for three-hop relay testing. +- At least two Android API levels and two manufacturer classes. +- BLE central and peripheral support on every Android device. +- At least one Android 13+ device with Wi-Fi Aware. +- One physical device running the current iOS client. +- The last supported Android client. +- The release-candidate APK built from one exact full Git commit. +- A local, disposable Nostr relay/Tor fixture with production network access + blocked. + +One physical handset may be reused for the legacy-client phase after the +current-client evidence for that slot is complete, but the matrix must keep the +logical aliases and installed client versions unambiguous. + +## 1. Verify the deterministic gate + +From the repository root: + +```sh +./gradlew clientRewriteContractTest checkChangedLineCoverage lintDebug +python3 tools/release_gate/release_gate.py validate-manifest +``` + +Do not begin device work from a dirty tree or a build whose deterministic gate +does not pass. + +## 2. Create the device matrix + +Copy `tools/release_gate/device-matrix.example.json` to an ignored working +directory under `release-gate-results/`. Replace every template value and set +both current-Android commit fields to the exact full commit under test. + +Probe Android capabilities without storing the ADB selector: + +```sh +python3 tools/release_gate/android_lab.py probe \ + --serial "$BITCHAT_ADB_SELECTOR" \ + --alias android-current +``` + +Copy only the returned logical metadata into the matrix. Validate it: + +```sh +python3 tools/release_gate/release_gate.py validate-matrix \ + --matrix release-gate-results/device-matrix.json \ + --commit "$BITCHAT_RELEASE_COMMIT" +``` + +The matrix validator enforces physical devices, three Android participants, two +API levels, two manufacturer classes, Wi-Fi Aware, BLE roles, iOS, and explicit +current/legacy client versions. + +## 3. Initialize disposable fixtures + +```sh +python3 tools/release_gate/release_gate.py init \ + --matrix release-gate-results/device-matrix.json \ + --commit "$BITCHAT_RELEASE_COMMIT" \ + --run-id rc-lab-01 \ + --output release-gate-results/rc-lab-01 +``` + +Initialization pins the scenario and fixture manifests, creates every scenario +as `pending`, and generates deterministic: + +- zero-byte and small files; +- a Unicode-named medium file; +- sparse exact-maximum and oversized boundary files. + +The fixture manifest records size and SHA-256. The final archive contains the +manifest, not the large fixture bodies. + +Clear only the disposable app data on each selected lab device: + +```sh +python3 tools/release_gate/android_lab.py prepare \ + --serial "$BITCHAT_ADB_SELECTOR" \ + --confirm-disposable-app-data +``` + +This stops the app and runs package-data cleanup. The explicit confirmation is +required because the operation is destructive to that app's local data. + +## 4. Execute scenarios + +The canonical scenario list is +`tools/release_gate/scenarios.json`. It contains 27 mandatory scenarios: + +- the complete physical transport matrix; +- Android API/manufacturer/permission/background coverage; +- 11 Android-to-Android workflows; +- 8 cross-client/backward-compatibility workflows; +- 6 background and endurance workflows. + +For each scenario: + +1. Confirm the listed participants and capabilities. +2. Perform the corresponding steps in + [device-transport-test-matrix.md](device-transport-test-matrix.md) and the + Milestone 10 checklist. +3. Record connection, lifecycle, transport, receipt, resource, and terminal + state as aggregate evidence. +4. Append at least one structured trace event. +5. Mark the scenario `pass`, `fail`, `blocked`, or `unsupported`. + +Record evidence with the exact keys declared by the scenario: + +```sh +python3 tools/release_gate/release_gate.py record \ + --run release-gate-results/rc-lab-01 \ + --scenario A2A-001 \ + --status pass \ + --evidence connection-transitions=4 \ + --evidence packet-correlation-count=6 \ + --evidence failure-reasons=none +``` + +Append a privacy-safe trace event: + +```sh +python3 tools/release_gate/release_gate.py trace \ + --run release-gate-results/rc-lab-01 \ + --scenario A2A-001 \ + --source android-current \ + --event reconnect-terminal \ + --outcome pass \ + --metric reconnect-count=1 \ + --metric duplicate-delivery-count=0 +``` + +Capture resource snapshots during endurance work: + +```sh +python3 tools/release_gate/android_lab.py snapshot \ + --serial "$BITCHAT_ADB_SELECTOR" \ + --alias android-current \ + --run release-gate-results/rc-lab-01 \ + --scenario END-003 +``` + +Use run-local sequential correlation labels while observing packets; archive +only aggregate correlation counts. Record failures with a stable reason code, +file a regression issue, and preserve the incomplete artifact. + +## 5. Endurance requirements + +- `END-001` requires at least 240 minutes. +- `END-002` requires at least 50 large-transfer/cancellation cycles. +- Sample memory, threads, file descriptors, wake locks, connection counts, and + late callbacks at consistent intervals. +- A passing result requires bounded resource behavior and a clean terminal + state; merely completing the time window is insufficient. + +The validator rejects shorter durations and cycle counts. + +## 6. Inspect progress and validate + +During a run: + +```sh +python3 tools/release_gate/release_gate.py validate \ + --run release-gate-results/rc-lab-01 \ + --allow-incomplete + +python3 tools/release_gate/release_gate.py summary \ + --run release-gate-results/rc-lab-01 +``` + +The release validator, without `--allow-incomplete`, requires: + +- every scenario to be `pass`; +- every declared evidence field; +- at least one structured trace per scenario; +- the pinned scenario and fixture manifests; +- the exact client commit and complete device matrix; +- endurance minimums; +- a completion timestamp; +- no detected identifying fields or values. + +`unsupported`, `blocked`, and `pending` never satisfy release approval. + +## 7. Archive release approval + +After the complete validator passes: + +```sh +python3 tools/release_gate/release_gate.py bundle \ + --run release-gate-results/rc-lab-01 \ + --output release-gate-results/rc-lab-01.zip +``` + +The deterministic archive contains the scenario manifest, device/client matrix, +results, structured trace, fixture manifest, Markdown summary, and +`SHA256SUMS`. Attach it to the release approval record without renaming fields +or adding raw diagnostics. + +Finally, clean the disposable app data with the same confirmed `cleanup` +command and stop the local relay/Tor fixture. + +## Failure handling + +- `fail`: behavior violated a contract. Record a stable reason code, file a bug, + add a deterministic regression where possible, fix it, and rerun the affected + scenario plus dependent scenarios. +- `blocked`: required lab infrastructure or counterpart client was unavailable. + Preserve the artifact and do not approve release. +- `unsupported`: the selected device lacks a capability. Because the defined + matrix requires Wi-Fi Aware, replace the device or matrix; unsupported does + not waive a mandatory scenario. +- A flaky result is a failure until its cause is understood. Never average + retries into a pass. + +## Appendix: mesh lab (ADB test hooks, debug builds) + +For day-to-day development there is a lighter-weight harness that drives a +debug-only broadcast receiver (`app/src/debug/`, never shipped in release) +exposing mesh operations over ADB: scan, connect, Noise handshake, DMs, +public broadcast, live push-to-talk, announce, file send/receive, BLE toggle, state dumps, and +raw packet injection. Results are JSON files in the app sandbox polled by the +host (`cache/testhook/results/.json`, also logged under tag `TestHook`). + +### Prerequisites + +- A JDK (e.g. the one bundled with Android Studio; set `JAVA_HOME`) and the + Android SDK platform-tools. `adb` must be on `PATH` or `ANDROID_HOME` set. +- Python 3.10+ on the host. No third-party packages are required. +- **Two physical Android devices** (API 26+, BLE) with USB debugging enabled, + both plugged into the host. Emulators are not supported (BLE mesh). +- Verify both are visible: `adb devices` → note the serials. + +### Device preparation (important) + +Keep both phones **unlocked with the screen on** for the whole run. A locked +or dozing device forces the app into POWER_SAVER (1 s BLE scan per 60 s), +which makes discovery and handshakes take minutes and will flake every +scenario. The harness runs `wake()` (dismiss keyguard, stretch screen +timeout) during `setup`, but it cannot defeat a secure lock screen — unlock +the devices manually first. Note that `svc power stayon` only helps while a +device is actually charging. + +### Build and set up + +```sh +./gradlew assembleDebug +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a --serial-b \ + --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk +``` + +`setup` cycles Bluetooth, installs the APK, clears app data, grants all +runtime permissions, wakes and launches the app, sets deterministic nicknames +(`alice`/`bob`), and waits for mutual peer discovery. It is safe (and +recommended) to rerun `setup` before each scenario batch; `--apk` may be +omitted if the current build is already installed. + +### Run scenarios + +```sh +python3 tools/release_gate/mesh_lab.py scenario all \ + --serial-a --serial-b --out /tmp/meshlab-evidence +``` + +| Scenario | What it asserts | +|---|---| +| `dm` | Noise handshake both ways, encrypted DM round trips with content match | +| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification | +| `broadcast` | public mesh message A→B | +| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption | +| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions | +| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | +| `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing | +| `file_private` | Noise-encrypted private file, digest match | +| `media_private` | private-chat contact ID resolves to the live mesh peer; voice, image, and generic-file digests match | +| `raw` | raw packet injection is accepted by the mesh | +| `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again | +| `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs | +| `all` | every scenario above in sequence | + +Each run writes `-evidence.json` to `--out` (digests, timings, +session states, logcat excerpts on failure) and exits non-zero on failure. +Evidence is a local diagnostic artifact; it may contain lab peer IDs and is +not privacy-checked like release-gate bundles — do not publish it. + +### Ad-hoc commands + +Any hook command can be sent to one device directly: + +```sh +python3 tools/release_gate/mesh_lab.py cmd --serial scan --extra timeout_ms=30000 +python3 tools/release_gate/mesh_lab.py cmd --serial handshake --extra peer= +python3 tools/release_gate/mesh_lab.py cmd --serial state # full mesh dump +``` + +See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`, +`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`, +`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`, +`favorite_status`, `verification_set`, `verification_status`, `file_send`, +`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`, +`clear_results`). + +### Troubleshooting + +- **Discovery/handshake timeouts**: almost always a locked or dozing phone — + unlock both devices and rerun `setup`. `cmd ... state` shows + `App In Background: true` and the BLE duty cycle when this is the cause. +- **Stale app state after many churn runs**: `svc bluetooth disable/enable` + on both devices (done automatically by `setup`) clears zombie GATT links. +- **Watch the wire**: `adb -s logcat -s TestHook MessageHandler + FragmentManager BitchatFilePacket` shows commands, results, decrypt + failures, fragment rejects, and saved incoming files in real time. +- Results also persist on-device at + `run-as com.bitchat.droid cat cache/testhook/results/.json`. + +Unlike the release gate, this harness is a development aid: it prints raw +diagnostics and does not produce a privacy-checked approval bundle. diff --git a/docs/reproducible-builds.md b/docs/reproducible-builds.md new file mode 100644 index 00000000..348b201f --- /dev/null +++ b/docs/reproducible-builds.md @@ -0,0 +1,281 @@ +# Reproducible builds + +Bitchat's canonical release build produces byte-for-byte reproducible unsigned +phone and Wear OS APKs and Android App Bundles (AABs). CI builds the release +twice in independent jobs and exposes a verified release artifact only when +every canonical byte matches. + +Signing remains local: no keystore or signing password is stored in or exposed +to GitHub Actions. Anyone can reproduce the unsigned artifacts; maintainers +download the verified CI output, sign the selected GitHub APKs and Play upload +AABs locally, then manually publish those exact files. + +Maintainers should use the complete +[Android maintainer release guide](maintainer-release-guide.md) for the +step-by-step release procedure, artifact inventory, local signing commands, and +GitHub/Google Play publication checklist. + +## What is pinned + +- Gradle wrapper version and distribution SHA-256 +- dependency versions, strict Gradle dependency locks, and downloaded-artifact + SHA-256 verification metadata +- exact Temurin JDK release and digest-pinned Linux builder image +- Android platform, Platform Tools, and Build Tools archives by filename and + SHA-256, plus the accepted SDK license-text SHA-1 required to use them +- Kotlin/JVM toolchain and bytecode target +- Arti source tag and full commit, stable native build epoch, Rust, `cargo-ndk`, + Android NDK, Cargo lockfile, digest-pinned Rust builder image, and immutable + Debian package snapshot +- immutable full commit SHAs for every third-party GitHub Action + +The build uses a clean source tree, an isolated Gradle user home, UTC, a stable +locale, `SOURCE_DATE_EPOCH` from the Git commit, no Gradle build or configuration +cache, fresh tasks, a non-incremental in-process Kotlin compiler, and R8's +deterministic-debugging mode. The R8 mode uses one compiler thread and disables +randomized input shuffling. The regular R8 ProGuard map remains embedded in each +AAB. Kotlin 2.4.10's optional Compose group-key mapping augmentation is disabled +because its duplicate-key selection depends on unspecified class-file iteration +order across clean builds. Native builds remap source paths and release +validation rejects host paths in packaged libraries. The container overlays a +canonical `local.properties`, so an ignored Android Studio file cannot redirect +Gradle to a host-specific SDK. + +AGP's embedded VCS record is disabled because its Git discovery depends on the +host checkout layout. The canonical `BUILDINFO.json` and GitHub provenance +attestation record the host-verified commit instead. + +The authoritative pins are: + +- `gradle/wrapper/gradle-wrapper.properties` +- `gradle/libs.versions.toml` +- `settings-gradle.lockfile` +- `app/gradle.lockfile` +- `wear/gradle.lockfile` +- `gradle/verification-metadata.xml` +- `tools/reproducible-builds/TOOLCHAIN.env` +- `tools/arti-build/TOOLCHAIN.env` +- `tools/arti-build/Cargo.lock` + +## Reproduce a release locally + +Requirements are Git, Docker with Linux/amd64 support, more than 8 GiB of memory +available to the Docker VM (16 GiB recommended), and enough free space for the +Android and Gradle images and dependencies. R8's single-threaded deterministic +mode can exceed an 8 GiB Docker memory limit while optimizing the phone app. + +```bash +git clone https://github.com/permissionlesstech/bitchat-android.git +cd bitchat-android +git checkout vX.Y.Z +tools/reproducible-builds/build-in-container.sh \ + .reproducible-build/local-vX.Y.Z +``` + +The output contains: + +- unsigned APKs for arm64, armv7, x86, x86_64, and universal installs +- `bitchat-android-release-unsigned.aab` +- `bitchat-android-wear-unsigned.apk` +- `bitchat-android-wear-release-unsigned.aab` +- `BUILDINFO.json` +- `SHA256SUMS.unsigned` + +The output directory must not already contain files. The script rejects a dirty +checkout so the commit in `BUILDINFO.json` identifies all source inputs. + +To test reproducibility yourself, build into two empty directories and compare: + +```bash +BITCHAT_CONTAINER_GRADLE_HOME_NAME=gradle-home-first \ + tools/reproducible-builds/build-in-container.sh .reproducible-build/first +BITCHAT_CONTAINER_GRADLE_HOME_NAME=gradle-home-second \ + tools/reproducible-builds/build-in-container.sh .reproducible-build/second +tools/reproducible-builds/compare-release.sh \ + .reproducible-build/first \ + .reproducible-build/second +``` + +If a comparison fails and `diffoscope` is installed, the comparison script +automatically reports the first differing artifact. + +## Verify a GitHub release + +Install the GitHub CLI, authenticate it if necessary, check out the release tag, +and run: + +```bash +git checkout vX.Y.Z +tools/reproducible-builds/verify-github-release.sh vX.Y.Z +``` + +That command: + +1. downloads all release APKs, AABs, build information, and checksum files; +2. verifies the canonical unsigned build's GitHub artifact-attestation subjects + against this repository; +3. verifies `BITCHAT_SHA256SUMS`; +4. checks that the local source commit is the release commit; +5. rebuilds in the pinned container; and +6. byte-compares every unsigned APK, both unsigned AABs, build information, and + the unsigned checksum manifest. + +To verify the published checksums and attestations without rebuilding: + +```bash +tools/reproducible-builds/verify-github-release.sh vX.Y.Z --no-rebuild +``` + +For a manual signature check, use the exact `apksigner` from Android Build Tools +37.0.0: + +```bash +apksigner verify --verbose --print-certs bitchat-android-universal.apk +``` + +Compare the reported signer certificate SHA-256 with +`BITCHAT_GITHUB_RELEASE_CERT_SHA256` in `gradle.properties`. A matching +certificate proves who signed the APK; the checksum, attestation, and local +unsigned rebuild establish which source and build produced it. A third party +cannot recreate the signed bytes without the private release key. + +You can also prove that the signed APK contains the same archive entries and +uncompressed payload bytes as the reproduced unsigned APK: + +```bash +tools/reproducible-builds/compare-archive-payloads.sh \ + .reproducible-build/local-vX.Y.Z/bitchat-android-universal-unsigned.apk \ + bitchat-android-universal.apk +``` + +GitHub's manual equivalents are: + +```bash +gh release download vX.Y.Z +sha256sum -c BITCHAT_SHA256SUMS +gh attestation verify bitchat-android-universal-unsigned.apk \ + --repo permissionlesstech/bitchat-android +``` + +## Verify a Google Play release + +Google Play App Signing changes the verification boundary: + +- maintainers upload signed phone and Wear AABs using the upload key; +- Google Play generates optimized, device-specific APK splits from those AABs; +- Google signs the delivered APKs with the app-signing key. + +Consequently, a Play-delivered APK is not expected to be byte-identical to the +GitHub universal APK or to a locally built APK. Use this procedure instead: + +1. In Play Console, open **Test and release > App bundle explorer**, select the + release/version code, and download the original app bundle if that option is + available to your account. Compare its unsigned payload with the matching + reproduced phone or Wear AAB: + + ```bash + tools/reproducible-builds/compare-archive-payloads.sh \ + .reproducible-build/local-vX.Y.Z/bitchat-android-release-unsigned.aab \ + downloaded-from-play.aab + ``` + + The helper excludes JAR-signing metadata and compares every other entry name + and uncompressed byte. +2. In **Setup > App integrity**, record the SHA-256 fingerprint under **App + signing key certificate**. This is different from the upload-key certificate + and may be different from the GitHub release certificate. +3. In App bundle explorer, download the Play-generated universal APK or the APKs + for a representative device. Verify each APK: + + ```bash + apksigner verify --verbose --print-certs downloaded-from-play.apk + ``` + + The signer SHA-256 must equal the Play Console app-signing certificate. +4. Confirm package name `com.bitchat.droid`, version code, version name, and + manifest/security configuration with Android's `apkanalyzer` or `aapt2`. +5. Recreate Google's split-generation behavior from the reproduced AAB with the + same `bundletool` version and a saved device specification: + + ```bash + bundletool build-apks \ + --bundle=bitchat-android-release-unsigned.aab \ + --output=local.apks \ + --device-spec=device.json + ``` + +This last check validates bundle-to-APK behavior, but it is not a byte-equality +claim: Play's server-side `bundletool` version, optimization, and signing inputs +are controlled by Google. The strongest public Play verification requires +maintainers to retain the uploaded AAB, publish its digest and provenance, and +record the Play version code and app-signing certificate fingerprint alongside +the release. + +The GitHub workflow builds and attests both canonical unsigned AABs. A +maintainer locally creates `bitchat-android-play-upload.aab` and +`bitchat-android-wear-play-upload.aab` from those exact files and uploads them +manually to Google Play. + +## Maintainer release process + +Follow the +[Android maintainer release guide](maintainer-release-guide.md). It is the +authoritative operational runbook from version preparation through the signed +tag, GitHub Actions artifact download, local APK/AAB signing, GitHub draft, +Play internal test, public rollout, and post-release verification. + +The workflow has no signing secrets and never publishes a release by itself. + +## Updating dependencies or toolchains + +Dependency changes must update and review both the lock state and verification +metadata: + +```bash +./gradlew testDebugUnitTest lintDebug resolveIdeRuntimeClasspathCopyLocks \ + --write-locks \ + --write-verification-metadata sha256 +``` + +`resolveIdeRuntimeClasspathCopyLocks` records the transient runtime classpath +copies that Android Studio resolves during model import. Their selected versions +are persisted under the generated copy configuration names while the canonical +debug and release runtime classpaths remain strictly locked. + +Generate release lock entries in separate invocations because split APK and AAB +intermediates cannot coexist: + +```bash +./gradlew :app:clean :app:bundleRelease \ + --write-locks \ + --write-verification-metadata sha256 +./gradlew :app:clean :app:assembleRelease \ + --write-locks \ + --write-verification-metadata sha256 +``` + +Review every new repository, component, artifact name, version, and checksum. +Do not accept verification metadata generated after an unexplained checksum +failure. + +The verification metadata deliberately trusts only IDE documentation and source +attachments (`*-javadoc.jar`, `*-sources.jar`, and Gradle's `*-src.zip`). Android +Studio resolves these outside the build dependency graph, and they are not build +inputs. Compiled artifacts and dependency metadata remain checksum-verified. + +When changing Gradle, update the wrapper and independently verify the new +distribution SHA-256. When changing JDK or Android tools, update the exact +version, archive checksum, and base-image digest together. Native updates follow +[`tools/arti-build/README.md`](../tools/arti-build/README.md). + +## References + +- [Gradle dependency locking](https://docs.gradle.org/current/userguide/dependency_locking.html) +- [Gradle dependency verification](https://docs.gradle.org/current/userguide/dependency_verification.html) +- [Gradle wrapper checksum verification](https://docs.gradle.org/current/userguide/best_practices_security.html#use_the_gradle_wrapper_and_verify_the_wrapper_checksum) +- [GitHub Actions security hardening](https://docs.github.com/en/actions/reference/security/secure-use) +- [GitHub artifact attestation verification](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations) +- [Google Play App Signing](https://support.google.com/googleplay/android-developer/answer/9842756) +- [Play Console App bundle explorer](https://support.google.com/googleplay/android-developer/answer/9859152) +- [Android `bundletool`](https://developer.android.com/tools/bundletool) +- [Android `apksigner`](https://developer.android.com/tools/apksigner) diff --git a/docs/screenshots/readme-geohash-globe.png b/docs/screenshots/readme-geohash-globe.png new file mode 100644 index 00000000..7bb99710 Binary files /dev/null and b/docs/screenshots/readme-geohash-globe.png differ diff --git a/docs/screenshots/readme-mesh-chat.png b/docs/screenshots/readme-mesh-chat.png new file mode 100644 index 00000000..5c9f98bd Binary files /dev/null and b/docs/screenshots/readme-mesh-chat.png differ diff --git a/docs/security-review-jul-27.md b/docs/security-review-jul-27.md new file mode 100644 index 00000000..ed7ce672 --- /dev/null +++ b/docs/security-review-jul-27.md @@ -0,0 +1,182 @@ +# Bitchat Android — Security Review + +**Commit:** 92d07b22 (worktree: `~/.opencode/worktrees/bitchat-security-review`) +**Scope:** Exploitable bugs, privacy violations, tracking opportunities, DoS vectors. +**Method:** Read-only static review of `crypto/`, `noise/`, `identity/`, `mesh/`, `protocol/`, `net/`, `service/`, `nostr/`, `geohash/`, `ui/`, `features/`, manifest & build config. All findings verified against source. + +--- + +## CRITICAL + +### C1. Noise session key material (DH shared secrets, chaining keys) written to logcat — ships in release +`noise/southernstorm/protocol/SymmetricState.java:135-159` (also 98-104, 168-182) +`mixKey()` logs the raw X25519 shared secret ("Input data"), current and new chaining keys in hex. `proguard-rules.pro` has **no** `assumenosideeffects` for `android.util.Log`, so this reaches the release APK. Anyone with the logcat transcript of a handshake can recompute `split()` outputs and fully decrypt the transport session. **Release-blocking.** + +### C2. Remote decompression bomb — pre-auth memory exhaustion via ~30-byte BLE write +`protocol/BinaryProtocol.kt:429-454`, `protocol/CompressionUtil.kt:75-118` +For compressed v2 packets the decoder reads a 4-byte attacker-controlled `originalSize` and immediately does `ByteArray(originalSize)` — up to 2 GiB per packet. The ratio guard is skipped when `compressedSize == 0` and is 50,000:1 anyway (real deflate max ≈ 1032:1). Runs inline on the GATT callback path; any nearby unauthenticated BLE peer can OOM/crash the foreground service with tiny writes. + +--- + +## HIGH + +### H1. Replay-window bit shift direction is wrong — Noise transport replay protection broken +`noise/NoiseSession.kt:69-106` +Bit indexing is LSB-first, but the window-shift code right-shifts the byte string. A replayed captured frame (nonce 0) tests a zero bit and is accepted; only the single most-recent nonce is reliably blocked. A passive BLE observer can replay DMs, ACKs, and receipts. + +### H2. Announcement signing key not bound to Noise key — "first announce wins" peer-ID impersonation +`mesh/AnnouncementIdentityValidator.kt:13-36`, `mesh/SecurityManager.kt:282-313`, `mesh/MessageHandler.kt:265-299` +An attacker can replay a victim's Noise public key (→ victim's peerID) inside an announcement signed with the *attacker's* Ed25519 key. On fresh installs or for never-authenticated peers the forged binding wins, and the genuine victim's later announce is rejected as "key replacement". + +### H3. Decrypted private message content written to logcat +`mesh/MessageHandler.kt:88` (also 125; `nostr/NostrProtocol.kt:277`) +`Log.d` logs the first 30 chars of every decrypted E2E private message plus sender peerID. Not stripped in release. Defeats E2E guarantees for any logcat reader. Related: `ui/DataManager.kt:161-196` dumps the full favorite/fingerprint social graph at startup (Low). + +### H4. E2E-encrypted DM content exposed in system notifications without lock-screen redaction +`ui/NotificationManager.kt:224-246` +Decrypted DM bodies (mesh + Nostr) are posted with full text in `BigTextStyle`/`InboxStyle`; no `VISIBILITY_SECRET` / lockscreen visibility set. Content renders on lock screen and to any notification-listener app; no opt-out. + +### H5. No signature verification on incoming public Nostr events — relay can forge any user +`nostr/GeohashMessageHandler.kt:44-105`, `nostr/LocationNotesManager.kt:358-414`, `nostr/NostrRelayManager.kt:663-706` +`NostrEvent.isValidSignature()` (exists, `NostrEvent.kt:171`) is never called for kind 20000/20001/1. A malicious/compromised relay (auto-selected from a daily third-party list, see M9) can inject events with arbitrary pubkeys — impersonating any user in any geohash channel, spoofing participants, caching attacker-chosen nicknames, registering DM aliases. + +### H6. Automatic delivery/read receipts over Nostr = online-presence & location oracle +`nostr/NostrDirectMessageHandler.kt:134-138,165-175`, `nostr/NostrTransport.kt:316-384` +Any decryptable gift-wrapped DM triggers an automatic signed DELIVERED ack. For geohash DMs the ack uses the public deterministic per-geohash identity, so anyone can probe a target's known geohash pubkey and confirm the device is online *now*, and chart activity patterns. No setting gates this. + +### H7. Building-precision geohash published publicly and permanently in location notes +`geohash/LocationChannel.kt:8` (precision 8 ≈ 19×38 m), `nostr/LocationNotesManager.kt:119-165` +Kind-1 (relay-archived, persistent) notes carry the 8-char geohash, exact `created_at`, and an optional plaintext nickname, signed by a stable pubkey — a permanent public record "pubkey X was within ~30 m of this spot at time T". REQ `#g` filters also disclose precise location to relays. + +### H8. Deterministic per-geohash identities are stable forever — long-term passive location tracking +`nostr/NostrIdentity.kt:137-177`, `ui/GeohashViewModel.kt:184-193` +Geohash identity = HMAC(deviceSeed, geohash), never rotated. A passive observer of a channel can recognize returning users months later and assemble their full history in that channel; the plaintext `["n", nickname]` tag (`nostr/NostrProtocol.kt:167-169`) links the same person across channels. + +### H9. Unbounded per-peer actor map and unlimited packet queues — memory/coroutine exhaustion +`mesh/PacketProcessor.kt:43-95`, `mesh/BluetoothPacketBroadcaster.kt:122-124` +A new coroutine actor with `Channel.UNLIMITED` is created per attacker-chosen `senderID` *before* any security validation, with no eviction. Spraying packets with random sender IDs grows live coroutines + unbounded channels forever. + +### H10. Unauthenticated packet types relayed mesh-wide with attacker-controlled TTL — flood/amplification +`mesh/SecurityManager.kt:268-280`, `mesh/PacketRelayManager.kt:59-107,134-163` +Signature verification enforced only for ANNOUNCE/MESSAGE/FILE_TRANSFER/LEAVE. FRAGMENT, REQUEST_SYNC, NOISE_* verify unconditionally and are relayed (unconditionally at TTL ≥ 4) with no rate limit. One BLE radio can make the entire mesh re-broadcast forged traffic, draining bandwidth and battery. + +### H11. Unsigned REQUEST_SYNC forces bulk re-broadcast — amplification +`mesh/BluetoothMeshService.kt:671-676`, `sync/GossipSyncManager.kt:168-199` +A spoofed REQUEST_SYNC with an empty (forgeable) GCS filter makes a victim dump its entire sync cache onto the radio; repeating keeps neighbors transmitting continuously. No rate limit or response budget. + +### H12. ANNOUNCE replay with TTL=7 forces Noise session teardown + fake "direct neighbor" +`mesh/SecurityManager.kt:78-90`, `mesh/BluetoothMeshService.kt:586-621` +Duplicate ANNOUNCEs are re-accepted at TTL ≥ 7; direct-link is inferred from TTL alone (excluded from the signature, attacker-settable). Replaying a victim's recent ANNOUNCE at TTL=7 repeatedly tears down the victim's Noise sessions, breaking in-flight DMs. + +--- + +## MEDIUM + +### M1. No low-order point / all-zero DH rejection — Noise spec violation, key-compromise downgrade +`noise/southernstorm/protocol/HandshakeState.java:750-762,1034-1040,1050-1068` +Only the all-zero ephemeral key is rejected; other low-order Curve25519 points pass, DH outputs never checked, remote static key unvalidated. A malicious identity with low-order keys yields all-zero DH outputs → publicly derivable session keys. `SecureIdentityStateManager.validatePublicKey()` (identity/…:403-417) is debug-only and its blocklist misses real low-order points. + +### M2. Unbounded crypto work + session state per forged handshake — crypto/memory DoS +`noise/NoiseSessionManager.kt:228-252,312-316` +Any frame from any spoofed peerID creates a responder session (~3 X25519 scalar mults) inside a `@Synchronized` method, with no cap on half-open responder sessions (staleness check only on the initiate path). + +### M3. Stable 8-byte peerID in BLE scan response — passive long-term tracking +`mesh/BluetoothGattServerManager.kt:400-412`, `mesh/BluetoothMeshService.kt:54` +The scan response embeds the truncated fingerprint of the *persistent* static Noise identity, surviving restarts and MAC rotation (by design, for dedup). Any passive sniffer can track a user across time/place. Nickname is broadcast in every ANNOUNCE; ANNOUNCE gossip TLVs disclose the user's neighbor graph. + +### M4. Fragment reassembly poisoning — cross-sender fragment-ID collision +`mesh/FragmentManager.kt:32,202-263` +Reassembly keyed by 8-byte fragment ID only (not sender). Fragments are unsigned; an attacker injects one colliding fragment to destroy a victim's in-flight (up to 1 MB) transfer. Bounded by existing caps; DoS/corruption, not forgery. + +### M5. Legacy plaintext Ed25519 private key can persist after "migration" +`crypto/EncryptionService.kt:505-524` +Old plaintext key is deleted only if the encrypted store doesn't already have one; otherwise the plaintext private key remains on disk indefinitely. + +### M6. Attacker-controlled image decode bombs / main-thread decode +`ui/media/ImageMessageItem.kt:74`, `ui/media/FullScreenImageViewer.kt:75`, `ui/MessageComponents.kt:294-304` +Received images decoded with `BitmapFactory.decodeFile` — no bounds check, no `inSampleSize`, on the main thread during composition. A small PNG with huge dimensions → instant OOM when the chat renders. `readBytes()` re-reads whole files on every recomposition. + +### M7. Received files auto-downloaded unencrypted; size limit only on send path +`features/file/FileUtils.kt:194-263`, `nostr/NostrDirectMessageHandler.kt:191-194` +Nostr DM path accepts up to 10 MB per message (`AppConstants.kt:71`); a malicious contact can fill `cacheDir` indefinitely. Filenames are sanitized (path traversal verified not exploitable). + +### M8. Exported MainActivity acts on attacker-supplied intent extras +`AndroidManifest.xml:98-115`, `MainActivity.kt:828-894` +Any app can fire intents with `EXTRA_OPEN_PRIVATE_CHAT`/`EXTRA_PEER_ID` to open arbitrary chat sheets and silently clear the victim's pending notifications, or trigger the verification sheet UI. QR payload itself is cryptographically validated — no verification forgery. + +### M9. Relay directory auto-updates daily from third-party GitHub CSV, unsigned/unpinned +`nostr/RelayDirectory.kt:29,152-191` +Compromise of `permissionlesstech/georelays` steers all users to attacker relays → precise `#g` filters + forged-event injection (H5). + +### M10. Geohash subscription filters & geo-nearest relay selection leak location to relays/observers +`nostr/NostrRelayManager.kt:131-147`, `nostr/RelayDirectory.kt:88-106`, `nostr/NostrFilter.kt:37-71` +Each relay learns subscribed cells; `#p` DM filters reveal owned pubkeys. Mitigated: Tor ON by default, fail-closed proxy config (`net/ArtiTorManager.kt:151,224`). + +### M11. Unbounded outbound Nostr message queue — never drained, re-sent on reconnect +`nostr/NostrRelayManager.kt:107,286-288,862-871` +`messageQueue` entries are never removed after send; every relay reconnect re-sends full history (duplicate gift wraps, memory growth, extra metadata). + +### M12. Unbounded identity-keyed caches — memory DoS by malicious relay (compounds H5) +`nostr/GeohashRepository.kt:22-29,61,104-118` +`geohashParticipants`, `geoNicknames`, etc. grow per unique pubkey with no eviction; forged events from unlimited fresh pubkeys exhaust memory. No WebSocket frame size cap (`NostrRelayManager.kt:874-876`). + +### M13. "NIP-44" DM encryption omits padding — exact plaintext length leakage; not real NIP-44 +`nostr/NostrCrypto.kt:211-260,267-293` +Raw XChaCha20-Poly1305 over unpadded UTF-8; relays/observers see exact DM lengths (receipt vs message vs file) and it's incompatible with real NIP-44 clients. AEAD itself sound. + +### M14. Peer-table flooding with throwaway identities +`mesh/PeerManager.kt:93,228-240,551-564` +No cap on the peers map; cheap self-signed ANNOUNCEs create verified entries and can each trigger Noise handshakes. 3-minute sweep is the only bound. + +### M15. No rate limiting on GATT writes / inbound packet processing +`mesh/BluetoothGattServerManager.kt:232-277`, `mesh/BluetoothGattClientManager.kt:644-656` +Every write is fully decoded/verified with no per-connection or global rate limit; combined with H9 guarantees backlog growth. + +### M16. Two independent persistent Ed25519 signing identities per device +`crypto/EncryptionService.kt:485-503` vs `identity/SecureIdentityStateManager.kt:145-195` +Different keys in different pref files; same device presents two signing identities; panic wipe doesn't rotate both. Also: `EncryptionService.sign()` returns an empty signature and `verify()` ignores its inputs entirely (EncryptionService.kt:226-242) — a latent trap. + +--- + +## LOW + +- **L1. Metadata leakage:** only NOISE_* frames padded; public MESSAGE/ANNOUNCE/FILE_TRANSFER leak exact sizes; every packet carries ms wall-clock timestamps (`mesh/BLEPacketPaddingPolicy.kt:11-17`, `protocol/BinaryProtocol.kt:42-43,76`). +- **L2. Signed MESSAGE/FILE_TRANSFER replayable after 5-min dedup expiry** — no freshness check (`mesh/SecurityManager.kt:55-73,101`). +- **L3. Silent identity regeneration on key-store corruption** — masks tampering, destroys identity on transient Keystore failure (`crypto/EncryptionService.kt:466-483`, `noise/NoiseEncryptionService.kt:85-119`). +- **L4. Channel KDF salt = channel name; PBKDF2 mislabeled as "Argon2id" in comments; passwords retained in memory; dead plaintext channel-key-sharing packet code** (`noise/NoiseChannelEncryption.kt:148-168,203-243`). +- **L5. Reverse geocoding sends precise GPS to OSM Nominatim (or Google Fused) with app-identifying UA** — rides Tor when on (`geohash/OpenStreetMapGeocoderProvider.kt:21-29`). +- **L6. Persistent account Nostr identity links all DM activity; `#p` filters announce pubkey ownership to relays** — mitigated by randomized gift-wrap timestamps (`nostr/NostrIdentity.kt:108-128`). +- **L7. Predictable subscription IDs (`sub--`) aid per-session correlation** (`nostr/NostrRelayManager.kt:809-811`). +- **L8. NIP-17 validation gaps:** rumor kind not checked to be 14; replay window uses attacker-controlled `created_at`. Sender spoofing inside gift wraps *is* correctly prevented (`nostr/NostrProtocol.kt:77-96`). +- **L9. No FLAG_SECURE anywhere; Recents preview leaks chats on API < 33** (`MainActivity.kt:84-86`). +- **L10. Clipboard copies not marked sensitive** (`ui/ChatUserSheet.kt:87`, `ui/SecurityVerificationSheet.kt:411`). +- **L11. WebView with JS + file access + unescaped geohash interpolation into `evaluateJavascript`** — activity not exported, limited impact (`ui/GeohashPickerActivity.kt:104-148`). +- **L12. `packet.recipientID != SpecialRecipients.BROADCAST` is ByteArray reference comparison — always true** (`mesh/BluetoothPacketBroadcaster.kt:340`). +- **L13. GATT server ignores preparedWrite/offset; force-unwrap NPEs on hot paths degrade gracefully** (`mesh/BluetoothGattServerManager.kt:232-239`, `mesh/PacketProcessor.kt:127`). + +--- + +## Verified positives + +- SecureRandom everywhere; no weak RNG. +- Keys in EncryptedSharedPreferences (AES256-GCM Keystore master key); `allowBackup="false"` + backup rules. +- No plaintext message DB; messages in-memory only; message IDs in encrypted prefs. +- PeerID = SHA-256(static key)[:8] with session establishment refusing mismatches; session-generation binding defeats re-handshake downgrade. +- AEAD verify-before-decrypt, constant-time tag check, nonce-wraparound enforcement, rekey limits, zeroization on destroy. +- NIP-17 seal signature verified + `seal.pubkey == rumor.pubkey` enforced; Schnorr nonces use SecureRandom. +- Tor fail-closed (proxy set before bootstrap; clients rebuilt on mode change); no telemetry/analytics SDKs; all traffic wss/https. +- Filename sanitization blocks path traversal; FileProvider per-URI grants; no auto-opening URLs; links require explicit tap and are coerced to https. +- Fragment caps (256/ID, 1 MB/set, 4 MB global, 30 s timeout); dedup caches bounded; connection limits + backoff; presence heartbeats only at city-level precision with jitter. + +## Top remediations (priority order) + +1. Strip all key/content logging (`SymmetricState`, `MessageHandler.kt:88`); add ProGuard `assumenosideeffects` for `android.util.Log`. *(C1, H3)* +2. Hard-cap `originalSize` in `CompressionUtil.decompress` (~1–2 MB); tighten ratio to ≈1032:1. *(C2)* +3. Fix replay-window shift direction + unit tests; reject low-order points / all-zero DH in `mixDH`. *(H1, M1)* +4. Verify `isValidSignature()` on all incoming Nostr events; pin/sign the relay directory. *(H5, M9)* +5. Cross-sign Ed key with Noise key (proof-of-possession) in announcements. *(H2)* +6. Bound PacketProcessor actor map (validate before actor creation), bound queues, authenticate/rate-limit FRAGMENT & REQUEST_SYNC relay, budget sync responses. *(H9–H11)* +7. Key fragment reassembly by (senderID, fragmentID); rotate the advertised peerID. *(M4, M3)* +8. Gate auto delivery/read receipts behind a privacy setting; warn about location-note precision/permanence. *(H6, H7)* +9. Lock-screen-redact DM notifications; image decode bounds checks off main thread. *(H4, M6)* +10. Bound Nostr `messageQueue` (drain after send) and `GeohashRepository` caches. *(M11, M12)* diff --git a/docs/sync.md b/docs/sync.md new file mode 100644 index 00000000..0bbd0735 --- /dev/null +++ b/docs/sync.md @@ -0,0 +1,149 @@ +# GCS Filter Sync (REQUEST_SYNC) + +This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets (ANNOUNCE and broadcast MESSAGE) across nodes via periodic sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets. + +## Overview + +- Each node maintains a rolling set of public BitChat packets it has seen recently: + - Broadcast messages (MessageType.MESSAGE where recipient is broadcast) + - Identity announcements (MessageType.ANNOUNCE) + - Default retention is 100 recent packets (configurable in the debug sheet). This value is the maximum number of packets that are synchronized per request (across both types combined). +- Nodes do not maintain a rolling Bloom filter. Instead, they compute a GCS filter on demand when sending a REQUEST_SYNC. +- Every 30 seconds, a node sends a REQUEST_SYNC packet to all immediate neighbors (local only; not relayed). +- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a REQUEST_SYNC only to that peer (unicast; local only). +- The receiver checks which packets are not in the sender’s filter and sends those packets back. For announcements, only the latest announcement per peerID is sent; for broadcast messages, all missing ones are sent. + +This synchronization is strictly local (not relayed), ensuring only immediate neighbors participate and preventing wide-area flooding while converging content across the mesh. + +## Packet ID + +To compare packets across peers, a deterministic packet ID is used: + +- ID = first 16 bytes of SHA-256 over: [type | senderID | timestamp | payload] +- This yields a 128-bit ID used in the filter. + +Implementation: `com.bitchat.android.sync.PacketIdUtil`. + +## GCS Filter (On-demand) + +Implementation: `com.bitchat.android.sync.GCSFilter`. + +- Parameters (configurable): + - size: 128–1024 bytes (default 256) + - target false positive rate (FPR): default 1% (range 0.1%–5%) +- Derivations: + - P = ceil(log2(1/FPR)) + - Maximum number of elements that fit into the filter is estimated as: N_max ≈ floor((8 * sizeBytes) / (P + 2)) + - This estimate is used to cap the set; the actual encoder will trim further if needed to stay within the configured size. +- What goes into the set: + - Combine the following and sort by packet timestamp (descending): + - Broadcast messages (MessageType 1) + - The most recent ANNOUNCE per peer + - Take at most `min(N_max, maxPacketsPerSync)` items from this ordered list. + - Compute the 16-byte Packet ID (see below), then for hashing use the first 8 bytes of SHA‑256 over the 16‑byte ID. + - Map each hash to [0, M) with M = N * 2^P; sort ascending and encode deltas with Golomb‑Rice parameter P. + +Hashing scheme (fixed for cross‑impl compatibility): +- Packet ID: first 16 bytes of SHA‑256 over [type | senderID | timestamp | payload]. +- GCS hash: h64 = first 8 bytes of SHA‑256 over the 16‑byte Packet ID, interpreted as an unsigned 64‑bit integer. Value = h64 % M. + +## REQUEST_SYNC Packet + +MessageType: `REQUEST_SYNC (0x21)` + +- Header: normal BitChat header with TTL indicating “local-only” semantics. Implementations SHOULD set TTL=0 to prevent any relay; neighbors still receive the packet over the direct link-layer. For periodic sync, recipient is broadcast; for per-peer initial sync, recipient is the specific peer. +- Payload: TLV with 16‑bit big‑endian length fields (type, length16, value) + - 0x01: P (uint8) — Golomb‑Rice parameter + - 0x02: M (uint32) — hash range N * 2^P + - 0x03: data (opaque) — GCS bitstream (MSB‑first bit packing) + +Notes: +- The GCS bitstream uses MSB‑first packing (bit 7 is the first bit in each byte). +- Receivers MUST reject filters with data length exceeding the local maximum (default 1024 bytes) to avoid DoS. + +Encode/Decode implementation: `com.bitchat.android.model.RequestSyncPacket`. + +## Behavior + +Sender behavior: +- Periodic: every 30 seconds, send REQUEST_SYNC with a freshly computed GCS snapshot, broadcast to immediate neighbors, and mark as local‑only (TTL=0 recommended; do not relay). +- Initial per-peer: upon receiving the first ANNOUNCE from a new directly connected peer, send a REQUEST_SYNC only to that peer after ~5 seconds (unicast; TTL=0 recommended; do not relay). + +Receiver behavior: +- Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream. +- For each locally stored public packet ID: + - Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` to the requester only. + - For announcements, send only the latest announcement per (sender peerID). + - For broadcast messages, send all missing ones. + +Announcement retention and pruning (consensus): +- Store only the most recent announcement per peerID for sync purposes. +- Age-out policy: announcements older than 60 seconds MUST be removed from the sync candidate set. +- Pruning cadence: run pruning every 15 seconds to drop expired announcements. +- LEAVE handling: upon receiving a LEAVE message from a peer, immediately remove that peer’s stored announcement from the sync candidate set. +- Stale/offline peer handling: when a peer is considered stale/offline (e.g., last announcement older than 60 seconds), immediately remove that peer’s stored announcement from the sync candidate set. + +Important: original packets are sent unmodified to preserve original signatures (e.g., ANNOUNCE). They MUST NOT be relayed beyond immediate neighbors. Implementations SHOULD send these response packets with TTL=0 (local-only) and, when possible, route them only to the requesting peer without altering the original packet contents. + +## Scope and Types Included + +Included in sync: +- Public broadcast messages: `MessageType.MESSAGE` with BROADCAST recipient (or null recipient). +- Identity announcements: `MessageType.ANNOUNCE`. +- Both packets produced by other peers and packets produced by the requester itself MUST be represented in the requester’s GCS; the responder MUST track and consider its own produced public packets as candidates to return when they are missing on the requester. +- Announcements included in the GCS MUST be at most 60 seconds old at the time of filter construction; older announcements are excluded by pruning. + +Not included: +- Private messages and any packets addressed to a non-broadcast recipient. + +## Configuration (Debug Sheet) + +Exposed under “sync settings” in the debug settings sheet: +- Max packets per sync (default 100) +- Max GCS filter size in bytes (default 256, min 128, max 1024) +- GCS target FPR in percent (default 1%, 0.1%–5%) +- Derived values (display only): P and the estimated maximum number of elements that fit into the filter. + +Backed by `DebugPreferenceManager` getters and setters: +- `getSeenPacketCapacity` / `setSeenPacketCapacity` +- `getGcsMaxFilterBytes` / `setGcsMaxFilterBytes` +- `getGcsFprPercent` / `setGcsFprPercent` + +## Android Integration + +- New/updated types and classes: + - `MessageType.REQUEST_SYNC` (0x21) in `BinaryProtocol.kt` + - `RequestSyncPacket` in `model/RequestSyncPacket.kt` + - `GCSFilter` and `PacketIdUtil` in `sync/` + - `GossipSyncManager` in `sync/` +- `BluetoothMeshService` wires and starts the sync manager, schedules per-peer initial (unicast) and periodic (broadcast) syncs, and forwards seen public packets (including our own) to the manager. +- `PacketProcessor` handles REQUEST_SYNC and forwards to `BluetoothMeshService` which responds via the sync manager with responses targeted only to the requester. + +## Compatibility Notes + +- GCS hashing and TLV structures are fully specified above; other implementations should use the same hashing scheme and payload layout for interoperability. +- REQUEST_SYNC and responses are local-only and MUST NOT be relayed. Implementations SHOULD use TTL=0 to prevent relaying. If an implementation requires TTL>0 for local delivery, it MUST still ensure that REQUEST_SYNC and responses are not relayed beyond direct neighbors (e.g., by special-casing these types in relay logic). + +## Consensus vs. Configurable + +The following items require consensus across all implementations to ensure interoperability: + +- Packet ID recipe: first 16 bytes of SHA‑256(type | senderID | timestamp | payload). +- GCS hashing function and mapping to [0, M) as specified above (v1), and MSB‑first bit packing for the bitstream. +- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque). +- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only ANNOUNCE and broadcast MESSAGE are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”. + +The following are requester‑defined and communicated or local policy (no global agreement required): + +- GCS parameters: P and M are carried in the REQUEST_SYNC and must be used by the receiver for membership tests. The sender chooses size and FPR; receivers MUST cap accepted data length for DoS protection. +- Local storage policy: how many packets to consider and how you determine the “latest” announcement per peer. +- Sync cadence: how often to send REQUEST_SYNC and initial delay after new neighbor connection; whether to use unicast for initial per-peer sync versus broadcast for periodic sync. The number of packets included is bounded by the debug setting and filter capacity. + +Validation and limits (recommended): + +- Reject malformed REQUEST_SYNC payloads (e.g., P < 1, M <= 0, or data length too large for local limits). +- Practical bounds: data length in [0, 1024]; P in [1, 24]; M up to 2^32‑1. + +Versioning: + +- This document defines a fixed GCS hashing scheme (“v1”) with no explicit version field in the payload. Changing the hashing or ID recipe would require a new message or an additional TLV in a future revision; current deployments must adhere to the constants above. diff --git a/docs/test-implementation-plan.md b/docs/test-implementation-plan.md new file mode 100644 index 00000000..e64b633b --- /dev/null +++ b/docs/test-implementation-plan.md @@ -0,0 +1,779 @@ +# Test implementation plan + +## Objective + +Build enough deterministic, adversarial, integration, and device-level coverage +that the Android client can be rewritten from scratch without silently changing +its wire behavior, security properties, delivery semantics, lifecycle behavior, +or user-visible workflows. + +The canonical compatibility requirements are documented in +[client-rewrite-contracts.md](client-rewrite-contracts.md). This plan describes +how to turn those requirements into a complete, continuously enforced test +program. + +## Status legend + +- **Complete**: acceptance criteria are met and the tests run in the rewrite gate. +- **In progress**: implementation has started but acceptance criteria are not met. +- **Not started**: no implementation work has been completed. +- **Blocked**: progress requires an external dependency, device, or decision. + +## Current progress + +| Milestone | Status | Progress | Depends on | +|---|---|---:|---| +| 0. Compatibility baseline | Complete | 100% | — | +| 1. Coverage and deterministic test infrastructure | Not started | 0% | 0 | +| 2. Adversarial protocol and parser testing | Not started | 0% | 1 | +| 3. Noise, cryptography, and identity testing | Not started | 0% | 1 | +| 4. BLE, Wi-Fi Aware, and transport lifecycle testing | Not started | 0% | 1, 3 | +| 5. Sync, routing, and store-and-forward testing | Not started | 0% | 1, 4 | +| 6. Nostr and Tor integration testing | Not started | 0% | 1, 3 | +| 7. Android lifecycle and permission testing | Not started | 0% | 1, 4 | +| 8. Persistence, migration, and recovery testing | Not started | 0% | 1, 3 | +| 9. UI, media, and accessibility testing | Not started | 0% | 1, 7, 8 | +| 10. Physical-device and cross-client release gate | Not started | 0% | 2–9 | + +Milestone completion is currently **1 of 11 milestones (9%)**. This is +milestone-based progress, not line or branch coverage. Milestone 1 will establish +measured coverage baselines and trends. + +## Test levels and execution policy + +| Level | Purpose | Expected execution | +|---|---|---| +| Pure JVM unit tests | Protocols, state machines, crypto vectors, parsing, routing, and deterministic utilities | Every pull request | +| Property and fuzz tests | Malformed inputs, boundary exploration, invariants, and crash resistance | Bounded set on every pull request; extended corpus nightly | +| Robolectric tests | Android services, lifecycle, broadcasts, permissions, persistence, and process recreation | Every pull request where stable | +| Instrumented emulator tests | Compose semantics, navigation, database/filesystem integration, and permission flows | Main branch and release candidates | +| Physical-device tests | BLE, Wi-Fi Aware, radios, background execution, and manufacturer-specific behavior | Nightly where devices are available; mandatory release gate | +| Cross-client interoperability | Android/iOS and old/new client wire compatibility | Mandatory release gate | + +## Global rules + +- [x] Keep literal golden vectors for externally visible bytes and hashes. +- [x] Run all compatibility and regression tests through + `./gradlew clientRewriteContractTest`. +- [ ] Prefer public behavior and stable adapter interfaces over implementation + details. +- [ ] Require deterministic clocks, randomness, dispatchers, storage, and + transports in tests. +- [ ] Never use production relay or internet availability as a test dependency. +- [ ] Every fixed protocol or security defect must receive a regression test. +- [ ] Every decoder must have positive, boundary, malformed, and fuzz coverage. +- [ ] Every asynchronous test must have bounded completion and must not use + arbitrary sleeps. +- [ ] Test failures must preserve seeds, inputs, and traces needed to reproduce + the failure. +- [ ] Golden vectors may change only with an intentional protocol version change + and coordinated interoperability review. + +--- + +## Milestone 0: Compatibility baseline + +**Status:** Complete +**Progress:** 100% + +### Scope + +Establish executable rewrite contracts for the most important deterministic +wire formats and reuse the existing regression suite as a single acceptance +gate. + +### Completed checklist + +- [x] Create an isolated workspace and + `codex/client-rewrite-contract-tests` branch. +- [x] Add literal v1 and v2 outer packet vectors. +- [x] Add public, private, optional-field, and encrypted message vectors. +- [x] Add private-message, Noise envelope, fragment, sync, identity, and file + transfer vectors. +- [x] Add padding, binary encoding, geohash, gossip, packet ID, GCS, and Noise + peer-ID contracts. +- [x] Add Bech32, secp256k1, NIP-01, NIP-44, and NIP-13 contracts. +- [x] Add required-prefix and representative truncated-input rejection tests. +- [x] Add explicit validation for malformed fragment IDs. +- [x] Add `clientRewriteContractTest` as the complete rewrite acceptance task. +- [x] Verify 32 new tests pass without skips. +- [x] Verify the full gate discovers 254 tests with zero failures or errors. +- [x] Document the remaining device-only acceptance requirements. + +### Acceptance criteria + +- [x] The original `main` workspace remains unchanged. +- [x] All new golden-vector tests pass. +- [x] The complete unit suite passes through one documented command. + +--- + +## Milestone 1: Coverage and deterministic test infrastructure + +**Status:** Not started +**Progress:** 0% + +### Goal + +Make coverage measurable and provide reusable deterministic seams so later +milestones test behavior without real time, radios, network access, or flaky +scheduling. + +### TODO checklist + +#### Coverage reporting + +- [ ] Add JaCoCo or Kover for JVM unit-test line and branch coverage. +- [ ] Generate XML and HTML reports from the rewrite acceptance task. +- [ ] Record the initial project-wide line and branch coverage baseline. +- [ ] Record package-level baselines for `mesh`, `noise`, `nostr`, `service`, + `services`, `sync`, `model`, `protocol`, `identity`, and `ui`. +- [ ] Publish coverage artifacts in CI. +- [ ] Add a changed-lines coverage check for new production code. +- [ ] Add non-regression thresholds without forcing low-value tests for trivial + generated or platform glue. +- [ ] Exclude generated code, Compose compiler output, Android resource classes, + and vendored cryptographic code from first-party coverage metrics. + +#### Deterministic seams + +- [ ] Introduce an injectable monotonic clock and wall clock. +- [ ] Introduce injectable secure and non-secure random-byte sources where + deterministic vectors are required. +- [ ] Introduce injectable coroutine dispatchers and test scopes. +- [ ] Introduce an in-memory key/value storage adapter for preferences. +- [ ] Introduce an in-memory file store with controllable I/O failures. +- [ ] Define a fake mesh transport that can connect, disconnect, delay, drop, + duplicate, corrupt, reorder, and fragment packets. +- [ ] Define fake BLE scanner, advertiser, GATT client, and GATT server adapters. +- [ ] Define a fake Wi-Fi Aware session/socket adapter. +- [ ] Define a fake Nostr relay transport or MockWebServer fixture. +- [ ] Provide reusable packet, identity, peer, graph, and message fixture + builders. +- [ ] Provide seed capture and reproduction helpers for randomized tests. +- [ ] Add test naming and directory conventions for unit, property, Robolectric, + instrumented, and interoperability suites. + +### Acceptance criteria + +- [ ] One command generates a repeatable coverage report. +- [ ] Two consecutive clean runs produce identical deterministic test results. +- [ ] Fake time and transport behavior require no wall-clock sleeps. +- [ ] CI publishes coverage and test-result artifacts. +- [ ] The plan's progress table is updated with measured baseline numbers. + +--- + +## Milestone 2: Adversarial protocol and parser testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Prove that all wire decoders preserve canonical behavior, reject unsafe input, +and never crash or allocate unreasonable memory for attacker-controlled data. + +### TODO checklist + +#### Outer packet protocol + +- [ ] Test every valid flag combination for v1 and v2. +- [ ] Test exact minimum and maximum payload sizes. +- [ ] Test sender and recipient IDs at 0, 1, 7, 8, 9, and oversized lengths. +- [ ] Test signatures at 0, 1, 63, 64, 65, and oversized lengths. +- [ ] Test route counts at 0, 1, 254, 255, and truncated route entries. +- [ ] Test unknown message type values remain safely representable or are + rejected according to the protocol contract. +- [ ] Test invalid versions, reserved flags, integer overflow, and unsigned + length conversion. +- [ ] Test trailing bytes and concatenated frames explicitly. +- [ ] Test padding boundaries around 256, 512, 1024, and 2048 bytes. +- [ ] Test malformed PKCS#7 tails and ambiguous unpadded frames. +- [ ] Test raw DEFLATE and zlib-header compatibility vectors. +- [ ] Test forged original-size fields, compression bombs, and truncated + compressed streams. +- [ ] Add encode/decode property tests for all valid packet shapes. +- [ ] Add a mutation corpus derived from every golden packet. + +#### Inner payloads and TLVs + +- [ ] Fuzz `BitchatMessage.fromBinaryPayload`. +- [ ] Fuzz `IdentityAnnouncement.decode`. +- [ ] Fuzz `AuthenticatedPeerState.decode`. +- [ ] Fuzz `PrivateMessagePacket.decode`. +- [ ] Fuzz `NoisePayload.decode`. +- [ ] Fuzz `BitchatFilePacket.decode`. +- [ ] Fuzz `FragmentPayload.decode`. +- [ ] Fuzz `RequestSyncPacket.decode`. +- [ ] Test missing, duplicated, reordered, unknown, and zero-length TLVs. +- [ ] Test truncated headers and values at every byte offset. +- [ ] Test UTF-8 ASCII, multi-byte, combining-mark, emoji, invalid-byte, and + maximum-byte-length cases. +- [ ] Test 255-byte one-byte-length boundaries. +- [ ] Test 65,535-byte two-byte-length boundaries. +- [ ] Test four-byte file content lengths and impossible content declarations. +- [ ] Test fragmented file content using one and multiple content TLVs. +- [ ] Define and test whether non-canonical but tolerated inputs re-encode + canonically. + +#### Fuzzing operations + +- [ ] Select a JVM-compatible property/fuzz framework. +- [ ] Add bounded pull-request fuzz runs with fixed seeds. +- [ ] Add extended randomized nightly runs. +- [ ] Store minimized failing inputs as regression fixtures. +- [ ] Assert no decoder throws for arbitrary byte arrays. +- [ ] Assert decoder runtime and allocations stay within configured bounds. + +### Acceptance criteria + +- [ ] Every externally reachable decoder has boundary and malformed-input tests. +- [ ] Every decoder has a bounded arbitrary-byte no-crash property. +- [ ] All discovered crashes or ambiguous contracts have regression fixtures. +- [ ] Extended fuzzing completes nightly and preserves reproduction seeds. + +--- + +## Milestone 3: Noise, cryptography, and identity testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Prove confidentiality, authenticity, identity binding, replay behavior, session +replacement, rekeying, and recovery across the complete secure-channel +lifecycle. + +### TODO checklist + +#### Known vectors and primitives + +- [ ] Add known Curve25519 key agreement vectors. +- [ ] Add known Ed25519 signing and verification vectors. +- [ ] Add known BIP-340 verification vectors. +- [ ] Add known HKDF and channel-key derivation vectors. +- [ ] Add external Noise XX handshake transcript vectors where compatible. +- [ ] Add deterministic channel encryption vectors with injected nonces. +- [ ] Test constant-time verification APIs where the underlying library exposes + an appropriate contract. + +#### Noise session lifecycle + +- [ ] Test initiator and responder handshakes without manager wrappers. +- [ ] Test all valid handshake state transitions. +- [ ] Test every invalid message for every handshake state. +- [ ] Test tampered handshake messages and remote static-key substitution. +- [ ] Test post-handshake encryption in both directions. +- [ ] Test empty, small, maximum, and fragmented plaintext. +- [ ] Test tampered ciphertext, nonce, tag, and associated data. +- [ ] Test replayed ciphertext. +- [ ] Test skipped, duplicated, and out-of-order transport messages. +- [ ] Test send and receive nonce progression. +- [ ] Test nonce exhaustion and counter-overflow behavior. +- [ ] Test rekey thresholds, successful rekey, failed rekey, and simultaneous + rekey. +- [ ] Test session reset and destruction zeroize or discard sensitive state as + designed. +- [ ] Test handshake timeouts and stale generation leases with fake time. +- [ ] Test simultaneous initiator tie-breaking across a larger peer matrix. +- [ ] Test process restart with and without persisted identity. + +#### Identity and downgrade protection + +- [ ] Test peer-ID derivation for valid and malformed static keys. +- [ ] Test signing-key rotation with authorized and unauthorized announcements. +- [ ] Test private-media capability pinning across restart. +- [ ] Test downgrade attempts after a capability has been pinned. +- [ ] Test corrupted, missing, partially written, and legacy identity storage. +- [ ] Test atomic clearing of identity, capability, and peer mappings. +- [ ] Test verification fingerprints remain stable for unchanged identities. +- [ ] Test identity replacement does not expose an established session before + authentication completes. + +### Acceptance criteria + +- [ ] Known vectors pass independently of Android storage and services. +- [ ] Replay, tampering, downgrade, and identity-substitution tests all fail + closed. +- [ ] All timeouts and rekey tests use fake time. +- [ ] No sensitive test fixtures contain production keys or user data. + +--- + +## Milestone 4: BLE, Wi-Fi Aware, and transport lifecycle testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify connection state machines and packet delivery across unreliable Android +transports without requiring real radios for the majority of cases. + +### TODO checklist + +#### BLE discovery and connection + +- [ ] Test scan start, stop, restart, and failure callbacks. +- [ ] Test advertising start, stop, restart, and failure callbacks. +- [ ] Test Bluetooth-off and Bluetooth-on recovery. +- [ ] Test duplicate scan results and rapidly changing peer addresses. +- [ ] Test connection success, rejection, timeout, and cancellation. +- [ ] Test simultaneous inbound and outbound connection races. +- [ ] Test canonical connection selection and duplicate-link teardown. +- [ ] Test service discovery failure and missing characteristics. +- [ ] Test GATT disconnect during discovery, negotiation, read, and write. +- [ ] Test reconnect backoff with fake time. +- [ ] Test maximum-connection enforcement and eviction policy. +- [ ] Test RSSI thresholds and power-mode transitions. + +#### Packet transfer + +- [ ] Test MTU negotiation at minimum, normal, and maximum values. +- [ ] Test partial writes and write callbacks delivered out of order. +- [ ] Test notification subscription and notification failure. +- [ ] Test queue backpressure and bounded memory use. +- [ ] Test fragmentation and reassembly across disconnect/reconnect. +- [ ] Test duplicate, missing, reordered, and corrupted fragments. +- [ ] Test cancellation cleans pending queues and transfer state. +- [ ] Test large file/media transfers under constrained MTU. +- [ ] Test broadcast and directed packet delivery. +- [ ] Test packet relay while one link disconnects. + +#### Wi-Fi Aware + +- [ ] Test feature unavailable and permission-denied behavior. +- [ ] Test publish/subscribe session creation and teardown. +- [ ] Test provisional link authentication and canonical promotion. +- [ ] Test socket replacement and stale-socket rejection. +- [ ] Test partial reads, writes, EOF, exceptions, and cancellation. +- [ ] Test reconnect and rediscovery. +- [ ] Test coexistence with BLE for the same peer. + +#### Unified transport behavior + +- [ ] Test peer-list union and removal across transports. +- [ ] Test preferred-transport selection. +- [ ] Test transparent failover between BLE and Wi-Fi Aware. +- [ ] Test duplicate packet suppression across transports. +- [ ] Test transport bridge TTL decrement and loop prevention. +- [ ] Test shutdown cancels all jobs, scans, advertisements, sockets, and queues. + +### Acceptance criteria + +- [ ] Transport state-machine tests run deterministically on the JVM or + Robolectric. +- [ ] Disconnect and cancellation tests leave no queued work or active jobs. +- [ ] Cross-transport duplicate delivery and loops are prevented. +- [ ] A smaller physical-device suite confirms the fake adapters match Android + behavior. + +--- + +## Milestone 5: Sync, routing, and store-and-forward testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify eventual delivery, bounded resource usage, correct routing, and duplicate +suppression during partitions, topology changes, and reconnects. + +### TODO checklist + +#### Packet identity and sync filters + +- [ ] Add more packet-ID vectors for every message type. +- [ ] Prove TTL, route, recipient, and signature mutations do not change sync + identity. +- [ ] Prove payload, sender, timestamp, and type mutations do change identity. +- [ ] Property-test GCS encode/decode membership. +- [ ] Test empty, singleton, maximum-capacity, duplicate, and collision-heavy + filters. +- [ ] Test false-positive behavior statistically against configured tolerances. +- [ ] Test maximum accepted filter bytes and malicious bitstreams. +- [ ] Test sync requests with unknown TLVs and future capability extensions. + +#### Store and forward + +- [ ] Test caching decisions for public, private, favorite, and offline peers. +- [ ] Test cache capacity and deterministic eviction. +- [ ] Test cache expiry with fake time. +- [ ] Test delivery acknowledgement removal. +- [ ] Test retransmission after reconnect. +- [ ] Test duplicate acknowledgements and late acknowledgements. +- [ ] Test process restart persistence policy. +- [ ] Test shutdown and cleanup under active delivery. +- [ ] Test memory bounds under repeated undeliverable messages. + +#### Routing and topology + +- [ ] Test shortest paths for disconnected, cyclic, diamond, and changing graphs. +- [ ] Test deterministic tie-breaking for equal-length routes. +- [ ] Test only confirmed edges are used. +- [ ] Test edge expiry and peer disappearance with fake time. +- [ ] Test a route invalidated between planning and send. +- [ ] Test relay TTL exhaustion at every hop. +- [ ] Test source-route loop rejection. +- [ ] Test broadcast storm suppression. +- [ ] Test delivery across mixed BLE and Wi-Fi Aware paths. +- [ ] Test graph updates while sync and relay operations run concurrently. + +### Acceptance criteria + +- [ ] Partition/reconnect scenarios eventually deliver exactly once at the + application layer. +- [ ] Cache, graph, and filter resource bounds are enforced. +- [ ] No topology or bridge scenario produces an infinite relay loop. +- [ ] All expiry behavior uses fake time. + +--- + +## Milestone 6: Nostr and Tor integration testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify relay communication, subscriptions, event validation, NIP-17 delivery, +and Tor-mode behavior under realistic network failures. + +### TODO checklist + +#### Relay protocol + +- [ ] Add a scripted local WebSocket relay fixture. +- [ ] Test initial connection and clean disconnect. +- [ ] Test DNS, TCP, TLS, WebSocket, and protocol failures. +- [ ] Test reconnect backoff and cancellation with fake time. +- [ ] Test relay notices, acknowledgements, end-of-stored-events, and malformed + messages. +- [ ] Test subscription creation, replacement, unsubscribe, and reconnect + restoration. +- [ ] Test duplicate, delayed, reordered, and conflicting events. +- [ ] Test multi-relay publish success, partial success, and total failure. +- [ ] Test event deduplication across relays. +- [ ] Test relay-list selection and invalid relay URLs. + +#### Event security and messaging + +- [ ] Add external NIP-01, NIP-13, NIP-17, and NIP-44 vectors. +- [ ] Test invalid event IDs and signatures are rejected before dispatch. +- [ ] Test future timestamps, stale timestamps, and integer boundaries. +- [ ] Test NIP-17 gift-wrap signer/rumor identity mismatches. +- [ ] Test malformed seals, wrong recipients, and tampered ciphertext. +- [ ] Test private-message and acknowledgement embedding/extraction. +- [ ] Test geohash note, presence, and ephemeral-event filters. +- [ ] Test nickname and teleport tags. +- [ ] Test proof-of-work policy at exact difficulty boundaries. +- [ ] Test cancellation and bounded mining iterations. + +#### Tor behavior + +- [ ] Add a fake Tor-state provider and proxy-selection tests. +- [ ] Test direct, Tor-only, and fallback modes. +- [ ] Test bootstrap delay, bootstrap failure, proxy failure, and shutdown. +- [ ] Verify Tor-only mode never silently uses a direct connection. +- [ ] Verify mode changes rebuild clients and close old connections. + +### Acceptance criteria + +- [ ] Nostr integration tests require no public relay or internet connection. +- [ ] Invalid or unauthenticated events never reach application state. +- [ ] Reconnect restores intended subscriptions without duplicate delivery. +- [ ] Tor-only policy fails closed. + +--- + +## Milestone 7: Android lifecycle and permission testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Verify the app behaves correctly under Android process, service, permission, +Bluetooth, battery, and background-execution rules. + +### TODO checklist + +#### Foreground service + +- [ ] Add Robolectric tests for service create, start, bind, unbind, and destroy. +- [ ] Test repeated start commands are idempotent. +- [ ] Test foreground notification creation and channel configuration. +- [ ] Test explicit shutdown clears transport and application state correctly. +- [ ] Test unexpected process/service recreation restores required state. +- [ ] Test task removal behavior. +- [ ] Test boot-completed handling. +- [ ] Test service start restrictions and failure reporting. +- [ ] Test all coroutines and resources are cancelled on destroy. + +#### Permissions and system state + +- [ ] Test first-run permission explanations. +- [ ] Test denial, permanent denial, and later grant. +- [ ] Test partial Bluetooth permission grants by Android version. +- [ ] Test location-disabled and Bluetooth-disabled states. +- [ ] Test notification permission denial. +- [ ] Test microphone permission denial during voice recording. +- [ ] Test background-location preferences where applicable. +- [ ] Test battery-optimization accepted, declined, and unavailable paths. +- [ ] Test configuration changes during onboarding. +- [ ] Test onboarding restoration after process recreation. + +#### Android-version matrix + +- [ ] Define minimum, target, and newest-supported API test matrix. +- [ ] Add emulator coverage for behavior changes in permissions and foreground + services. +- [ ] Add at least one low-memory/process-death scenario. +- [ ] Add manufacturer-device coverage for known BLE/background differences. + +### Acceptance criteria + +- [ ] Critical service and permission flows have Robolectric or instrumented + coverage. +- [ ] No permission denial crashes or leaves onboarding irrecoverable. +- [ ] Service recreation does not duplicate transports or lose required state. +- [ ] Required API-level matrix passes before release. + +--- + +## Milestone 8: Persistence, migration, and recovery testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Ensure identities, settings, aliases, favorites, bookmarks, messages, and +capability pins survive upgrades and fail safely when storage is incomplete or +corrupt. + +### TODO checklist + +- [ ] Inventory every persisted key, file, schema, and version marker. +- [ ] Create legacy fixtures for every supported application version. +- [ ] Test clean first launch with no persisted state. +- [ ] Test upgrade from each retained legacy fixture. +- [ ] Test unknown future fields are preserved or ignored safely. +- [ ] Test truncated, malformed, empty, and type-mismatched preference values. +- [ ] Test partial multi-key identity writes. +- [ ] Test storage write failure and rollback. +- [ ] Test concurrent readers and writers. +- [ ] Test alias merging and canonical conversation migration. +- [ ] Test chronological ordering after migration. +- [ ] Test favorite and bookmark preservation. +- [ ] Test message-retention expiry with fake time. +- [ ] Test secure identity clearing removes all linked mappings and pins. +- [ ] Test signing-key and capability rotation is atomic. +- [ ] Test backup/restore policy does not duplicate or expose sensitive identity + material. +- [ ] Test migration idempotence by running each migration twice. +- [ ] Test downgrade behavior when a newer schema has already been written. + +### Acceptance criteria + +- [ ] Every supported legacy fixture migrates deterministically. +- [ ] Failed migrations leave either the old valid state or the new valid state, + never a partial mixture. +- [ ] Security-sensitive corruption fails closed with a recoverable user path. +- [ ] Migration and retention behavior uses deterministic storage and time. + +--- + +## Milestone 9: UI, media, and accessibility testing + +**Status:** Not started +**Progress:** 0% + +### Goal + +Protect user-visible behavior and media workflows while keeping most assertions +at ViewModel/state boundaries and reserving Compose instrumentation for genuine +interaction and rendering contracts. + +### TODO checklist + +#### ViewModels and application state + +- [ ] Restore or replace the currently skipped command-processor tests. +- [ ] Restore or replace the currently skipped notification tests. +- [ ] Test public/private/channel conversation switching. +- [ ] Test optimistic send, success, failure, retry, and cancellation. +- [ ] Test delivery and read-receipt transitions. +- [ ] Test peer arrival, departure, alias change, and identity rotation. +- [ ] Test state restoration after configuration change and process recreation. +- [ ] Test concurrent inbound messages while changing conversations. +- [ ] Test error messages for permission, transport, storage, and crypto failures. + +#### Compose UI + +- [ ] Add semantics tests for critical chat actions. +- [ ] Test onboarding navigation and recoverability. +- [ ] Test empty, loading, connected, disconnected, and error states. +- [ ] Test long nicknames, messages, channels, and localized text. +- [ ] Test dynamic font sizes and display scaling. +- [ ] Test light, dark, and supported theme variants. +- [ ] Add screenshot tests only for stable high-value layouts. +- [ ] Test keyboard, focus, back navigation, and bottom-sheet behavior. +- [ ] Test screen-reader labels, traversal order, and minimum touch targets. +- [ ] Test reduced-motion behavior where animations are nonessential. + +#### Files, images, and voice + +- [ ] Test zero-byte, small, maximum-size, and oversized files. +- [ ] Test unsupported and misleading MIME types. +- [ ] Test missing filenames and Unicode filenames. +- [ ] Test file read/write failures and insufficient storage. +- [ ] Test image decode failures, orientation metadata, and large-image memory + limits. +- [ ] Test voice-recording start, pause/stop, cancellation, and microphone loss. +- [ ] Test corrupt and unsupported audio playback. +- [ ] Test waveform generation boundaries. +- [ ] Test interrupted private-media preparation and commit rollback. +- [ ] Test cleanup of temporary files after success, failure, and cancellation. + +### Acceptance criteria + +- [ ] Critical user journeys pass through state-level tests. +- [ ] A focused Compose suite protects navigation, semantics, and accessibility. +- [ ] Media failures are visible, recoverable, and leak no temporary resources. +- [ ] Previously skipped UI-related tests are either active or replaced with + equivalent coverage. + +--- + +## Milestone 10: Physical-device and cross-client release gate + +**Status:** Not started +**Progress:** 0% + +### Goal + +Validate the behavior that JVM, Robolectric, and emulator tests cannot prove: +real radios, background limits, device interoperability, and compatibility with +released clients. + +### TODO checklist + +#### Device matrix and harness + +- [ ] Define a minimum physical-device matrix covering at least two Android API + levels and two manufacturers. +- [ ] Include devices supporting BLE only and BLE plus Wi-Fi Aware where + available. +- [ ] Build a test control channel that does not interfere with mesh transport. +- [ ] Capture structured traces, packet IDs, connection transitions, and failure + reasons. +- [ ] Make test accounts, identities, and files disposable and non-personal. +- [ ] Provide deterministic scenario setup and cleanup. + +#### Android-to-Android scenarios + +- [ ] Discover, connect, exchange announcements, disconnect, and reconnect. +- [ ] Send public and private messages in both directions. +- [ ] Verify delivery and read receipts. +- [ ] Transfer image, audio, and generic files at multiple sizes. +- [ ] Relay across at least three devices. +- [ ] Partition the mesh and verify store-and-forward delivery after reconnect. +- [ ] Disable and re-enable Bluetooth during active transfers. +- [ ] Lock screens and background both apps during active mesh operation. +- [ ] Kill and recreate one process. +- [ ] Exercise simultaneous connections and duplicate-link resolution. +- [ ] Exercise Wi-Fi Aware failover where supported. + +#### Cross-client and backward compatibility + +- [ ] Test current Android against the current iOS client. +- [ ] Test the rewrite against the last supported Android release. +- [ ] Test legacy announcements without capabilities. +- [ ] Test current capability announcements with an older client. +- [ ] Test canonical private-media type and decode-only prerelease alias. +- [ ] Compare packet, message, identity, fragment, sync, and file golden vectors + across implementations. +- [ ] Verify malformed and unauthenticated inputs are rejected consistently. +- [ ] Verify Nostr fallback messages and receipts across clients. + +#### Background and endurance + +- [ ] Run a multi-hour discovery/connect/disconnect soak test. +- [ ] Run repeated large-transfer and cancellation cycles. +- [ ] Monitor memory, threads, file descriptors, wake locks, and battery impact. +- [ ] Test foreground-service survival with screens off. +- [ ] Test network and Tor availability changes during Nostr operation. +- [ ] Confirm shutdown releases radios, sockets, jobs, and wake locks. + +### Acceptance criteria + +- [ ] All mandatory scenarios pass on the defined device matrix. +- [ ] Android/iOS and old/new clients exchange every supported critical payload. +- [ ] No endurance run shows unbounded growth or leaked resources. +- [ ] Failures produce sufficient traces for deterministic reproduction where + possible. +- [ ] Release approval records the client versions, device matrix, and results. + +--- + +## CI rollout + +### Pull-request gate + +- [ ] Run formatting and static analysis. +- [ ] Run deterministic JVM unit tests. +- [ ] Run bounded property/fuzz tests. +- [ ] Run stable Robolectric tests. +- [ ] Run `clientRewriteContractTest`. +- [ ] Upload JUnit and coverage reports. +- [ ] Reject new failures, errors, or unexpected skips. +- [ ] Reject golden-vector changes without the protocol-change review label. + +### Main and nightly gate + +- [ ] Run the extended fuzz corpus. +- [ ] Run emulator instrumented tests. +- [ ] Run local relay/Tor integration tests. +- [ ] Run physical-device smoke tests when the lab is available. +- [ ] Track runtime, flakes, coverage, and quarantined tests. + +### Release-candidate gate + +- [ ] Run the complete device matrix. +- [ ] Run Android/iOS and old/new interoperability. +- [ ] Run endurance and background scenarios. +- [ ] Review all skipped or quarantined tests. +- [ ] Archive coverage, test, trace, and version metadata with the release. + +## Progress update procedure + +When work lands: + +1. Check completed TODOs in the relevant milestone. +2. Update the milestone percentage based on completed checklist items. +3. Change status to **In progress** when its first TODO is complete. +4. Change status to **Complete** only when all acceptance criteria are met. +5. Update the top-level progress table and milestone completion count. +6. Link the implementing pull request or commit next to material completed work + without including personal information. +7. Record intentionally deferred items and their justification; do not mark them + complete. + +## Final definition of done + +The test program is complete when: + +- [ ] Milestones 0–10 meet every acceptance criterion. +- [ ] Project and package-level line/branch coverage no longer regress. +- [ ] All critical parsers have adversarial and fuzz coverage. +- [ ] All security-sensitive state transitions fail closed under tampering, + replay, downgrade, and corruption. +- [ ] Transport, sync, and lifecycle tests cover disconnection, cancellation, + timeout, and restart. +- [ ] Physical-device and cross-client scenarios pass for every release. +- [ ] The full rewrite can replace the existing implementation while preserving + the unchanged compatibility and acceptance tests. diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md new file mode 100644 index 00000000..b2da4e7b --- /dev/null +++ b/docs/testing-conventions.md @@ -0,0 +1,90 @@ +# Testing conventions + +## Purpose + +These conventions keep the client-rewrite suite deterministic, reproducible, +and portable across implementations. + +## Test locations + +| Test type | Location | Naming | +|---|---|---| +| JVM unit and contract tests | `app/src/test/` | `*Test.kt` | +| Shared deterministic fakes and fixtures | `app/src/test/**/testsupport/` | Descriptive fixture name | +| Robolectric tests | `app/src/test/` | `*RobolectricTest.kt` | +| Android instrumented tests | `app/src/androidTest/` | `*InstrumentedTest.kt` | +| Coverage-tool tests | `tools/coverage/` | `test_*.py` | +| Interoperability fixtures | `app/src/test/resources/contracts/` | Protocol and version in filename | + +## Required behavior + +- Tests must not use arbitrary sleeps. Advance a fake clock or coroutine test + scheduler instead. +- Tests must not require public relays, internet access, Bluetooth hardware, or a + user's persisted data. +- Time, randomness, dispatchers, storage, and transports must be injectable in + code exercised by state-machine tests. +- Randomized failures must print a reproduction seed. Use `TEST_SEED` for a + specific replay. +- Mutable byte arrays returned by fixtures and fakes must be defensively copied. +- Negative security tests must assert fail-closed behavior. +- Protocol round trips must be paired with literal golden vectors for critical + externally visible formats. +- Asynchronous tests must have a deterministic completion condition and a + bounded timeout. +- A fixed bug must retain its smallest reproducing input as a regression test. + +## Naming + +Test names should describe observable behavior: + +```kotlin +@Test +fun `replayed ciphertext is rejected without advancing receive state`() { + // ... +} +``` + +Avoid names tied to private methods or temporary implementation structure. + +## Fixtures and seeds + +Reusable Kotlin fixtures live under +`com.bitchat.android.testsupport`. `ReproducibleTestSeed` resolves +`TEST_SEED` and provides a reproduction hint: + +```sh +TEST_SEED=12345 ./gradlew clientRewriteContractTest +``` + +Never use production keys, contact information, messages, or other user data in +fixtures. + +## Coverage + +Run the full report and non-regression floor: + +```sh +./gradlew clientRewriteContractTest +``` + +Reports are written to: + +- `app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml` +- `app/build/reports/jacoco/jacocoTestReport/html/` + +Check executable production lines changed from the base branch: + +```sh +COVERAGE_BASE_REF=origin/main ./gradlew checkChangedLineCoverage +``` + +Generated resource classes, Compose-generated singleton classes, platform +bridges, and vendored Noise code are excluded from first-party coverage metrics. + +## Quarantine and skips + +- A flaky test must be fixed, not silently retried. +- A temporary quarantine must include an issue and removal condition. +- Unexpected skips fail review. Existing skips must be restored or replaced by + equivalent coverage. diff --git a/docs/wear-os-implementation-plan.md b/docs/wear-os-implementation-plan.md new file mode 100644 index 00000000..cf89ac36 --- /dev/null +++ b/docs/wear-os-implementation-plan.md @@ -0,0 +1,371 @@ +# Bitchat for Pixel Watch — Implementation Plan + +> **Status tracker**: each milestone carries a status (`pending` / `in-progress` / `done`) and a +> checklist. A milestone may only be started when the previous milestone's success criteria pass. +> Update statuses in this file as work progresses. + +| Milestone | Title | Status | +|-----------|-------|--------| +| M0 | Scaffolding & plan document | done | +| M1 | Shared core compiles on Wear | done | +| M2 | BLE transport & background service on watch | done | +| M3 | Global chat | done | +| M4 | Noise DMs & people screen | done | +| M5 | Files/images receive + voice notes (push-to-talk) + input redesign | done | +| M6 | ADB test hook & mesh_lab interop | done | +| M7 | Polish & final design pass | done | + +--- + +## 1. Context for a fresh coding agent + +- **Reference app**: this repository (`bitchat-android`) is a fully working, decentralized BLE mesh + chat client. Single Gradle module `:app`, root package `com.bitchat.android`, applicationId + `com.bitchat.droid`. See `AGENTS.md` for the full architecture overview. +- **Goal**: a new `:wear` Gradle module (applicationId `com.bitchat.watch`) — a standalone Wear OS + app for the Pixel Watch. **Bluetooth mesh only**: global chat, Noise-encrypted direct messages, + and receiving/displaying files & images. It must be a fully interoperable bitchat client: scan, + advertise, connect, relay, handshake, and exchange messages with the Android (and iOS) apps. +- **Explicitly out of scope**: no internet features (no Nostr, no Tor/Arti, no relays), no GPS / + geohash / location channels, no Wi-Fi Aware, no hotspot/APK sharing, no voice notes recording. + The watch manifest must not even declare `INTERNET` or location permissions. +- **Hard constraint**: **zero modifications to `:app` production code.** The only allowed changes + to shared repo files are: `settings.gradle.kts` (add `include(":wear")`), entries in + `gradle/libs.versions.toml` (new wear dependencies only), the new `wear/` directory, and docs. + All shared Kotlin code is consumed by the `:wear` module *in place* via Gradle source sets — + files are never moved, copied, or edited. + +## 2. Code reuse strategy (shared source sets) + +Wear OS is Android. `android.util.Log`, `android.bluetooth.*`, `EncryptedSharedPreferences` +(androidx.security), BouncyCastle, and coroutines all work on the watch, so the vast majority of +the bitchat protocol stack compiles unmodified. + +In `wear/build.gradle.kts`: + +```kotlin +sourceSets["main"].java.srcDir("../app/src/main/java") // with include filters (see below) +``` + +**Include** (iteratively refined by fixing compile errors — the include list lives in +`wear/build.gradle.kts` with comments): + +- `protocol/**` — wire format, `BinaryProtocol`, `CompressionUtil`, `MessagePadding` +- `model/**` — `BitchatMessage`, `BitchatFilePacket`, `FragmentPayload`, `NoiseEncrypted`, + `IdentityAnnouncement`, `RoutedPacket` +- `noise/**` — `NoiseSession`, `NoiseSessionManager`, `NoiseEncryptionService`, + `NoiseChannelEncryption`, vendored pure-Java `noise/southernstorm/**` +- `crypto/**` — `EncryptionService` +- `identity/**` — `SecureIdentityStateManager` +- `mesh/**` — BLE stack (`BluetoothConnectionManager`, GATT server/client managers, broadcaster, + tracker, permission manager), `FragmentManager`, `SecurityManager`, `PacketProcessor`, + `MessageHandler`, `PeerManager`, `StoreForwardManager`, `MeshTransport`, `MeshService`, + `TransferProgressManager`, `PrivateMediaTransfer`, `PowerManager` +- `services/AppStateStore.kt` — process-wide state store +- `util/AppConstants.kt` — shared constants (GATT UUIDs, fragmentation sizes) +- Small transitive deps the compiler reveals (known: `ui/debug/DebugSettingsManager.kt` is + referenced from the mesh layer — include the file, not the package) + +**Exclude**: `ui/**` (except forced single-file includes), `onboarding/**`, `nostr/**`, `net/**`, +`geohash/**`, `wifi-aware/**`, `hotspot/**`, `features/voice/**`, `service/MeshForegroundService.kt` +(wear gets its own service), `BitchatApplication.kt`, `MainActivity.kt`. + +**Tests**: the app's own unit tests for shared packages (`protocol`, `noise`, `crypto`, `mesh`) +are wired into the `:wear` test source set the same way (srcDir + includes), so shared behavior is +continuously verified on both modules. + +**Resources**: font files cannot be selectively shared via srcDir cleanly — copy the 4 Geist Mono +font files (`app/src/main/res/font/geist_mono_*`) into `wear/src/main/res/font/`. Theme/palette/ +peer-color logic is re-created as wear-owned files mirroring `ui/theme/` values exactly. + +## 3. Watch UX design + +Wear Compose Material3 (round-screen safe by default): + +- **Screens**: Chat (global timeline) → People (connected peers w/ RSSI, unread badges) → + DM conversation. Edge-swipe back, `TimeText` scaffold, rotary crown scrolling. +- **Visual identity** (mirrors `ui/theme/` exactly): black background `#000000`, green primary + `#32D74B`, error `#FF453A`, orange accent for self/mentions, djb2-hash stable peer colors + (`PeerColors.kt` algorithm), Geist Mono typography, `BitchatMotion` timing tokens + (120/180/240 ms) for all animations. +- **Input**: text field using the Pixel Watch Gboard IME, plus voice dictation via + `RecognizerIntent`. Haptic feedback on incoming messages. +- **Background**: wear-owned foreground service (type `connectedDevice`) keeps scan/advertise + alive; shared `PowerManager` provides duty-cycling. + +## 4. Hardware & test environment + +- Pixel Watch connected via ADB (target device for all milestones; screencaps via + `adb exec-out screencap`). +- Two phones running the Android bitchat app, also on ADB, for interop testing (used heavily from + M6; manual interop checks from M2 onward). +- Design verification: at every UI milestone, take ADB screencaps of every screen and review them + for round-screen clipping, element visibility, contrast, and touch-target size. A milestone does + not pass until its screencap set is approved. + +## 5. Risks & notes + +- `BluetoothMeshService` (legacy monolith) vs `MeshCore` — prefer wiring the shared components + directly (MeshCore-style composition) in the wear service. +- `mesh/` references `ui/debug/DebugSettingsManager` — include that single file; do not pull in + the debug UI sheet. +- Wear BLE MTUs are small; the shared fragmentation layer (469-byte fragments) already handles + this. +- `EncryptedSharedPreferences` (androidx.security-crypto) works on Wear OS — identity persistence + is reused as-is. +- Watch has no camera/gallery: file transfer is **receive + display only** (confirmed decision). + +--- + +## Milestones + +### M0 — Scaffolding & plan document + +- [x] Write this plan to `docs/wear-os-implementation-plan.md` +- [x] Create `:wear` module: `wear/build.gradle.kts`, manifest + (``, standalone, BT permissions, + **no INTERNET/location**), `MainActivity` with hello-world screen using the ported theme +- [x] Add Wear Compose dependencies to `gradle/libs.versions.toml`; `include(":wear")` in + `settings.gradle.kts` +- [x] Build, install, and launch on the physical Pixel Watch via ADB; take first screencap + +**Success criteria**: `./gradlew :wear:assembleDebug` green; app launches on the watch; +`git diff --name-only` shows no changes under `app/src/`. +**Result**: PASSED — installed on Pixel Watch 3 (serial 4C201JEAYW0020), launch screencap shows +"bitchat" wordmark (green `#32D74B`, Geist Mono, black background) correctly centered on the +round display. No `app/src/` changes. + +--- + +### M1 — Shared core compiles on Wear + +- [x] Configure shared-source wiring in `wear/build.gradle.kts`; resolve transitive dependencies by + extending includes (never by copying Kotlin sources) +- [x] Copy Geist Mono fonts; create wear theme/palette/peer-color files mirroring `ui/theme/` +- [x] Wire shared unit tests (`protocol`, `noise`, `crypto`, `mesh`) into `:wear` test source set +- [x] `./gradlew :app:test :wear:test` green + +**Implementation notes** (deviation from original plan): AGP 9 source directory sets no longer +support include/exclude filters, so a Gradle `Sync` task (`syncSharedAppSources`) materializes a +filtered mirror of `app/src/main/java` into `wear/build/sharedSrc` which is added as a source +root. App sources remain the single source of truth; nothing is hand-copied. Excluded: +`BluetoothMeshService`/`UnifiedMeshService` (phone monolith / Wi-Fi Aware multiplexer — the watch +composes its own service in M2). Two tiny wear-owned shims satisfy the only unresolvable +references from shared code: `com.bitchat.android.service.MeshServiceHolder` (BLE-toggle +interface, null) and `com.bitchat.android.wifiaware.WifiAwareController` (no-op). + +**Success criteria**: the entire shared stack (protocol, noise, crypto, identity, mesh, model, +AppStateStore) compiles into `:wear`; both modules' unit tests pass; `app/src/` untouched. +**Result**: PASSED — `:wear` compiles the full shared stack; 172 shared unit tests pass on +`:wear` (0 failures), `:app` suite green; `app/src/` unchanged. + +--- + +### M2 — BLE transport & background service on watch + +- [ ] Wear onboarding flow: Bluetooth-enable check + runtime permission requests + (`BLUETOOTH_SCAN/CONNECT/ADVERTISE`), watch-styled screens +- [ ] `WearMeshService` foreground service (type `connectedDevice`); wire shared + `BluetoothConnectionManager` + mesh components; start scanning + advertising +- [ ] Internal debug screen: discovered peers with RSSI (temporary, replaced by real UI in M3/M4) +- [ ] Manual interop check: watch and one phone mutually discover + +**Success criteria**: the phone's bitchat app lists the watch as a connected peer and vice versa +(logcat + screencap evidence); mesh survives the screen turning off (ambient mode) for 5 minutes. +**Result**: PASSED — phone↔watch mutual discovery via `mesh_lab.py setup`; 5-minute screen-off +ambient test: `WearMeshForegroundService` kept the process alive, the GATT link stayed up +(`direct=true`, fresh RSSI/last_seen), and a broadcast sent after wake arrived instantly. +Two wear-specific fixes were needed: (1) the shared `BluetoothPermissionManager` requires location +permissions, which the watch deliberately doesn't declare — it is excluded from the sync and +replaced by a same-FQN wear variant that checks Bluetooth permissions only; +(2) `WearMeshService` mirrors the phone's `BluetoothMeshService.handleAnnounce` behavior of +learning the direct address↔peerID mapping via `DirectLinkAnnouncementPolicy.observationFor` + +`connectionManager.observePeerIfCurrent` (without this, `connect` after restarts fails). + +--- + +### M3 — Global chat + +- [ ] Nickname onboarding; identity announcement over the mesh +- [ ] Send/receive/relay public `BitchatMessage`s (relay/TTL comes free from shared mesh code) +- [ ] Chat timeline UI (`ScalingLazyColumn`, message bubbles per bitchat style, peer colors, + timestamps) + composer (IME + `RecognizerIntent` dictation) + incoming-message haptics +- [ ] Design check: ADB screencaps of onboarding, chat (empty/populated), composer; review for + round-screen clipping/visibility/contrast + +**Success criteria**: two-way public chat between watch and phone; messages the watch relays reach +a second phone that is only connected through the first (relay proof); screencap set approved. +**Result**: PASSED (relay proof noted below) — phone→watch and watch→phone public chat verified +end-to-end (watch UI: typed via the Pixel Watch Gboard into the composer, sent with Gboard's send +action, received on the phone; message id `67AB88FF…`, content `uitest-42ruitest`). Gossip sync +re-delivers history after reinstall/restart. Screencaps reviewed; fixes applied: composer pinned +outside the `ScalingLazyColumn` (edge items are shrunk and hard to tap on a round screen), +`singleLine = true` on the composer field (without it the IME ignores `imeAction=Send`), widened +bottom insets so the send button is not clipped by the circle chord. Relay: the watch runs the +shared `PacketRelayManager` and phone logs show watch packets being relayed end-to-end; a forced +watch-as-relay topology needs physical RF separation of the two phones — noted as a manual test. + +--- + +### M4 — Noise DMs & people screen + +- [x] People screen: connected peers, nicknames, RSSI, unread-DM badges +- [x] Tap peer → Noise XX handshake (shared `EncryptionService`/`NoiseSessionManager`) → DM thread +- [x] DM conversation UI; unread counters; delivery/read receipts if supported by shared code +- [x] Identity persistence (`EncryptedSharedPreferences`); stale-session detection & automatic + re-handshake after watch app restart +- [x] Design check: screencaps of people screen, handshake state, DM thread + +**Success criteria**: encrypted DM round trip with the phone; DMs survive a watch app restart +(session recovery); screencap set approved. +**Result**: PASSED — `mesh_lab.py scenario dm` phone↔watch green (Noise XX established both +ways, DM round trips with content assertions). People screen shows peers with djb2 peer colors, +RSSI, `noise ✓` session state, and unread badges; tapping a peer opens the DM thread and +auto-initiates the handshake. Session recovery after watch force-stop verified by +`session_recovery` scenario (identity preserved, auto re-handshake, DMs flow). + +--- + +### M5 — Files/images receive + voice notes (push-to-talk) + input redesign + +> Revised scope (was: receive-only, deferred). Now includes voice messages as a first-class +> input method and a native-Wear bottom-action redesign of the composer. + +**Files & images (receive + display)** + +- [x] Receive broadcast + Noise-encrypted private files (shared `BitchatFilePacket` TLV, + `FileUtils.saveIncomingFile`, `messageTypeForMime` — already wired via shared `MessageHandler`) +- [x] Image messages render as compact inline thumbnails (rounded, fit-width); tap → full-screen + viewer (black surface, fit-to-screen, dismiss) — mirrors the phone's `ImageMessageItem` / + `FullScreenImageViewer` +- [x] Non-media files: compact chip (name + size) +- [x] mesh_lab: add `file_recv` to the wear test hook; enable `file` + `file_private` scenarios + for the watch + +**Voice notes (first-class)** + +- [x] RECORD_AUDIO permission (manifest + just-in-time runtime request) +- [x] Push-to-talk recording: press-and-hold starts recording, release sends (10 s cap, 600 ms + minimum, ~80 ms amplitude polls); full-screen overlay that fades in with a live waveform + animation + elapsed time; shared `VoiceRecorder` (16 kHz mono AAC, `audio/mp4`, `.m4a`) +- [x] Send as `BitchatFilePacket` broadcast in global chat (`MeshCore.sendFileBroadcast`); in a + DM thread send Noise-encrypted (`WearMeshService.sendFilePrivateEncrypted` with + handshake/prep retry, mirroring the phone's `dispatchFileSend`) +- [x] Received voice notes (`BitchatMessageType.Audio`, `content` = local path) render as a + voice-note bubble: play/pause + waveform (shared `Waveform.kt` extractor, 120 bins) + + duration; `MediaPlayer` playback + +**Input redesign (native Wear bottom actions)** + +- [x] Replaced the inline composer with two always-visible bottom action buttons (the + framework's `ScreenScaffold.edgeButton` slot auto-hides on scroll, making push-to-talk + unreachable mid-conversation, so the bar is overlaid with the same native look instead): + - keyboard button → full-screen text input screen (field auto-focused, the watch IME opens + immediately with its built-in dictation; IME hides on send) + - mic button → push-to-talk (press-and-hold record, release send) with the full-screen + waveform overlay (rendered outside the edgeButton slot, which would clip it) +- [x] Message lists use `LazyColumn(reverseLayout = true)`: the newest message anchors at the + bottom above the buttons; empty space collects at the top. Works identically on round and + square screens (no ScalingLazyColumn center-anchor gap). +- [x] ScreenScaffold contentPadding keeps the last message reachable right above the buttons + +**Result**: PASSED — +- `mesh_lab.py scenario file` and `file_private` (phone→watch) green, SHA-256 digest match. +- Push-to-talk voice note (watch→phone) verified end-to-end: broadcast in global chat and + Noise-encrypted in DM, digest match on the phone side; phone→watch voice note renders as a + bubble and plays (MediaPlayer). +- Image (phone→watch) verified: compact inline render, tap → full-screen viewer, digest match. +- Keyboard path: auto-focus opens the IME, send hides it, message arrives on the phone. +- Full regression: `scenario all` (7 scenarios) green in ~75 s. +- Robustness: the watch auto-initiates a throttled Noise handshake with peers lacking an + established session — heals stale sessions after watch restarts (the protocol has no + decrypt-failure kick path; without this, private files/DMs from peers with stale sessions + were silently dropped). + +--- + +### M6 — ADB test hook & mesh_lab interop + +- [x] Wear debug-only `TestHookReceiver` (`wear/src/debug/`) mirroring the phone's command set: + `ping`, `start`, `stop`, `whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, + `session`, `announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `raw_send`, `state`, + `clear_results` — broadcast action `com.bitchat.watch.TEST_HOOK`, same JSON-result-file protocol + (`file_*` excluded while M5 is deferred) +- [x] Extend `tools/release_gate/mesh_lab.py`: `--serial-watch` argument and phone↔watch scenarios + (`dm`, `broadcast`, `raw`, `session_recovery`, `identity_reset`, `all`), reusing + the existing `Device`/`cmd` machinery +- [x] Run full scenario suite phone↔watch; store evidence JSON + +**Success criteria**: +`python3 tools/release_gate/mesh_lab.py scenario all --serial-a --serial-watch ` +exits 0 with evidence files; no manual intervention. +**Result**: PASSED — `scenario all` (dm, broadcast, raw, session_recovery, identity_reset) +green in 73 s, evidence in `/tmp/meshlab-evidence/all-evidence.json`. Host-side robustness fixes +in `mesh_lab.py`: `WatchDevice` (package/hook/permissions/activity for `com.bitchat.watch`), +`launch()` now verifies top-resumed activity (a frozen background process silently hangs test-hook +commands — observed on Wear), `wake()` sets `stay_on_while_plugged_in` (otherwise the charging +screen takes foreground and the app gets frozen), `ensure_direct_link` retries while announcing +(address↔peer mapping lags after restarts), and `all` tolerates sub-scenario failures. +Known environment note: the watch's ADB-over-USB link flaps occasionally (puck contact); retry +the command if `run_adb` raises `GateError`. + +--- + +### M7 — Polish & final design pass + +- [x] Animations/transitions per `BitchatMotion` tokens; message-appear animations; screen + transitions; auto-scroll to newest; splash screen (black, on-brand) & app icon +- [x] Power/battery: ambient test passed (see M2 result); shared `PowerManager` duty-cycling + active; composer/IME insets verified. Rotary crown scrolling is provided by + `ScalingLazyColumn` (wear-compose-foundation ≥1.3, framework-level; `input rotary` is not + supported by this Wear build's adb, so crown feel was not adb-verifiable — check manually) +- [x] Full screencap design review of every screen and state; fixes applied (composer pinning, + `singleLine` IME action, bottom-chord clipping, black splash) +- [x] Update this document: all milestones `done`; add a short "how to build/run/test" section + +**Success criteria**: all milestones marked `done`; interop suite green; final screencap set +approved; a fresh agent can build, install, and test the watch app from this document alone. +**Result**: PASSED. + +--- + +## How to build / run / test + +Prereqs: JDK (e.g. Android Studio JBR), `adb` on PATH or `ANDROID_HOME` set, Python 3.10+, +a Wear OS device (Pixel Watch) and a phone with USB debugging. **Both devices unlocked, screen +on** — on the watch, disable the lock screen (Settings → Security) or tests will stall on the +pattern lock; mesh_lab sets `stay_on_while_plugged_in` etc. automatically. + +```bash +# Build +./gradlew :wear:assembleDebug :app:assembleDebug + +# Unit tests (shared stack runs on both modules) +./gradlew :wear:testDebugUnitTest :app:testDebugUnitTest + +# Install & launch on the watch +adb -s install -r -g wear/build/outputs/apk/debug/wear-debug.apk +adb -s shell monkey -p com.bitchat.watch -c android.intent.category.LAUNCHER 1 + +# Screencap (design checks) +adb -s exec-out screencap -p > watch.png + +# Full interop suite (phone + watch) +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a --serial-watch \ + --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk \ + --watch-apk wear/build/outputs/apk/debug/wear-debug.apk +python3 tools/release_gate/mesh_lab.py scenario all \ + --serial-a --serial-watch --out /tmp/meshlab-evidence + +# Ad-hoc test-hook commands (watch) +adb -s shell am broadcast -a com.bitchat.watch.TEST_HOOK \ + -n com.bitchat.watch/.testhook.WearTestHookReceiver --es cmd state --es id s1 +adb -s shell run-as com.bitchat.watch cat cache/testhook/results/s1.json +``` + +Notes: +- If `mesh_lab` raises `GateError: ADB command failed`, the watch's USB link flapped — retry. +- Wear test-hook commands: `ping start stop whoami set_nickname scan peers connect handshake + session announce broadcast_msg dm_send dm_recv msg_recv raw_send file_recv state + clear_results`. diff --git a/docs/wear/screenshots/chat.png b/docs/wear/screenshots/chat.png new file mode 100644 index 00000000..1bb541df Binary files /dev/null and b/docs/wear/screenshots/chat.png differ diff --git a/docs/wear/screenshots/onboarding-nickname.png b/docs/wear/screenshots/onboarding-nickname.png new file mode 100644 index 00000000..7526337d Binary files /dev/null and b/docs/wear/screenshots/onboarding-nickname.png differ diff --git a/docs/wear/screenshots/people.png b/docs/wear/screenshots/people.png new file mode 100644 index 00000000..34513fc2 Binary files /dev/null and b/docs/wear/screenshots/people.png differ diff --git a/docs/wear/screenshots/voice-notes.png b/docs/wear/screenshots/voice-notes.png new file mode 100644 index 00000000..baabd834 Binary files /dev/null and b/docs/wear/screenshots/voice-notes.png differ diff --git a/docs/wear/screenshots/voice-recording.png b/docs/wear/screenshots/voice-recording.png new file mode 100644 index 00000000..75c455c9 Binary files /dev/null and b/docs/wear/screenshots/voice-recording.png differ diff --git a/gradle.properties b/gradle.properties index 0838af96..e3461bd0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,9 +9,6 @@ # Specifies the JVM arguments used for the daemon process. android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true - # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects @@ -25,5 +22,13 @@ android.nonTransitiveRClass=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +# Public SHA-256 fingerprint of the certificate used by the existing GitHub +# universal APK releases. This is not a secret; it lets the app reject an APK +# signed by an unexpected publisher. +BITCHAT_GITHUB_RELEASE_CERT_SHA256=3b03fa66a5451321100792f5b55a7b4966d5c8dc10c6daa40aa95ea489531bca + # JVM heap size configuration to prevent OutOfMemoryError -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError \ No newline at end of file +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e3a5fba..405f6dc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,61 +1,84 @@ [versions] # Android and Kotlin -agp = "8.10.1" -kotlin = "2.2.0" -compileSdk = "35" +agp = "9.3.1" +kotlin = "2.4.10" +compileSdk = "37" minSdk = "26" # API 26 for proper BLE support -targetSdk = "34" +targetSdk = "37" +buildTools = "37.0.0" # AndroidX Core -core-ktx = "1.16.0" -lifecycle-runtime = "2.9.1" -activity-compose = "1.10.1" +core-ktx = "1.19.0" +lifecycle-runtime = "2.11.0" +activity-compose = "1.13.0" appcompat = "1.7.1" # Compose -compose-bom = "2025.06.01" +compose-bom = "2026.06.01" +compose-icons-extended = "1.7.8" # Navigation -navigation-compose = "2.9.1" +navigation-compose = "2.9.8" # Accompanist accompanist-permissions = "0.37.3" +# Wear OS +wear-compose = "1.6.2" +wear-tooling-preview = "1.0.0" + # Cryptography -bouncycastle = "1.70" -tink-android = "1.10.0" +bouncycastle = "1.85" +tink-android = "1.23.0" # JSON -gson = "2.13.1" +gson = "2.14.0" # Coroutines -kotlinx-coroutines = "1.10.2" +kotlinx-coroutines = "1.11.0" # Bluetooth -nordic-ble = "2.6.1" +nordic-ble = "2.11.0" # WebSocket -okhttp = "4.12.0" +okhttp = "5.4.0" tor-android-binary = "0.4.4.6" # Google Play Services -gms-location = "21.3.0" +gms-location = "21.4.0" + +# WorkManager +work-runtime = "2.10.1" + +# NanoHTTPD (hotspot APK sharing) +nanohttpd = "2.3.1" # Security -security-crypto = "1.1.0-beta01" +security-crypto = "1.1.0" + +# QR +zxing-core = "3.5.4" + +# EXIF +exifinterface = "1.4.2" + +# CameraX / ML Kit +camerax = "1.6.1" +mlkit-barcode = "17.3.0" # Testing junit = "4.13.2" -androidx-test-ext = "1.2.1" -espresso = "3.6.1" -mockito-kotlin = "4.1.0" -mockito-inline = "4.1.0" -roboelectric = "4.15" +androidx-test-ext = "1.3.0" +espresso = "3.7.0" +mockito-kotlin = "6.3.0" +mockito-core = "5.23.0" +roboelectric = "4.15" # 4.16+ drops the AndroidKeyStore shim; breaks EncryptionServiceTest [libraries] # AndroidX Core androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -67,12 +90,10 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } -androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } -androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" } # Lifecycle androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" } -androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle-runtime" } # Navigation androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } @@ -80,8 +101,13 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose # Accompanist accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" } +# Wear OS +androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" } +androidx-wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wear-compose" } +androidx-wear-tooling-preview = { module = "androidx.wear:wear-tooling-preview", version.ref = "wear-tooling-preview" } + # Cryptography -bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" } # JSON @@ -95,6 +121,7 @@ nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" # WebSocket okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttp" } tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref = "tor-android-binary" } # Tor (embed) intentionally not pinned yet; add once repo is chosen @@ -102,9 +129,27 @@ tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref # Google Play Services gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" } +# WorkManager +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime" } + +# NanoHTTPD +nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } + # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } +# QR +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" } + +# EXIF +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } + +# CameraX / ML Kit +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } +mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode" } + # Testing junit = { module = "junit:junit", version.ref = "junit" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } @@ -112,13 +157,13 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } -mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" } roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric"} +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-parcelize = { id = "kotlin-parcelize" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } @@ -128,14 +173,12 @@ compose = [ "androidx-compose-ui-graphics", "androidx-compose-ui-tooling-preview", "androidx-compose-material3", - "androidx-compose-runtime-livedata", "androidx-compose-material-icons-extended" ] lifecycle = [ "androidx-lifecycle-runtime-ktx", "androidx-lifecycle-viewmodel-compose", - "androidx-lifecycle-livedata-ktx" ] cryptography = [ @@ -148,8 +191,10 @@ testing = [ "androidx-test-ext-junit", "androidx-test-espresso-core", "mockito-kotlin", - "mockito-inline", - "roboelectric" + "mockito-core", + "roboelectric", + "kotlinx-coroutines-test", + "okhttp-mockwebserver" ] compose-testing = [ diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml new file mode 100644 index 00000000..4dad1c4d --- /dev/null +++ b/gradle/verification-metadata.xml @@ -0,0 +1,5208 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b1..e3ec95fa 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings-gradle.lockfile b/settings-gradle.lockfile new file mode 100644 index 00000000..1aa2035e --- /dev/null +++ b/settings-gradle.lockfile @@ -0,0 +1,5 @@ +# 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 dependencies --write-locks +empty=incomingCatalogForLibs0 diff --git a/settings.gradle.kts b/settings.gradle.kts index 53c50616..ffd88519 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,11 +10,10 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - // Guardian Project raw GitHub Maven (hosts info.guardianproject:arti-mobile-ex) - maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") } } } rootProject.name = "bitchat-android" include(":app") +include(":wear") // Using published Arti AAR; local module not included diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000..b48e1209 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Repository-local verification tooling.""" diff --git a/tools/arti-build/Cargo.lock b/tools/arti-build/Cargo.lock new file mode 100644 index 00000000..7438c8f7 --- /dev/null +++ b/tools/arti-build/Cargo.lock @@ -0,0 +1,5429 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "amplify" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" +dependencies = [ + "amplify_derive", + "amplify_num", + "ascii", + "getrandom 0.2.17", + "getrandom 0.3.4", + "wasm-bindgen", +] + +[[package]] +name = "amplify_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" +dependencies = [ + "amplify_syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "amplify_num" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afed304556696656d2d71495e1e5f2c4b524a3fb6eb0f2f3778ffc482a40b8a8" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "amplify_syn" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "arti-android-wrapper" +version = "1.9.0" +dependencies = [ + "anyhow", + "arti-client", + "jni", + "tokio", + "tor-rtcompat", +] + +[[package]] +name = "arti-client" +version = "0.38.0" +dependencies = [ + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "educe", + "fs-mistrust", + "futures", + "hostname-validator", + "humantime", + "humantime-serde", + "libc", + "once_cell", + "postage", + "rand 0.9.5", + "safelog", + "serde", + "thiserror 2.0.19", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-chanmgr", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dircommon", + "tor-dirmgr", + "tor-error", + "tor-guardmgr", + "tor-hsclient", + "tor-hscrypto", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "async_executors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" +dependencies = [ + "blanket", + "futures-core", + "futures-task", + "futures-util", + "pin-project", + "rustc_version", + "tokio", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blanket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "caret" +version = "0.8.1" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "liblzma", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-cycles-per-byte" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5396de42a52e9e5d8f67ef0702dae30451f310a9ba1c3094dcf228f0be0e54bc" +dependencies = [ + "cfg-if", + "criterion", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "cookie-factory", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive-deftly" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284db66a66f03c3dafbe17360d959eb76b83f77cfe191677e2a7899c0da291f3" +dependencies = [ + "derive-deftly-macros", + "heck", +] + +[[package]] +name = "derive-deftly-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caef6056a5788d05d173cdc3c562ac28ae093828f851f69378b74e4e3d578e41" +dependencies = [ + "heck", + "indexmap 2.14.0", + "itertools 0.15.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3", + "strum", + "syn 2.0.119", + "void", +] + +[[package]] +name = "derive_builder_core_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" +dependencies = [ + "derive_builder_macro_fork_arti", +] + +[[package]] +name = "derive_builder_macro_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" +dependencies = [ + "derive_builder_core_fork_arti", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "merlin", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "3.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluid-let" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "fs-mistrust" +version = "0.13.1" +dependencies = [ + "derive_builder_fork_arti", + "dirs", + "libc", + "pwd-grp", + "serde", + "thiserror 2.0.19", + "walkdir", +] + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "liblzma" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45aec2360b3933207e27908049d8e4df4e476b58180afb1e56b2a4fb72efe4ba" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a046c7f353ba30f810545151e04f63545833803f5b86ee3ddf1517247fe560a5" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneshot-fused-workaround" +version = "0.5.1" +dependencies = [ + "futures", +] + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic 0.5.3", + "crossbeam-queue", + "futures", + "parking_lot", + "pin-project", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap 2.14.0", + "serde", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pwd-grp" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" +dependencies = [ + "derive-deftly", + "libc", + "paste", + "thiserror 2.0.19", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_jitter" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +dependencies = [ + "libc", + "rand_core 0.9.5", + "winapi", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rdrand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "retry-error" +version = "0.10.0" +dependencies = [ + "humantime", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "time", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "safelog" +version = "0.7.1" +dependencies = [ + "derive_more", + "educe", + "either", + "fluid-let", + "thiserror 2.0.19", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "saturating-time" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "slotmap-careful" +version = "0.5.1" +dependencies = [ + "paste", + "serde", + "slotmap", + "thiserror 2.0.19", + "void", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tor-async-utils" +version = "0.38.0" +dependencies = [ + "derive-deftly", + "educe", + "futures", + "oneshot-fused-workaround", + "pin-project", + "postage", + "thiserror 2.0.19", + "void", +] + +[[package]] +name = "tor-basic-utils" +version = "0.38.0" +dependencies = [ + "derive_more", + "hex", + "itertools 0.14.0", + "libc", + "paste", + "rand 0.9.5", + "rand_chacha 0.9.0", + "serde", + "slab", + "smallvec", + "thiserror 2.0.19", + "weak-table", +] + +[[package]] +name = "tor-bytes" +version = "0.38.0" +dependencies = [ + "bytes", + "derive-deftly", + "digest", + "educe", + "getrandom 0.3.4", + "safelog", + "thiserror 2.0.19", + "tor-error", + "tor-llcrypto", + "zeroize", +] + +[[package]] +name = "tor-cell" +version = "0.38.0" +dependencies = [ + "amplify", + "bitflags", + "bytes", + "caret", + "derive-deftly", + "derive_more", + "educe", + "itertools 0.14.0", + "paste", + "rand 0.9.5", + "smallvec", + "thiserror 2.0.19", + "tor-basic-utils", + "tor-bytes", + "tor-cert", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-protover", + "tor-units", + "void", +] + +[[package]] +name = "tor-cert" +version = "0.38.0" +dependencies = [ + "caret", + "derive_builder_fork_arti", + "derive_more", + "digest", + "thiserror 2.0.19", + "tor-bytes", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-chanmgr" +version = "0.38.0" +dependencies = [ + "async-trait", + "caret", + "derive_builder_fork_arti", + "derive_more", + "educe", + "futures", + "oneshot-fused-workaround", + "postage", + "rand 0.9.5", + "safelog", + "serde", + "thiserror 2.0.19", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-config", + "tor-error", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tor-units", + "tracing", + "void", +] + +[[package]] +name = "tor-checkable" +version = "0.38.0" +dependencies = [ + "humantime", + "signature", + "thiserror 2.0.19", + "tor-llcrypto", +] + +[[package]] +name = "tor-circmgr" +version = "0.38.0" +dependencies = [ + "amplify", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "educe", + "futures", + "humantime-serde", + "itertools 0.14.0", + "once_cell", + "oneshot-fused-workaround", + "pin-project", + "rand 0.9.5", + "retry-error", + "safelog", + "serde", + "thiserror 2.0.19", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-chanmgr", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-linkspec", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "void", + "weak-table", +] + +[[package]] +name = "tor-config" +version = "0.38.0" +dependencies = [ + "amplify", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "educe", + "either", + "figment", + "fs-mistrust", + "futures", + "humantime-serde", + "itertools 0.14.0", + "notify", + "paste", + "postage", + "regex", + "serde", + "serde-value", + "serde_ignored", + "strum", + "thiserror 2.0.19", + "toml 0.9.12+spec-1.1.0", + "tor-basic-utils", + "tor-error", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-config-path" +version = "0.38.0" +dependencies = [ + "directories", + "serde", + "shellexpand", + "thiserror 2.0.19", + "tor-error", + "tor-general-addr", +] + +[[package]] +name = "tor-consdiff" +version = "0.38.0" +dependencies = [ + "digest", + "hex", + "thiserror 2.0.19", + "tor-llcrypto", +] + +[[package]] +name = "tor-dirclient" +version = "0.38.0" +dependencies = [ + "async-compression", + "base64ct", + "derive_more", + "futures", + "hex", + "http", + "httparse", + "httpdate", + "itertools 0.14.0", + "memchr", + "thiserror 2.0.19", + "tor-circmgr", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-proto", + "tor-rtcompat", + "tracing", +] + +[[package]] +name = "tor-dircommon" +version = "0.38.0" +dependencies = [ + "base64ct", + "derive_builder_fork_arti", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", +] + +[[package]] +name = "tor-dirmgr" +version = "0.38.0" +dependencies = [ + "async-trait", + "base64ct", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "event-listener", + "fs-mistrust", + "fslock", + "futures", + "hex", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "memmap2", + "oneshot-fused-workaround", + "paste", + "postage", + "rand 0.9.5", + "rusqlite", + "safelog", + "scopeguard", + "serde", + "serde_json", + "signature", + "static_assertions", + "strum", + "thiserror 2.0.19", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-consdiff", + "tor-dirclient", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", +] + +[[package]] +name = "tor-error" +version = "0.38.0" +dependencies = [ + "derive_more", + "futures", + "paste", + "retry-error", + "static_assertions", + "strum", + "thiserror 2.0.19", + "tracing", + "void", +] + +[[package]] +name = "tor-general-addr" +version = "0.38.0" +dependencies = [ + "derive_more", + "thiserror 2.0.19", + "void", +] + +[[package]] +name = "tor-guardmgr" +version = "0.38.0" +dependencies = [ + "amplify", + "base64ct", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "num_enum", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.5", + "safelog", + "serde", + "strum", + "thiserror 2.0.19", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", +] + +[[package]] +name = "tor-hsclient" +version = "0.38.0" +dependencies = [ + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "either", + "futures", + "itertools 0.14.0", + "oneshot-fused-workaround", + "postage", + "rand 0.9.5", + "retry-error", + "safelog", + "slotmap-careful", + "strum", + "thiserror 2.0.19", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-dirclient", + "tor-error", + "tor-hscrypto", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", +] + +[[package]] +name = "tor-hscrypto" +version = "0.38.0" +dependencies = [ + "data-encoding", + "derive-deftly", + "derive_more", + "digest", + "hex", + "humantime", + "itertools 0.14.0", + "paste", + "rand 0.9.5", + "safelog", + "serde", + "signature", + "subtle", + "thiserror 2.0.19", + "tor-basic-utils", + "tor-bytes", + "tor-error", + "tor-key-forge", + "tor-llcrypto", + "tor-memquota", + "tor-units", + "void", +] + +[[package]] +name = "tor-key-forge" +version = "0.38.0" +dependencies = [ + "derive-deftly", + "derive_more", + "downcast-rs", + "paste", + "rand 0.9.5", + "rsa", + "signature", + "ssh-key", + "thiserror 2.0.19", + "tor-bytes", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-keymgr" +version = "0.38.0" +dependencies = [ + "amplify", + "arrayvec", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "fs-mistrust", + "glob-match", + "humantime", + "inventory", + "itertools 0.14.0", + "rand 0.9.5", + "safelog", + "serde", + "signature", + "ssh-key", + "thiserror 2.0.19", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-config-path", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-llcrypto", + "tor-persist", + "tracing", + "visibility", + "walkdir", + "zeroize", +] + +[[package]] +name = "tor-linkspec" +version = "0.38.0" +dependencies = [ + "base64ct", + "by_address", + "caret", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "hex", + "itertools 0.14.0", + "safelog", + "serde", + "serde_with", + "strum", + "thiserror 2.0.19", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-llcrypto", + "tor-memquota", + "tor-protover", +] + +[[package]] +name = "tor-llcrypto" +version = "0.38.0" +dependencies = [ + "aes", + "base64ct", + "ctr", + "curve25519-dalek", + "der-parser", + "derive-deftly", + "derive_more", + "digest", + "ed25519-dalek", + "educe", + "getrandom 0.3.4", + "hex", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_core 0.6.4", + "rand_core 0.9.5", + "rand_jitter", + "rdrand", + "rsa", + "safelog", + "serde", + "sha1", + "sha2", + "sha3", + "signature", + "subtle", + "thiserror 2.0.19", + "tor-error", + "tor-memquota", + "visibility", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "tor-log-ratelim" +version = "0.38.0" +dependencies = [ + "futures", + "humantime", + "thiserror 2.0.19", + "tor-error", + "tor-rtcompat", + "tracing", + "weak-table", +] + +[[package]] +name = "tor-memquota" +version = "0.38.0" +dependencies = [ + "cfg-if", + "derive-deftly", + "derive_more", + "dyn-clone", + "educe", + "futures", + "itertools 0.14.0", + "paste", + "pin-project", + "serde", + "slotmap-careful", + "static_assertions", + "sysinfo", + "thiserror 2.0.19", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-error", + "tor-log-ratelim", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-netdir" +version = "0.38.0" +dependencies = [ + "async-trait", + "bitflags", + "derive_more", + "digest", + "futures", + "hex", + "humantime", + "itertools 0.14.0", + "num_enum", + "rand 0.9.5", + "serde", + "strum", + "thiserror 2.0.19", + "time", + "tor-basic-utils", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-protover", + "tor-units", + "tracing", + "typed-index-collections", +] + +[[package]] +name = "tor-netdoc" +version = "0.38.0" +dependencies = [ + "amplify", + "base64ct", + "cipher", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enumset", + "hex", + "humantime", + "itertools 0.14.0", + "memchr", + "paste", + "phf", + "rand 0.9.5", + "saturating-time", + "serde", + "serde_with", + "signature", + "smallvec", + "strum", + "subtle", + "thiserror 2.0.19", + "time", + "tinystr", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-protover", + "tor-units", + "void", + "zeroize", +] + +[[package]] +name = "tor-persist" +version = "0.38.0" +dependencies = [ + "derive-deftly", + "derive_more", + "filetime", + "fs-mistrust", + "fslock", + "futures", + "itertools 0.14.0", + "oneshot-fused-workaround", + "paste", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.19", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-error", + "tracing", + "void", +] + +[[package]] +name = "tor-proto" +version = "0.38.0" +dependencies = [ + "amplify", + "asynchronous-codec", + "bitvec", + "bytes", + "caret", + "cfg-if", + "cipher", + "coarsetime", + "criterion-cycles-per-byte", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enum_dispatch", + "futures", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "nonany", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.5", + "rand_core 0.9.5", + "safelog", + "slotmap-careful", + "smallvec", + "static_assertions", + "subtle", + "sync_wrapper", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-config", + "tor-error", + "tor-hscrypto", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-protover", + "tor-relay-crypto", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "typenum", + "visibility", + "void", + "zeroize", +] + +[[package]] +name = "tor-protover" +version = "0.38.0" +dependencies = [ + "caret", + "paste", + "serde_with", + "thiserror 2.0.19", + "tor-basic-utils", + "tor-bytes", +] + +[[package]] +name = "tor-relay-crypto" +version = "0.38.0" +dependencies = [ + "derive-deftly", + "derive_more", + "humantime", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-key-forge", + "tor-keymgr", + "tor-llcrypto", + "tor-persist", +] + +[[package]] +name = "tor-relay-selection" +version = "0.38.0" +dependencies = [ + "rand 0.9.5", + "serde", + "tor-basic-utils", + "tor-linkspec", + "tor-netdir", + "tor-netdoc", +] + +[[package]] +name = "tor-rtcompat" +version = "0.38.0" +dependencies = [ + "async-trait", + "async_executors", + "asynchronous-codec", + "coarsetime", + "derive_more", + "dyn-clone", + "educe", + "futures", + "futures-rustls", + "hex", + "libc", + "native-tls", + "paste", + "pin-project", + "rustls-pki-types", + "rustls-webpki", + "socket2", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "tor-error", + "tor-general-addr", + "tracing", + "void", +] + +[[package]] +name = "tor-rtmock" +version = "0.38.0" +dependencies = [ + "amplify", + "assert_matches", + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "futures", + "humantime", + "itertools 0.14.0", + "oneshot-fused-workaround", + "pin-project", + "priority-queue", + "slotmap-careful", + "strum", + "thiserror 2.0.19", + "tor-error", + "tor-general-addr", + "tor-rtcompat", + "tracing", + "tracing-test", + "void", +] + +[[package]] +name = "tor-socksproto" +version = "0.38.0" +dependencies = [ + "amplify", + "caret", + "derive-deftly", + "educe", + "safelog", + "subtle", + "thiserror 2.0.19", + "tor-bytes", + "tor-error", +] + +[[package]] +name = "tor-units" +version = "0.38.0" +dependencies = [ + "derive-deftly", + "derive_more", + "serde", + "thiserror 2.0.19", + "tor-memquota", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasix" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" +dependencies = [ + "wasi", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weak-table" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "zerofrom", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml new file mode 100644 index 00000000..d5858590 --- /dev/null +++ b/tools/arti-build/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "arti-android-wrapper" +version = "1.9.0" +edition = "2021" + +[workspace] +# Empty workspace table to exclude from parent workspace + +[lib] +crate-type = ["cdylib"] +name = "arti_android" + +[dependencies] +arti-client = { path = "../crates/arti-client", default-features = false, features = ["tokio", "rustls", "compression", "bridge-client", "onion-service-client", "static", "static-sqlite"] } +tor-rtcompat = { path = "../crates/tor-rtcompat", features = ["tokio", "rustls", "static"] } +jni = "0.21" +tokio = { version = "1", features = ["full"] } +anyhow = "1.0" + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +strip = true # Strip symbols +panic = "abort" # Smaller panic handler diff --git a/tools/arti-build/Dockerfile b/tools/arti-build/Dockerfile new file mode 100644 index 00000000..15c0ea7f --- /dev/null +++ b/tools/arti-build/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 +FROM rust:1.95.0-slim-bookworm@sha256:d7482085ff5b415f84dba5647ae71606650bdef00db7aeb69f4b3d170c3e4082 + +SHELL ["/bin/bash", "-euo", "pipefail", "-c"] + +COPY tools/arti-build/TOOLCHAIN.env /tmp/TOOLCHAIN.env + +RUN source /tmp/TOOLCHAIN.env \ + && printf '%s\n' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: bookworm bookworm-updates' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + 'Check-Valid-Until: no' \ + '' \ + 'Types: deb' \ + "URIs: https://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: bookworm-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + 'Check-Valid-Until: no' \ + > /etc/apt/sources.list.d/debian.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + binutils \ + ca-certificates \ + curl \ + git \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN source /tmp/TOOLCHAIN.env \ + && curl -fsSL \ + "https://dl.google.com/android/repository/${ANDROID_NDK_ARCHIVE}" \ + -o "/tmp/${ANDROID_NDK_ARCHIVE}" \ + && echo "${ANDROID_NDK_SHA256} /tmp/${ANDROID_NDK_ARCHIVE}" | sha256sum -c - \ + && unzip -q "/tmp/${ANDROID_NDK_ARCHIVE}" -d /opt \ + && rm "/tmp/${ANDROID_NDK_ARCHIVE}" \ + && test "$(sed -n 's/^Pkg.Revision *= *//p' /opt/android-ndk-r27d/source.properties | tr -d '\r')" = "$ANDROID_NDK_VERSION" + +RUN source /tmp/TOOLCHAIN.env \ + && test "$(rustc --version | awk '{print $2}')" = "$RUST_VERSION" \ + && cargo install cargo-ndk --version "$CARGO_NDK_VERSION" --locked \ + && rustup target add \ + aarch64-linux-android \ + armv7-linux-androideabi \ + i686-linux-android \ + x86_64-linux-android + +ENV ANDROID_NDK_HOME=/opt/android-ndk-r27d +ENV PATH=/usr/local/cargo/bin:${PATH} +ENV TZ=UTC +ENV LC_ALL=C.UTF-8 + +WORKDIR /workspace + +ENTRYPOINT ["/bin/bash", "tools/arti-build/build-arti.sh", "--clean"] diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md new file mode 100644 index 00000000..2307a53a --- /dev/null +++ b/tools/arti-build/README.md @@ -0,0 +1,70 @@ +# Rebuilding Arti for Android + +The four committed `libarti_android.so` files are built from the official Arti +source plus the JNI wrapper in this directory. The supported build path is the +pinned Linux container: + +```bash +tools/arti-build/rebuild-in-container.sh +tools/arti-build/verify-checksums.sh +``` + +The container builds all four Android ABIs, verifies the exported JNI symbols +and rejects native binaries containing host-specific paths. It then rewrites +`tools/arti-build/SHA256SUMS`; both that manifest and the rebuilt libraries are +release inputs. + +## Pinned inputs + +- `TOOLCHAIN.env`: Arti tag and commit, stable build epoch, Rust, `cargo-ndk`, + Android NDK, and immutable Debian snapshot +- `rust-toolchain.toml`: Rust release and Android targets +- `Cargo.lock`: complete Rust dependency graph +- `Dockerfile`: digest-pinned Rust base image and checksum-verified NDK archive +- `Cargo.toml` and `src/lib.rs`: JNI wrapper package and source + +Rust source paths are remapped to stable virtual paths, incremental compilation +is disabled, and `SOURCE_DATE_EPOCH` is pinned to the value used for the +committed libraries. + +## Updating Arti or the native toolchain + +1. Update the tag, full source commit, stable build epoch, tool versions, + archive name, and archive checksum in `TOOLCHAIN.env`. +2. Update `Cargo.toml` if Arti changed its crate features or wrapper version. +3. Regenerate `Cargo.lock` against the exact Arti checkout and review every + dependency change: + + ```bash + tools/arti-build/rebuild-in-container.sh --update-lockfile + ``` + +4. Update the digest-pinned base image and `rust-toolchain.toml` when changing + Rust. +5. Run `tools/arti-build/rebuild-in-container.sh` twice from clean generated + state and confirm `tools/arti-build/SHA256SUMS` is unchanged. +6. Run the Android tests and the two-build release comparison described in + [`docs/reproducible-builds.md`](../../docs/reproducible-builds.md). + +Do not build release native libraries with an unpinned local NDK or Rust +toolchain. `build-arti.sh` deliberately rejects mismatched tool versions. + +## JNI surface + +The wrapper exports the native methods used by `ArtiNative`: + +- `getVersion` +- `setLogCallback` +- `initialize` +- `startSocksProxy` +- `stop` + +The build fails if any expected symbol is absent. The NDK `readelf` check also +reports ELF load-segment alignment for Android's 16 KiB page-size requirement. + +## References + +- [Arti source](https://gitlab.torproject.org/tpo/core/arti) +- [Cargo lockfiles](https://doc.rust-lang.org/cargo/faq.html#why-have-cargolock-in-version-control) +- [Rust source-path remapping](https://doc.rust-lang.org/rustc/remap-source-paths.html) +- [Install a specific Android NDK](https://developer.android.com/studio/projects/install-ndk) diff --git a/tools/arti-build/SHA256SUMS b/tools/arti-build/SHA256SUMS new file mode 100644 index 00000000..4972e4e3 --- /dev/null +++ b/tools/arti-build/SHA256SUMS @@ -0,0 +1,4 @@ +bb92268898b51a7b2979e74aa5263bdffb6450a1703c2fa6f2779e714e154d61 app/src/main/jniLibs/arm64-v8a/libarti_android.so +2a037d0cef4cef2227dba4bd8ad6c10ccf29064c5718298b21dae0de0a7c11ad app/src/main/jniLibs/armeabi-v7a/libarti_android.so +e70eece0ccf425032c11c3177801fe10b964c8be0e14c926da6b613a2d06aead app/src/main/jniLibs/x86/libarti_android.so +ae6f389c4fe9d969c65e64a1d55169fc941e7d95737aa3ba0b8d42f5d54d6c56 app/src/main/jniLibs/x86_64/libarti_android.so diff --git a/tools/arti-build/TOOLCHAIN.env b/tools/arti-build/TOOLCHAIN.env new file mode 100644 index 00000000..2ca10ad4 --- /dev/null +++ b/tools/arti-build/TOOLCHAIN.env @@ -0,0 +1,9 @@ +ARTI_VERSION=arti-v1.9.0 +ARTI_COMMIT=ba421ece1acb47e5c554c2bfa043cc9156451bab +NATIVE_SOURCE_DATE_EPOCH=1785193050 +DEBIAN_SNAPSHOT=20260720T000000Z +RUST_VERSION=1.95.0 +CARGO_NDK_VERSION=4.1.2 +ANDROID_NDK_VERSION=27.3.13750724 +ANDROID_NDK_ARCHIVE=android-ndk-r27d-linux.zip +ANDROID_NDK_SHA256=601246087a682d1944e1e16dd85bc6e49560fe8b6d61255be2829178c8ed15d9 diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh new file mode 100755 index 00000000..5d20541a --- /dev/null +++ b/tools/arti-build/build-arti.sh @@ -0,0 +1,638 @@ +#!/usr/bin/env bash +# +# Rebuild Arti native libraries from official source +# +# This script clones the official Arti repository, applies our custom JNI wrapper, +# and builds the native libraries for Android. Use this to: +# - Verify the pre-built .so files match the source +# - Update to a new Arti version +# - Debug or modify the wrapper code +# +# Requirements are pinned in TOOLCHAIN.env and rust-toolchain.toml. The +# supported entry point is rebuild-in-container.sh, which supplies all of them. +# +# Usage: +# ./build-arti.sh # Build both architectures (debug/emulator) +# ./build-arti.sh --release # Build ARM targets only +# ./build-arti.sh --clean # Remove cloned Arti repo and rebuild +# ./build-arti.sh --update-lockfile # Refresh Cargo.lock without building + +set -euo pipefail + +# ============================================================================== +# Configuration +# ============================================================================== + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script and project directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source" +JNILIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs" +TOOLCHAIN_FILE="$SCRIPT_DIR/TOOLCHAIN.env" +LOCKFILE_SOURCE="$SCRIPT_DIR/Cargo.lock" + +if [ ! -f "$TOOLCHAIN_FILE" ]; then + echo -e "${RED}Error: TOOLCHAIN.env file not found${NC}" + exit 1 +fi +# shellcheck disable=SC1090 +source "$TOOLCHAIN_FILE" + +VERSION="$ARTI_VERSION" +export RUSTUP_TOOLCHAIN="$RUST_VERSION" + +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + echo -e "${RED}Error: ANDROID_NDK_HOME must point to NDK $ANDROID_NDK_VERSION.${NC}" + exit 1 +fi +export ANDROID_NDK_HOME + +# Min SDK version (must match bitchat-android minSdk) +MIN_SDK_VERSION=26 + +# Keep host paths and timestamps out of Rust objects and panic-location strings. +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$NATIVE_SOURCE_DATE_EPOCH}" +if ! [[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]]; then + echo -e "${RED}Error: SOURCE_DATE_EPOCH must be an integer.${NC}" + exit 1 +fi +CARGO_HOME_PATH="${CARGO_HOME:-$HOME/.cargo}" +REPRO_RUSTFLAGS=( + "--remap-path-prefix=$PROJECT_ROOT=/usr/src/bitchat-android" + "--remap-path-prefix=$ARTI_SOURCE_DIR=/usr/src/arti" + "--remap-path-prefix=$CARGO_HOME_PATH=/usr/local/cargo" +) +for rustflag in "${REPRO_RUSTFLAGS[@]}"; do + RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$rustflag" +done +export CARGO_INCREMENTAL=0 +export RUSTFLAGS +export SOURCE_DATE_EPOCH + +# Parse arguments +RELEASE_ONLY=false +CLEAN_BUILD=false +UPDATE_LOCKFILE=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + RELEASE_ONLY=true + shift + ;; + --clean) + CLEAN_BUILD=true + shift + ;; + --update-lockfile) + UPDATE_LOCKFILE=true + shift + ;; + --help|-h) + echo "Usage: $0 [--release] [--clean]" + echo "" + echo "Options:" + echo " --release Build ARM targets only" + echo " --clean Remove cached Arti source and rebuild from scratch" + echo " --update-lockfile Regenerate the wrapper Cargo.lock and exit" + echo "" + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown argument: $1${NC}" + echo "Run: $0 --help" + exit 1 + ;; + esac +done + +# Architectures to build +if [ "$RELEASE_ONLY" = true ]; then + TARGETS=("aarch64-linux-android" "armv7-linux-androideabi") +else + TARGETS=("aarch64-linux-android" "x86_64-linux-android" "armv7-linux-androideabi" "i686-linux-android") +fi + +# Map Rust targets to Android ABI names +declare -A ABI_MAP=( + ["aarch64-linux-android"]="arm64-v8a" + ["x86_64-linux-android"]="x86_64" + ["armv7-linux-androideabi"]="armeabi-v7a" + ["i686-linux-android"]="x86" +) + +# Toolchain placeholders (set in detect_ndk_host) +NDK_HOST="" +NDK_LLVM_BIN="" +LLVM_STRIP="" +LLVM_NM="" +LLVM_READELF="" + +# ============================================================================== +# Functions +# ============================================================================== + +print_header() { + echo -e "${BLUE}=========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}=========================================${NC}" +} + +print_success() { echo -e "${GREEN}$1${NC}"; } +print_error() { echo -e "${RED}$1${NC}"; } +print_info() { echo -e "${YELLOW}$1${NC}"; } + +detect_ndk_host() { + local uname_s + uname_s="$(uname -s)" + local PREBUILT_DIR="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" + local HOST_CANDIDATES=() + + case "$uname_s" in + Darwin) + HOST_CANDIDATES=("darwin-arm64" "darwin-x86_64") + ;; + Linux) + HOST_CANDIDATES=("linux-x86_64" "linux-arm64" "linux-aarch64") + ;; + *) + print_error "Unsupported host OS: $uname_s" + exit 1 + ;; + esac + + for candidate in "${HOST_CANDIDATES[@]}"; do + if [ -d "$PREBUILT_DIR/$candidate" ]; then + NDK_HOST="$candidate" + NDK_LLVM_BIN="$PREBUILT_DIR/$candidate/bin" + LLVM_STRIP="$NDK_LLVM_BIN/llvm-strip" + LLVM_NM="$NDK_LLVM_BIN/llvm-nm" + LLVM_READELF="$NDK_LLVM_BIN/llvm-readelf" + return 0 + fi + done + + print_error "No compatible NDK toolchain found under $PREBUILT_DIR" + print_info "Searched for: ${HOST_CANDIDATES[*]}" + exit 1 +} + +check_prerequisites() { + print_header "Checking Prerequisites" + + # Bash version + if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then + print_error "Bash 4+ is required. macOS ships bash 3.2 by default." + print_info "macOS: brew install bash" + print_info "Linux: use your distro package manager (e.g., sudo apt install bash)" + print_info "Then run with the installed bash (e.g., /opt/homebrew/bin/bash ./build-arti.sh)" + exit 1 + fi + print_success "Bash found: $BASH_VERSION" + + # Git + if ! command -v git >/dev/null 2>&1; then + print_error "git is not installed." + exit 1 + fi + print_success "git found: $(git --version)" + + # Rust + if ! command -v rustc >/dev/null 2>&1; then + print_error "Rust is not installed." + exit 1 + fi + local actual_rust_version + actual_rust_version="$(rustc --version | awk '{print $2}')" + if [ "$actual_rust_version" != "$RUST_VERSION" ]; then + print_error "Rust $RUST_VERSION is required; found $actual_rust_version" + exit 1 + fi + print_success "Rust version verified: $actual_rust_version" + + # rustup + if ! command -v rustup >/dev/null 2>&1; then + print_error "rustup is required (for managing targets). Install from https://rustup.rs/" + exit 1 + fi + print_success "rustup found: $(rustup --version | head -1)" + + # cargo-ndk + if ! command -v cargo-ndk >/dev/null 2>&1; then + print_error "cargo-ndk $CARGO_NDK_VERSION is not installed." + exit 1 + fi + local actual_cargo_ndk_version + actual_cargo_ndk_version="$(cargo ndk --version | awk '{print $2}')" + if [ "$actual_cargo_ndk_version" != "$CARGO_NDK_VERSION" ]; then + print_error "cargo-ndk $CARGO_NDK_VERSION is required; found $actual_cargo_ndk_version" + exit 1 + fi + print_success "cargo-ndk version verified: $actual_cargo_ndk_version" + + # NDK + if [ ! -d "$ANDROID_NDK_HOME" ]; then + print_error "Android NDK not found at: $ANDROID_NDK_HOME" + print_info "Set ANDROID_NDK_HOME environment variable to your NDK location" + exit 1 + fi + print_success "Android NDK found: $ANDROID_NDK_HOME" + + local ndk_properties="$ANDROID_NDK_HOME/source.properties" + if [ ! -f "$ndk_properties" ]; then + print_error "NDK source.properties not found." + exit 1 + fi + local actual_ndk_version + actual_ndk_version="$(sed -n 's/^Pkg.Revision *= *//p' "$ndk_properties" | tr -d '\r')" + if [ "$actual_ndk_version" != "$ANDROID_NDK_VERSION" ]; then + print_error "Android NDK $ANDROID_NDK_VERSION is required; found $actual_ndk_version" + exit 1 + fi + print_success "Android NDK version verified: $actual_ndk_version" + + detect_ndk_host + if [ ! -d "$NDK_LLVM_BIN" ]; then + print_error "NDK LLVM toolchain bin directory not found: $NDK_LLVM_BIN" + exit 1 + fi + print_success "NDK host tag: $NDK_HOST" + + if [ ! -x "$LLVM_STRIP" ]; then + print_error "Pinned llvm-strip is missing." + exit 1 + fi + print_success "llvm-strip found" + + if [ ! -x "$LLVM_NM" ] && ! command -v nm >/dev/null 2>&1; then + print_error "Neither llvm-nm nor nm found. Cannot verify JNI symbols." + exit 1 + fi + if [ -x "$LLVM_NM" ]; then + print_success "llvm-nm found" + else + print_info "llvm-nm not found, will fall back to system nm" + fi + + if [ ! -x "$LLVM_READELF" ] && ! command -v readelf >/dev/null 2>&1; then + print_info "Neither llvm-readelf nor readelf found. Alignment verification may be skipped." + else + print_success "readelf capability available" + fi + # Android targets + for TARGET in "${TARGETS[@]}"; do + if ! rustup target list --installed | grep -qx "$TARGET"; then + print_error "Rust target $TARGET not installed" + print_info "Run: rustup target add $TARGET" + exit 1 + fi + done + print_success "All Rust Android targets installed" + + echo "" +} + +clone_or_update_arti() { + print_header "Setting up Arti Source (version: $VERSION, commit: $ARTI_COMMIT)" + + if [ "$CLEAN_BUILD" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cleaning existing Arti source..." + rm -rf "$ARTI_SOURCE_DIR" + fi + + if [ ! -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cloning official Arti repository..." + git clone --filter=blob:none --no-checkout \ + https://gitlab.torproject.org/tpo/core/arti.git "$ARTI_SOURCE_DIR" + else + print_info "Using cached Arti source at $ARTI_SOURCE_DIR" + fi + + cd "$ARTI_SOURCE_DIR" + + print_info "Fetching pinned commit..." + git fetch --depth 1 origin "$ARTI_COMMIT" --quiet + + print_info "Checking out commit: $ARTI_COMMIT" + git checkout --detach "$ARTI_COMMIT" --quiet + + # Ensure clean working tree to avoid cached modifications influencing builds + print_info "Resetting repository state (hard) and cleaning untracked files..." + git reset --hard --quiet + git clean -ffdqx --quiet + + local actual_commit + actual_commit="$(git rev-parse HEAD)" + if [ "$actual_commit" != "$ARTI_COMMIT" ]; then + print_error "Arti checkout mismatch." + exit 1 + fi + print_success "Arti source commit verified" + + # Apply patches + if [ -d "$SCRIPT_DIR/patches" ]; then + print_info "Applying patches..." + for patch in "$SCRIPT_DIR/patches"/*.patch; do + if [ -f "$patch" ]; then + print_info "Applying $(basename "$patch")" + git apply "$patch" || { print_error "Failed to apply $patch"; exit 1; } + fi + done + fi + + echo "" +} + +setup_wrapper() { + print_header "Setting up JNI Wrapper" + + WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + + # Recreate wrapper directory to avoid stale files + rm -rf "$WRAPPER_DIR" + mkdir -p "$WRAPPER_DIR/src" + + cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/" + cp "$SCRIPT_DIR/Cargo.toml" "$WRAPPER_DIR/" + cp "$LOCKFILE_SOURCE" "$WRAPPER_DIR/Cargo.lock" + + print_success "Wrapper files copied to $WRAPPER_DIR" + echo "" +} + +ensure_wrapper_lockfile() { + print_header "Ensuring wrapper Cargo.lock exists" + + local WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + local LOCKFILE="$WRAPPER_DIR/Cargo.lock" + + if [ ! -f "$LOCKFILE_SOURCE" ] || [ ! -f "$LOCKFILE" ]; then + print_error "Committed Cargo.lock is required." + return 1 + fi + + if ! cmp -s "$LOCKFILE_SOURCE" "$LOCKFILE"; then + print_error "Wrapper Cargo.lock differs from the committed lockfile." + return 1 + fi + + print_success "Committed Cargo.lock verified" + echo "" +} + +update_wrapper_lockfile() { + print_header "Updating wrapper Cargo.lock" + + local WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + cargo generate-lockfile --manifest-path "$WRAPPER_DIR/Cargo.toml" + cp "$WRAPPER_DIR/Cargo.lock" "$LOCKFILE_SOURCE" + + print_success "Updated $LOCKFILE_SOURCE" + echo "" +} + +build_for_target() { + local TARGET="$1" + local ABI="${ABI_MAP[$TARGET]}" + local OUTPUT_PATH="$JNILIBS_DIR/$ABI" + + print_header "Building for $ABI ($TARGET)" + + mkdir -p "$OUTPUT_PATH" + + print_info "Building Arti Android wrapper..." + cargo ndk \ + -t "$TARGET" \ + --platform "$MIN_SDK_VERSION" \ + -o "$OUTPUT_PATH" \ + build --release \ + --locked \ + --manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml" + + local LIB_NAME="libarti_android.so" + local NESTED_PATH="$OUTPUT_PATH/$ABI/$LIB_NAME" + + if [ -f "$NESTED_PATH" ]; then + mv "$NESTED_PATH" "$OUTPUT_PATH/$LIB_NAME" + rmdir "$OUTPUT_PATH/$ABI" 2>/dev/null || true + fi + + if [ -f "$OUTPUT_PATH/$LIB_NAME" ]; then + print_success "Built: $OUTPUT_PATH/$LIB_NAME" + + # Strip debug symbols safely + print_info "Stripping debug symbols..." + if [ -x "$LLVM_STRIP" ]; then + "$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" + print_success "Stripped debug symbols" + fi + + local SIZE + SIZE="$(du -h "$OUTPUT_PATH/$LIB_NAME" | cut -f1)" + print_success "Final size: $SIZE" + + # Verify 16KB page size alignment (best-effort) + print_info "Verifying 16KB page alignment..." + local READELF_TOOL="" + if [ -x "$LLVM_READELF" ]; then + READELF_TOOL="$LLVM_READELF" + elif command -v readelf >/dev/null 2>&1; then + READELF_TOOL="$(command -v readelf)" + fi + + if [ -n "$READELF_TOOL" ]; then + local ALIGNMENT + ALIGNMENT="$("$READELF_TOOL" -l "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null | grep "LOAD" | head -1 | awk '{print $NF}' || echo "unknown")" + if [ "$ALIGNMENT" = "0x4000" ] || [ "$ALIGNMENT" = "16384" ]; then + print_success "16KB page alignment verified: $ALIGNMENT" + else + print_info "Page alignment: $ALIGNMENT (NDK handles 16KB at link time)" + fi + else + print_info "Skipping alignment check (readelf not available)" + fi + else + print_error "Build failed: $LIB_NAME not found" + return 1 + fi + + echo "" +} + +verify_no_host_paths() { + print_header "Verifying Native Build Privacy" + + if ! command -v strings >/dev/null 2>&1; then + print_error "strings is required to check native artifacts for host paths." + return 1 + fi + + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + if strings "$LIB_PATH" | grep -Eq '/Users/|/home/|[A-Za-z]:\\\\Users\\\\'; then + print_error "Host-specific path detected in $ABI native library." + return 1 + fi + done + + print_success "No host-specific paths detected" + echo "" +} + +write_checksums() { + print_header "Writing Native Checksums" + + local checksum_file="$SCRIPT_DIR/SHA256SUMS" + : > "$checksum_file" + for abi in arm64-v8a armeabi-v7a x86 x86_64; do + local lib_path="$JNILIBS_DIR/$abi/libarti_android.so" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$lib_path" | sed "s# $JNILIBS_DIR/# app/src/main/jniLibs/#" >> "$checksum_file" + else + shasum -a 256 "$lib_path" | sed "s# $JNILIBS_DIR/# app/src/main/jniLibs/#" >> "$checksum_file" + fi + done + print_success "Wrote $checksum_file" + echo "" +} + +verify_jni_symbols_for_lib() { + local LIB_PATH="$1" + + if [ ! -f "$LIB_PATH" ]; then + print_error "Library not found: $LIB_PATH" + return 1 + fi + + local EXPECTED_SYMBOLS=( + "Java_org_torproject_arti_ArtiNative_getVersion" + "Java_org_torproject_arti_ArtiNative_setLogCallback" + "Java_org_torproject_arti_ArtiNative_initialize" + "Java_org_torproject_arti_ArtiNative_startSocksProxy" + "Java_org_torproject_arti_ArtiNative_stop" + ) + + local ALL_FOUND=true + for SYMBOL in "${EXPECTED_SYMBOLS[@]}"; do + local FOUND=false + + if [ -x "$LLVM_NM" ]; then + if "$LLVM_NM" -D --defined-only "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + elif command -v nm >/dev/null 2>&1; then + # Fallback (may be unreliable for ELF on macOS) + if nm -g "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + fi + + if [ "$FOUND" = true ]; then + print_success " Found: $SYMBOL" + else + print_error " Missing: $SYMBOL" + ALL_FOUND=false + fi + done + + if [ "$ALL_FOUND" = true ]; then + print_success "All JNI symbols verified for: $LIB_PATH" + else + print_error "Some JNI symbols are missing for: $LIB_PATH" + return 1 + fi + + return 0 +} + +verify_jni_symbols() { + print_header "Verifying JNI Symbols" + print_info "Checking exported JNI symbols..." + + local FAILED=false + + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + print_info "Verifying $ABI: $LIB_PATH" + if ! verify_jni_symbols_for_lib "$LIB_PATH"; then + FAILED=true + fi + done + + if [ "$FAILED" = true ]; then + return 1 + fi + + echo "" +} + +show_summary() { + print_header "Build Complete!" + + echo -e "${GREEN}Built libraries:${NC}" + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + if [ -f "$LIB_PATH" ]; then + local SIZE + SIZE="$(du -h "$LIB_PATH" | cut -f1)" + echo -e " ${GREEN}$ABI:${NC} $SIZE" + fi + done + + echo "" + echo -e "${GREEN}Arti version:${NC} $VERSION" + echo -e "${GREEN}Source:${NC} https://gitlab.torproject.org/tpo/core/arti" + echo "" + echo -e "${GREEN}Next steps:${NC}" + echo " 1. Test the build: ./gradlew assembleDebug" + echo " 2. Verify checksums: tools/arti-build/verify-checksums.sh" + echo "" + echo -e "${GREEN}To update Arti version:${NC}" + echo " 1. Update TOOLCHAIN.env and regenerate Cargo.lock" + echo " 2. Run: tools/arti-build/rebuild-in-container.sh" + echo "" +} + +# ============================================================================== +# Main +# ============================================================================== + +main() { + print_header "Arti Android Build Script" + echo -e "${BLUE}Building Arti for Android with 16KB page size support${NC}" + echo -e "${BLUE}Version: $VERSION${NC}" + echo -e "${BLUE}Architectures: ${TARGETS[*]}${NC}" + echo "" + + check_prerequisites + clone_or_update_arti + setup_wrapper + if [ "$UPDATE_LOCKFILE" = true ]; then + update_wrapper_lockfile + return 0 + fi + ensure_wrapper_lockfile + + for TARGET in "${TARGETS[@]}"; do + build_for_target "$TARGET" + done + + verify_jni_symbols + verify_no_host_paths + if [ "$RELEASE_ONLY" = false ]; then + write_checksums + fi + show_summary +} + +main diff --git a/tools/arti-build/rebuild-in-container.sh b/tools/arti-build/rebuild-in-container.sh new file mode 100755 index 00000000..ad7918b9 --- /dev/null +++ b/tools/arti-build/rebuild-in-container.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +IMAGE_NAME="bitchat-android-arti-builder:1.9.0" +MODE="${1:-}" + +if ! command -v docker >/dev/null 2>&1; then + echo "error: Docker is required to rebuild the native Arti libraries" >&2 + exit 1 +fi + +mkdir -p "$PROJECT_ROOT/.reproducible-build/cargo-home" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/TOOLCHAIN.env" +if ! [[ "$NATIVE_SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]]; then + echo "error: NATIVE_SOURCE_DATE_EPOCH must be an integer" >&2 + exit 1 +fi + +docker build \ + --platform linux/amd64 \ + --file "$SCRIPT_DIR/Dockerfile" \ + --tag "$IMAGE_NAME" \ + "$PROJECT_ROOT" + +docker run \ + --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + --env CARGO_HOME=/workspace/.reproducible-build/cargo-home \ + --env HOME=/workspace/.reproducible-build \ + --env SOURCE_DATE_EPOCH="$NATIVE_SOURCE_DATE_EPOCH" \ + --volume "$PROJECT_ROOT:/workspace" \ + "$IMAGE_NAME" \ + ${MODE:+"$MODE"} + +if [ "$MODE" = "--update-lockfile" ]; then + echo "Cargo.lock updated in the pinned native container; review it before rebuilding." + exit 0 +fi +"$SCRIPT_DIR/verify-checksums.sh" diff --git a/tools/arti-build/rust-toolchain.toml b/tools/arti-build/rust-toolchain.toml new file mode 100644 index 00000000..47e85ae9 --- /dev/null +++ b/tools/arti-build/rust-toolchain.toml @@ -0,0 +1,9 @@ +[toolchain] +channel = "1.95.0" +profile = "minimal" +targets = [ + "aarch64-linux-android", + "armv7-linux-androideabi", + "i686-linux-android", + "x86_64-linux-android", +] diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs new file mode 100644 index 00000000..941b1386 --- /dev/null +++ b/tools/arti-build/src/lib.rs @@ -0,0 +1,493 @@ +use jni::JNIEnv; +use jni::objects::{JClass, JString, JObject, GlobalRef}; +use jni::sys::{jint, jstring}; +use jni::JavaVM; + +use arti_client::TorClient; +use arti_client::config::TorClientConfigBuilder; +use tor_rtcompat::PreferredRuntime; + +use std::sync::{Arc, Mutex, Once}; +use std::path::PathBuf; +use anyhow::Result; + +// ============================================================================ +// Global State +// ============================================================================ + +/// Global Arti client instance +static ARTI_CLIENT: Mutex>>> = Mutex::new(None); + +/// Global Tokio runtime (must persist for Arti to work) +static TOKIO_RUNTIME: Mutex> = Mutex::new(None); + +/// Global JavaVM reference (cached on first JNI call) +static JAVA_VM: Mutex> = Mutex::new(None); + +/// Global log callback reference +static LOG_CALLBACK: Mutex> = Mutex::new(None); + +/// Handle to SOCKS server task (for graceful shutdown) +static SOCKS_TASK: Mutex>> = Mutex::new(None); + +/// Initialization flag +static INIT_ONCE: Once = Once::new(); + +// ============================================================================ +// Logging Integration +// ============================================================================ + +/// Send log message to Java callback +fn send_log_to_java(message: String) { + let vm_opt = JAVA_VM.lock().unwrap(); + let callback_opt = LOG_CALLBACK.lock().unwrap(); + + if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) { + if let Ok(mut env) = vm.attach_current_thread() { + if let Ok(jmessage) = env.new_string(&message) { + let _ = env.call_method( + callback.as_obj(), + "onLogLine", + "(Ljava/lang/String;)V", + &[(&jmessage).into()] + ); + } + } + } +} + +/// Macro for logging to both Android logcat and Java callback +macro_rules! log_info { + ($($arg:tt)*) => {{ + let msg = format!($($arg)*); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +macro_rules! log_error { + ($($arg:tt)*) => {{ + let msg = format!("ERROR: {}", format!($($arg)*)); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +// ============================================================================ +// JNI Functions +// ============================================================================ + +/// Get Arti version string +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_getVersion( + env: JNIEnv, + _class: JClass, +) -> jstring { + // Cache JavaVM on first call + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION")); + let output = env.new_string(version).expect("Couldn't create java string!"); + output.into_raw() +} + +/// Set log callback for Arti logs +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_setLogCallback( + env: JNIEnv, + _class: JClass, + callback: JObject, +) { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + // Store global reference to callback + if let Ok(global_ref) = env.new_global_ref(callback) { + *LOG_CALLBACK.lock().unwrap() = Some(global_ref); + log_info!("Log callback registered"); + } +} + +/// Initialize Arti runtime +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_initialize( + mut env: JNIEnv, + _class: JClass, + data_dir: JString, +) -> jint { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let data_dir_str: String = match env.get_string(&data_dir) { + Ok(s) => s.into(), + Err(e) => { + log_error!("Failed to convert data_dir: {:?}", e); + return -1; + } + }; + + log_info!("AMEx: state changed to Initialized"); + log_info!("Initializing Arti with data directory: {}", data_dir_str); + + // Initialize Tokio runtime (once) + INIT_ONCE.call_once(|| { + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => { + log_info!("Tokio runtime created successfully"); + *TOKIO_RUNTIME.lock().unwrap() = Some(rt); + } + Err(e) => { + log_error!("Failed to create Tokio runtime: {:?}", e); + } + } + }); + + // Check if runtime exists + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Create config with explicit Android paths + let data_path = PathBuf::from(data_dir_str); + let cache_dir = data_path.join("cache"); + let state_dir = data_path.join("state"); + + // Create directories if they don't exist + std::fs::create_dir_all(&cache_dir).ok(); + std::fs::create_dir_all(&state_dir).ok(); + + let result: Result<()> = runtime.block_on(async { + log_info!("Creating Arti client..."); + log_info!("Cache dir: {:?}", cache_dir); + log_info!("State dir: {:?}", state_dir); + + // Create config with Android-specific directories + let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) + .build()?; + + // Create client with Android-specific config + let client = TorClient::create_bootstrapped(config).await?; + + log_info!("Arti client created successfully"); + + // Store client globally + *ARTI_CLIENT.lock().unwrap() = Some(Arc::new(client)); + + Ok(()) + }); + + match result { + Ok(_) => { + log_info!("Arti initialized successfully"); + 0 + } + Err(e) => { + log_error!("Failed to initialize Arti: {:?}", e); + -3 + } + } +} + +/// Start SOCKS proxy on specified port +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_startSocksProxy( + _env: JNIEnv, + _class: JClass, + port: jint, +) -> jint { + log_info!("AMEx: state changed to Starting"); + log_info!("Starting SOCKS proxy on port {}", port); + + // Stop any existing SOCKS server first + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting previous SOCKS server task"); + handle.abort(); + } + + let client_guard = ARTI_CLIENT.lock().unwrap(); + let client = match client_guard.as_ref() { + Some(c) => Arc::clone(c), + None => { + log_error!("Arti client not initialized - call initialize() first"); + return -1; + } + }; + drop(client_guard); + + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Try to bind IMMEDIATELY to detect port conflicts before returning + let addr = format!("127.0.0.1:{}", port); + + // Use block_on to synchronously attempt binding + let bind_result = runtime.block_on(async { + tokio::net::TcpListener::bind(&addr).await + }); + + let listener = match bind_result { + Ok(l) => { + log_info!("SOCKS proxy bound to {}", addr); + l + } + Err(e) => { + log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e); + return -3; + } + }; + + // Now spawn the background task with the already-bound listener + let handle = runtime.spawn(async move { + log_info!("SOCKS proxy listening on {}", addr); + log_info!("Sufficiently bootstrapped; system SOCKS now functional"); + + // Signal bootstrap completion to bitchat-android (expected by ArtiTorManager) + // This sets bootstrapPercent to 100% and stops inactivity restarts + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + log_info!("We have found that guard [scrubbed] is usable."); + + // Accept connections + loop { + match listener.accept().await { + Ok((stream, peer_addr)) => { + log_info!("SOCKS connection from: {}", peer_addr); + let client_clone = Arc::clone(&client); + + tokio::spawn(async move { + if let Err(e) = handle_socks_connection(stream, client_clone).await { + log_error!("SOCKS connection error: {:?}", e); + } + }); + } + Err(e) => { + log_error!("Failed to accept SOCKS connection: {:?}", e); + break; // Exit loop on error + } + } + } + + log_info!("SOCKS proxy task exiting"); + }); + + // Store handle for cleanup + *SOCKS_TASK.lock().unwrap() = Some(handle); + + log_info!("SOCKS proxy started on port {}", port); + 0 +} + +/// Handle a single SOCKS connection +async fn handle_socks_connection( + mut stream: tokio::net::TcpStream, + client: Arc>, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Simple SOCKS5 handshake + let mut buf = [0u8; 512]; + + // Read version + methods + let n = stream.read(&mut buf).await?; + if n < 2 { + return Err(anyhow::anyhow!("Invalid SOCKS handshake")); + } + + // Send "no auth required" response + stream.write_all(&[0x05, 0x00]).await?; + + // Read request + let n = stream.read(&mut buf).await?; + if n < 10 { + return Err(anyhow::anyhow!("Invalid SOCKS request")); + } + + // Parse SOCKS5 request: VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2) + let version = buf[0]; + let cmd = buf[1]; + let atyp = buf[3]; + + if version != 0x05 { + return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version)); + } + + if cmd != 0x01 { + // Only support CONNECT command + stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd)); + } + + // Parse target address and port + let (target_host, target_port) = match atyp { + 0x01 => { + // IPv4: 4 bytes + let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + (ip, port) + } + 0x03 => { + // Domain name: length byte + domain + let len = buf[4] as usize; + if n < 5 + len + 2 { + return Err(anyhow::anyhow!("Invalid domain name length")); + } + let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string(); + let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]); + (domain, port) + } + 0x04 => { + // IPv6: 16 bytes + 2 bytes port = 22 bytes total + if n < 22 { + stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Truncated IPv6 request")); + } + let ip = format!( + "{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}", + buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11], + buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19] + ); + let port = u16::from_be_bytes([buf[20], buf[21]]); + (ip, port) + } + _ => { + stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported address type: {}", atyp)); + } + }; + + log_info!("SOCKS5 CONNECT to {}:{}", target_host, target_port); + + // Establish Tor connection + let tor_stream = match client.connect((target_host.as_str(), target_port)).await { + Ok(s) => s, + Err(e) => { + log_error!("Failed to connect through Tor: {:?}", e); + // Send SOCKS5 error: general failure + stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(e.into()); + } + }; + + log_info!("Tor connection established to {}:{}", target_host, target_port); + + // Send SOCKS5 success response + stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + + // Bidirectional data forwarding + let (mut client_read, mut client_write) = stream.split(); + let (mut tor_read, mut tor_write) = tor_stream.split(); + + let client_to_tor = async { + tokio::io::copy(&mut client_read, &mut tor_write).await + }; + + let tor_to_client = async { + tokio::io::copy(&mut tor_read, &mut client_write).await + }; + + // Run both directions concurrently, exit when either completes + tokio::select! { + result = client_to_tor => { + if let Err(ref e) = result { + log_error!("Client->Tor copy error: {:?}", e); + } + } + result = tor_to_client => { + if let Err(ref e) = result { + log_error!("Tor->Client copy error: {:?}", e); + } + } + }; + + log_info!("SOCKS connection closed for {}:{}", target_host, target_port); + + Ok(()) +} + +/// Stop Arti and cleanup +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_stop( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("AMEx: state changed to Stopping"); + log_info!("Stopping Arti..."); + + // Abort SOCKS proxy task (releases the port) + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting SOCKS server task"); + handle.abort(); + } + + // Give the abort a moment to complete and release the port + if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { + rt.block_on(async { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + }); + } + + // NOTE: We do NOT clear ARTI_CLIENT here! + // The TorClient can be reused for multiple SOCKS proxy start/stop cycles. + // Only clear it if you want to force full reinitialization. + + // Uncomment this line only if you want to force reinitialization on every start: + // *ARTI_CLIENT.lock().unwrap() = None; + + log_info!("AMEx: state changed to Stopped"); + log_info!("Arti stopped successfully"); + + 0 +} + +// ============================================================================ +// Android Logger (simple implementation) +// ============================================================================ + +mod android_logger { + use std::ffi::CString; + + #[allow(non_camel_case_types)] + type c_int = i32; + + #[allow(non_camel_case_types)] + type c_char = i8; + + extern "C" { + fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int; + } + + const ANDROID_LOG_INFO: c_int = 4; + + pub fn log(message: &str) { + unsafe { + let tag = CString::new("ArtiNative").unwrap(); + let text = CString::new(message).unwrap(); + __android_log_write(ANDROID_LOG_INFO, tag.as_ptr() as *const c_char, text.as_ptr() as *const c_char); + } + } +} diff --git a/tools/arti-build/verify-checksums.sh b/tools/arti-build/verify-checksums.sh new file mode 100755 index 00000000..4ae8957f --- /dev/null +++ b/tools/arti-build/verify-checksums.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CHECKSUM_FILE="$SCRIPT_DIR/SHA256SUMS" + +if [ ! -f "$CHECKSUM_FILE" ]; then + echo "error: missing native checksum manifest: $CHECKSUM_FILE" >&2 + exit 1 +fi + +cd "$PROJECT_ROOT" + +if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "$CHECKSUM_FILE" +else + shasum -a 256 -c "$CHECKSUM_FILE" +fi + +for library in app/src/main/jniLibs/*/libarti_android.so; do + if strings "$library" | grep -Eq '/Users/|/home/|[A-Za-z]:\\\\Users\\\\'; then + echo "error: host-specific path detected in a native library" >&2 + exit 1 + fi +done + +echo "Native Arti libraries match the pinned checksums and contain no host paths." diff --git a/tools/coverage/__init__.py b/tools/coverage/__init__.py new file mode 100644 index 00000000..8aec01cc --- /dev/null +++ b/tools/coverage/__init__.py @@ -0,0 +1 @@ +"""Coverage verification helpers.""" diff --git a/tools/coverage/check_changed_coverage.py b/tools/coverage/check_changed_coverage.py new file mode 100644 index 00000000..fde7b1bc --- /dev/null +++ b/tools/coverage/check_changed_coverage.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Enforce JaCoCo coverage for executable Kotlin/Java lines changed from a Git base.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass + + +SOURCE_ROOTS = ( + "app/src/main/java/", + "app/src/main/kotlin/", +) + + +@dataclass(frozen=True) +class CoverageLine: + missed_instructions: int + covered_instructions: int + + @property + def covered(self) -> bool: + return self.covered_instructions > 0 + + +def parse_jacoco(xml_path: pathlib.Path) -> dict[tuple[str, int], CoverageLine]: + root = ET.parse(xml_path).getroot() + result: dict[tuple[str, int], CoverageLine] = {} + for package in root.findall("package"): + package_name = package.attrib["name"] + for source_file in package.findall("sourcefile"): + relative_path = f"{package_name}/{source_file.attrib['name']}" + for line in source_file.findall("line"): + result[(relative_path, int(line.attrib["nr"]))] = CoverageLine( + missed_instructions=int(line.attrib.get("mi", "0")), + covered_instructions=int(line.attrib.get("ci", "0")), + ) + return result + + +def parse_changed_lines(diff_text: str) -> dict[str, set[int]]: + changed: dict[str, set[int]] = {} + current_path: str | None = None + for raw_line in diff_text.splitlines(): + if raw_line.startswith("+++ b/"): + current_path = raw_line[6:] + continue + if raw_line.startswith("+++ /dev/null"): + current_path = None + continue + if not raw_line.startswith("@@") or current_path is None: + continue + match = re.search(r"\+(\d+)(?:,(\d+))?", raw_line) + if match is None: + continue + start = int(match.group(1)) + count = int(match.group(2) or "1") + if count > 0: + changed.setdefault(current_path, set()).update(range(start, start + count)) + return changed + + +def jacoco_relative_path(repository_path: str) -> str | None: + for root in SOURCE_ROOTS: + if repository_path.startswith(root): + return repository_path[len(root) :] + return None + + +def git_diff_command(base: str) -> list[str]: + return [ + "git", + "diff", + # Reformatting an executable line without changing its tokens must not turn an otherwise + # covered change set into a coverage failure. + "--ignore-all-space", + "--unified=0", + "--diff-filter=AM", + base, + "--", + *SOURCE_ROOTS, + ] + + +def git_diff(base: str) -> str: + command = git_diff_command(base) + result = subprocess.run(command, check=True, capture_output=True, text=True) + return result.stdout + + +def evaluate( + changed: dict[str, set[int]], + coverage: dict[tuple[str, int], CoverageLine], +) -> tuple[int, int, list[str]]: + executable = 0 + covered = 0 + missed: list[str] = [] + for repository_path, line_numbers in sorted(changed.items()): + source_path = jacoco_relative_path(repository_path) + if source_path is None: + continue + for line_number in sorted(line_numbers): + line = coverage.get((source_path, line_number)) + if line is None: + continue + executable += 1 + if line.covered: + covered += 1 + else: + missed.append(f"{repository_path}:{line_number}") + return covered, executable, missed + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--xml", type=pathlib.Path, required=True) + parser.add_argument("--base", required=True) + parser.add_argument("--threshold", type=float, default=0.80) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not 0.0 <= args.threshold <= 1.0: + raise SystemExit("--threshold must be between 0 and 1") + if not args.xml.is_file(): + raise SystemExit(f"JaCoCo XML report not found: {args.xml}") + + coverage = parse_jacoco(args.xml) + changed = parse_changed_lines(git_diff(args.base)) + covered, executable, missed = evaluate(changed, coverage) + ratio = 1.0 if executable == 0 else covered / executable + + print( + f"Changed executable line coverage: {covered}/{executable} " + f"({ratio:.1%}), required {args.threshold:.1%}" + ) + if ratio >= args.threshold: + return 0 + + for location in missed[:50]: + print(f"UNCOVERED {location}") + if len(missed) > 50: + print(f"... and {len(missed) - 50} more uncovered executable lines") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/coverage/test_check_changed_coverage.py b/tools/coverage/test_check_changed_coverage.py new file mode 100644 index 00000000..7c452449 --- /dev/null +++ b/tools/coverage/test_check_changed_coverage.py @@ -0,0 +1,85 @@ +import pathlib +import tempfile +import unittest + +from tools.coverage.check_changed_coverage import ( + CoverageLine, + evaluate, + git_diff_command, + jacoco_relative_path, + parse_changed_lines, + parse_jacoco, +) + + +class ChangedCoverageToolTest(unittest.TestCase): + def test_parses_added_and_modified_hunks(self) -> None: + diff = """\ +diff --git a/app/src/main/java/example/Thing.kt b/app/src/main/java/example/Thing.kt +--- a/app/src/main/java/example/Thing.kt ++++ b/app/src/main/java/example/Thing.kt +@@ -1,0 +2,3 @@ ++a ++b ++c +@@ -9 +12 @@ +-old ++new +""" + self.assertEqual( + {"app/src/main/java/example/Thing.kt": {2, 3, 4, 12}}, + parse_changed_lines(diff), + ) + + def test_parses_jacoco_source_lines(self) -> None: + xml = """\ + + + + + + + + +""" + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "report.xml" + path.write_text(xml, encoding="utf-8") + parsed = parse_jacoco(path) + + self.assertTrue(parsed[("example/Thing.kt", 2)].covered) + self.assertFalse(parsed[("example/Thing.kt", 3)].covered) + + def test_evaluates_only_executable_changed_lines(self) -> None: + changed = {"app/src/main/java/example/Thing.kt": {1, 2, 3}} + coverage = { + ("example/Thing.kt", 2): CoverageLine(0, 1), + ("example/Thing.kt", 3): CoverageLine(1, 0), + } + + self.assertEqual( + (1, 2, ["app/src/main/java/example/Thing.kt:3"]), + evaluate(changed, coverage), + ) + + def test_maps_both_supported_source_roots(self) -> None: + self.assertEqual( + "example/Thing.kt", + jacoco_relative_path("app/src/main/java/example/Thing.kt"), + ) + self.assertEqual( + "example/Thing.kt", + jacoco_relative_path("app/src/main/kotlin/example/Thing.kt"), + ) + self.assertIsNone(jacoco_relative_path("app/src/test/example/Thing.kt")) + + def test_changed_line_diff_ignores_formatting_only_edits(self) -> None: + command = git_diff_command("origin/main") + + self.assertIn("--ignore-all-space", command) + self.assertIn("--unified=0", command) + self.assertEqual("origin/main", command[command.index("--diff-filter=AM") + 1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release_gate/__init__.py b/tools/release_gate/__init__.py new file mode 100644 index 00000000..e9ab0855 --- /dev/null +++ b/tools/release_gate/__init__.py @@ -0,0 +1 @@ +"""Physical-device and cross-client release-gate tooling.""" diff --git a/tools/release_gate/android_lab.py b/tools/release_gate/android_lab.py new file mode 100644 index 00000000..165bb469 --- /dev/null +++ b/tools/release_gate/android_lab.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""USB/ADB control helpers for the physical release gate. + +Device selectors are accepted only as ephemeral command inputs. They are never +printed or written to artifacts; all output uses the operator-assigned alias. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Callable + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from tools.release_gate.release_gate import ( + GateError, + SAFE_ID_RE, + append_trace_event, +) + + +APPLICATION_ID = "com.bitchat.droid" + + +def find_adb() -> str: + direct = shutil.which("adb") + if direct: + return direct + android_home = os.environ.get("ANDROID_HOME") + if android_home: + candidate = Path(android_home) / "platform-tools" / "adb" + if candidate.is_file(): + return str(candidate) + raise GateError("adb was not found; set ANDROID_HOME or add adb to PATH") + + +def run_adb( + serial: str, + arguments: list[str], + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> str: + result = runner( + [find_adb(), "-s", serial, *arguments], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise GateError("ADB command failed for the selected logical device") + return result.stdout.strip() + + +def count_connected_devices( + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> int: + result = runner( + [find_adb(), "devices"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise GateError("could not enumerate ADB devices") + return sum( + 1 + for line in result.stdout.splitlines()[1:] + if line.strip().endswith("\tdevice") + ) + + +def probe_device(serial: str, alias: str) -> dict[str, object]: + if not SAFE_ID_RE.fullmatch(alias): + raise GateError("device alias must be a lowercase logical identifier") + api_text = run_adb(serial, ["shell", "getprop", "ro.build.version.sdk"]) + if not api_text.isdigit(): + raise GateError("selected device returned an invalid API level") + features = run_adb(serial, ["shell", "pm", "list", "features"]) + manufacturer = run_adb( + serial, ["shell", "getprop", "ro.product.manufacturer"] + ).strip().lower() + model = run_adb(serial, ["shell", "getprop", "ro.product.model"]).strip() + capabilities = ["ble-central", "ble-peripheral"] + if "android.hardware.wifi.aware" in features: + capabilities.append("wifi-aware") + return { + "alias": alias, + "platform": "android", + "model": model, + "manufacturer_class": re.sub(r"[^a-z0-9._-]", "-", manufacturer)[:64], + "api_level": int(api_text), + "physical": True, + "capabilities": capabilities, + } + + +def prepare_disposable_device(serial: str, confirmed: bool) -> None: + if not confirmed: + raise GateError("prepare requires --confirm-disposable-app-data") + run_adb(serial, ["shell", "am", "force-stop", APPLICATION_ID]) + output = run_adb(serial, ["shell", "pm", "clear", APPLICATION_ID]) + if "Success" not in output: + raise GateError("could not clear disposable app data") + + +def collect_resource_snapshot(serial: str) -> dict[str, int | bool]: + pid_text = run_adb(serial, ["shell", "pidof", APPLICATION_ID]) + pid = pid_text.split()[0] if pid_text else "" + metrics: dict[str, int | bool] = {"process-running": bool(pid)} + if not pid.isdigit(): + return metrics + meminfo = run_adb(serial, ["shell", "dumpsys", "meminfo", APPLICATION_ID]) + total_match = re.search(r"TOTAL\s+(\d+)", meminfo) + metrics["total-pss-kb"] = int(total_match.group(1)) if total_match else -1 + thread_text = run_adb( + serial, + ["shell", "sh", "-c", f"find /proc/{pid}/task -mindepth 1 -maxdepth 1 | wc -l"], + ) + fd_text = run_adb( + serial, + ["shell", "sh", "-c", f"find /proc/{pid}/fd -mindepth 1 -maxdepth 1 | wc -l"], + ) + metrics["thread-count"] = int(thread_text) if thread_text.isdigit() else -1 + metrics["fd-count"] = int(fd_text) if fd_text.isdigit() else -1 + power = run_adb(serial, ["shell", "dumpsys", "power"]) + metrics["app-wakelock-count"] = sum( + 1 + for line in power.splitlines() + if APPLICATION_ID in line and "WakeLock" in line + ) + battery = run_adb(serial, ["shell", "dumpsys", "battery"]) + battery_level = re.search(r"^\s*level:\s*(\d+)", battery, re.MULTILINE) + metrics["battery-level-percent"] = ( + int(battery_level.group(1)) if battery_level else -1 + ) + return metrics + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + commands.add_parser("count") + + probe = commands.add_parser("probe") + probe.add_argument("--serial", required=True, help=argparse.SUPPRESS) + probe.add_argument("--alias", required=True) + + prepare = commands.add_parser("prepare") + prepare.add_argument("--serial", required=True, help=argparse.SUPPRESS) + prepare.add_argument("--confirm-disposable-app-data", action="store_true") + + cleanup = commands.add_parser("cleanup") + cleanup.add_argument("--serial", required=True, help=argparse.SUPPRESS) + cleanup.add_argument("--confirm-disposable-app-data", action="store_true") + + snapshot = commands.add_parser("snapshot") + snapshot.add_argument("--serial", required=True, help=argparse.SUPPRESS) + snapshot.add_argument("--alias", required=True) + snapshot.add_argument("--run", type=Path, required=True) + snapshot.add_argument("--scenario", required=True) + snapshot.add_argument("--event", default="resource-snapshot") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "count": + print(json.dumps({"authorized-device-count": count_connected_devices()})) + elif args.command == "probe": + print(json.dumps(probe_device(args.serial, args.alias), sort_keys=True)) + elif args.command in {"prepare", "cleanup"}: + prepare_disposable_device( + args.serial, args.confirm_disposable_app_data + ) + print(json.dumps({"status": "clean", "application": APPLICATION_ID})) + elif args.command == "snapshot": + metrics = collect_resource_snapshot(args.serial) + append_trace_event( + args.run, + args.scenario, + args.alias, + args.event, + "observed", + None, + metrics, + ) + print(json.dumps({"source_alias": args.alias, "metrics": metrics}, sort_keys=True)) + return 0 + except (GateError, OSError, subprocess.SubprocessError) as error: + print(f"android lab error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release_gate/device-matrix.example.json b/tools/release_gate/device-matrix.example.json new file mode 100644 index 00000000..01b8f629 --- /dev/null +++ b/tools/release_gate/device-matrix.example.json @@ -0,0 +1,72 @@ +{ + "schema_version": 1, + "commit": "0000000000000000000000000000000000000000", + "clients": { + "android-current": { + "version": "replace-with-release-candidate", + "commit": "0000000000000000000000000000000000000000" + }, + "android-legacy": { + "version": "replace-with-last-supported-release" + }, + "ios-current": { + "version": "replace-with-current-ios-release" + } + }, + "lab_capabilities": ["local-relay", "tor"], + "devices": [ + { + "alias": "android-low", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-a", + "api_level": 28, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + }, + { + "alias": "android-current", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-b", + "api_level": 35, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"] + }, + { + "alias": "android-relay", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-c", + "api_level": 33, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + }, + { + "alias": "android-aware", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-b", + "api_level": 35, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"] + }, + { + "alias": "android-legacy", + "platform": "android", + "model": "replace-with-model", + "manufacturer_class": "manufacturer-a", + "api_level": 28, + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + }, + { + "alias": "ios-current", + "platform": "ios", + "model": "replace-with-model", + "manufacturer_class": "apple", + "physical": true, + "capabilities": ["ble-central", "ble-peripheral"] + } + ] +} diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py new file mode 100644 index 00000000..7abe8085 --- /dev/null +++ b/tools/release_gate/mesh_lab.py @@ -0,0 +1,1020 @@ +#!/usr/bin/env python3 +"""ADB-driven mesh test orchestrator for two (or more) live devices. + +Drives the debug-only TestHookReceiver in the app +(intent action: com.bitchat.droid.TEST_HOOK) to perform mesh operations: +peer scanning, connect, Noise handshake, DMs, file transfer, broadcast, +announce, and raw packet injection. + +Each on-device command writes a JSON result to +cache/testhook/results/.json inside the app sandbox; this module polls +for it via `run-as` and returns the parsed dict. + +Typical usage: + python3 tools/release_gate/mesh_lab.py setup --serial-a X --serial-b Y --apk app/build/outputs/apk/debug/app-debug.apk + python3 tools/release_gate/mesh_lab.py scenario dm --serial-a X --serial-b Y + python3 tools/release_gate/mesh_lab.py scenario all --serial-a X --serial-b Y + python3 tools/release_gate/mesh_lab.py cmd --serial X scan --extra timeout_ms=30000 +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import hashlib +import json +import random +import shlex +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from tools.release_gate.android_lab import APPLICATION_ID, find_adb, run_adb + +TEST_HOOK_ACTION = "com.bitchat.droid.TEST_HOOK" +TEST_HOOK_COMPONENT = f"{APPLICATION_ID}/com.bitchat.android.testhook.TestHookReceiver" +RESULTS_DIR = "cache/testhook/results" +DEVICE_TMP_DIR = "/data/local/tmp/meshlab" +APP_FIXTURE_DIR = f"/data/data/{APPLICATION_ID}/cache/fixtures" + +WATCH_APPLICATION_ID = "com.bitchat.watch" +WATCH_TEST_HOOK_ACTION = "com.bitchat.watch.TEST_HOOK" +WATCH_TEST_HOOK_COMPONENT = f"{WATCH_APPLICATION_ID}/com.bitchat.watch.testhook.WearTestHookReceiver" + +PERMISSIONS = [ + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.POST_NOTIFICATIONS", + "android.permission.NEARBY_WIFI_DEVICES", + "android.permission.RECORD_AUDIO", +] + +WATCH_PERMISSIONS = [ + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", + "android.permission.POST_NOTIFICATIONS", + "android.permission.RECORD_AUDIO", +] + + +class MeshLabError(Exception): + pass + + +def _shell(serial: str, command: str) -> str: + return run_adb(serial, ["shell", command]) + + +class Device: + """One ADB-connected device running a debug build with the test hook.""" + + def __init__( + self, + serial: str, + alias: str, + package: str = APPLICATION_ID, + hook_action: str = TEST_HOOK_ACTION, + hook_component: str = TEST_HOOK_COMPONENT, + permissions: list[str] = PERMISSIONS, + activity_component: str = f"{APPLICATION_ID}/com.bitchat.android.MainActivity", + ): + self.serial = serial + self.alias = alias + self.package = package + self.hook_action = hook_action + self.hook_component = hook_component + self.permissions = permissions + self.activity_component = activity_component + + # -- app lifecycle ------------------------------------------------------ + + def install(self, apk: Path) -> None: + result = subprocess.run( + [find_adb(), "-s", self.serial, "install", "-r", "-g", str(apk)], + check=False, capture_output=True, text=True, timeout=300, + ) + if result.returncode != 0 or "Success" not in result.stdout: + raise MeshLabError(f"[{self.alias}] install failed: {result.stdout} {result.stderr}") + + def grant_permissions(self) -> None: + for perm in self.permissions: + subprocess.run( + [find_adb(), "-s", self.serial, "shell", "pm", "grant", self.package, perm], + check=False, capture_output=True, text=True, timeout=30, + ) + + def clear_app_data(self) -> None: + _shell(self.serial, f"am force-stop {self.package}") + output = _shell(self.serial, f"pm clear {self.package}") + if "Success" not in output: + raise MeshLabError(f"[{self.alias}] pm clear failed: {output}") + + def force_stop(self) -> None: + _shell(self.serial, f"am force-stop {self.package}") + + def launch(self) -> None: + """Launch the app and verify it is actually top-resumed. + + A background/cached process can be frozen by the system (observed on Wear OS), + which silently hangs test-hook commands; the foreground activity (and the FGS it + starts) keeps the process unfrozen. + """ + for _attempt in range(3): + _shell(self.serial, f"monkey -p {self.package} -c android.intent.category.LAUNCHER 1") + time.sleep(3) + try: + top = _shell( + self.serial, + "dumpsys activity activities | grep topResumedActivity", + ) + if self.package in top: + return + except Exception: + pass + _shell(self.serial, f"am start -n {self.activity_component}") + time.sleep(3) + + def wake(self) -> None: + """Keep the screen on and the app foregrounded (full-power BLE duty cycle). + + A backgrounded app drops to POWER_SAVER (1 s scan per 60 s), which makes + mesh reformation after restarts take minutes and scenarios flaky. + `svc power stayon` only applies while charging, so also stretch the + screen timeout as a fallback. + """ + _shell(self.serial, "svc power stayon true") + _shell(self.serial, "settings put system screen_off_timeout 600000") + subprocess.run( + [find_adb(), "-s", self.serial, "shell", "locksettings", "set-disabled", "true"], + check=False, capture_output=True, text=True, timeout=30, + ) + _shell(self.serial, "input keyevent KEYCODE_WAKEUP") + _shell(self.serial, "wm dismiss-keyguard") + _shell(self.serial, "input keyevent 82") # dismiss non-secure keyguard + _shell(self.serial, "input swipe 500 1500 500 400") # swipe-up dismiss + + def reset_bluetooth(self) -> None: + """Cycle the BT adapter; clears zombie GATT connections from peer restarts.""" + _shell(self.serial, "svc bluetooth disable") + time.sleep(2) + _shell(self.serial, "svc bluetooth enable") + time.sleep(3) + + def enable_bluetooth(self) -> None: + subprocess.run( + [find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"], + check=False, capture_output=True, text=True, timeout=30, + ) + + # -- fixtures ----------------------------------------------------------- + + def push_fixture(self, local: Path, name: str | None = None) -> str: + """Stage a fixture inside the app sandbox and return its app-readable path. + + adb push lands files as shell:ext_data_rw, which the app cannot read + through the FUSE Android/data mount, so the bytes are piped through + the shell into the app's own cache directory via run-as. + """ + fname = name or local.name + tmp = f"{DEVICE_TMP_DIR}/{fname}" + _shell(self.serial, f"mkdir -p {DEVICE_TMP_DIR}") + result = subprocess.run( + [find_adb(), "-s", self.serial, "push", str(local), tmp], + check=False, capture_output=True, text=True, timeout=120, + ) + if result.returncode != 0: + raise MeshLabError(f"[{self.alias}] push failed: {result.stderr}") + fixture_dir = f"/data/data/{self.package}/cache/fixtures" + target = f"{fixture_dir}/{fname}" + _shell( + self.serial, + f"run-as {self.package} mkdir -p {fixture_dir} && " + f"cat {tmp} | run-as {self.package} sh -c 'cat > {target}' && rm -f {tmp}", + ) + return target + + def clear_incoming(self) -> None: + _shell( + self.serial, + f"run-as {self.package} rm -rf cache/files/incoming cache/images/incoming", + ) + + # -- test hook commands ------------------------------------------------- + + def cmd(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict: + """Send a test-hook command and poll for its JSON result.""" + cmd_id = uuid.uuid4().hex[:12] + _shell(self.serial, f"run-as {self.package} rm -f {RESULTS_DIR}/{cmd_id}.json") + + args = [ + "am", "broadcast", "-a", self.hook_action, + "-n", self.hook_component, + "--es", "cmd", cmd, + "--es", "id", cmd_id, + "--el", "timeout_ms", str(timeout_ms), + "--el", "overall_timeout_ms", str(timeout_ms + 30_000), + ] + for key, value in extras.items(): + if value is None: + continue + if isinstance(value, bool): + args += ["--ez", key, "true" if value else "false"] + elif isinstance(value, int): + # `am` stores --el as Long and --ei as Integer; on-device + # readers use getIntExtra, so int extras must go via --ei. + args += ["--ei", key, str(value)] + else: + args += ["--es", key, str(value)] + try: + _shell(self.serial, " ".join(shlex.quote(a) for a in args)) + except Exception as error: + # The shell occasionally hangs even though the broadcast was delivered; + # fall through to result polling, which is the authoritative channel. + print(f"[{self.alias}] warning: broadcast send for '{cmd}' raised: {error}", file=sys.stderr) + + deadline = time.monotonic() + (timeout_ms + 60_000) / 1000 + while time.monotonic() < deadline: + try: + raw = _shell(self.serial, f"run-as {self.package} cat {RESULTS_DIR}/{cmd_id}.json") + if raw.strip().startswith("{"): + return json.loads(raw) + except Exception: + pass + time.sleep(1.0) + raise MeshLabError(f"[{self.alias}] timed out waiting for result of '{cmd}' ({cmd_id})") + + def cmd_ok(self, cmd: str, timeout_ms: int = 60_000, **extras: object) -> dict: + result = self.cmd(cmd, timeout_ms=timeout_ms, **extras) + if result.get("status") != "ok": + raise MeshLabError(f"[{self.alias}] '{cmd}' failed: {result}") + return result + + def logcat_dump(self, lines: int = 200) -> str: + return _shell(self.serial, f"logcat -d -t {lines}") + + +class WatchDevice(Device): + """Pixel Watch running the com.bitchat.watch debug build. + + Same test-hook protocol as the phone; different package/hook, a smaller permission + set (Bluetooth + notifications only), and wake tweaks that skip phone-only keyguard + commands. File-transfer scenarios are not supported on the watch yet (M5 deferred). + """ + + def __init__(self, serial: str, alias: str = "watch"): + super().__init__( + serial, + alias, + package=WATCH_APPLICATION_ID, + hook_action=WATCH_TEST_HOOK_ACTION, + hook_component=WATCH_TEST_HOOK_COMPONENT, + permissions=WATCH_PERMISSIONS, + activity_component=f"{WATCH_APPLICATION_ID}/.MainActivity", + ) + + def wake(self) -> None: + # Keep the screen on while on the charging puck; otherwise Wear shows the + # charging activity on top, our app loses foreground, and the OS freezes the + # process (cached-app freezer), silently hanging test-hook commands. + _shell(self.serial, "settings put global stay_on_while_plugged_in 3") + _shell(self.serial, "svc power stayon true") + _shell(self.serial, "settings put system screen_off_timeout 600000") + _shell(self.serial, "input keyevent KEYCODE_WAKEUP") + + +# MARK: - fixtures + +FIXTURE_SIZES = { + "small_1k.bin": 1_024, + "medium_512k.bin": 512 * 1_024, + "large_2m.bin": 2 * 1_024 * 1_024, +} + + +def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = None) -> dict[str, dict]: + directory.mkdir(parents=True, exist_ok=True) + fixtures = {} + rng = random.Random(seed) + for name, size in FIXTURE_SIZES.items(): + if names is not None and name not in names: + rng.randbytes(size) # keep the stream deterministic across subsets + continue + path = directory / name + data = rng.randbytes(size) + path.write_bytes(data) + fixtures[name] = {"path": path, "sha256": hashlib.sha256(data).hexdigest(), "bytes": size} + return fixtures + + +def make_private_media_fixtures(directory: Path, seed: int = 7331) -> dict[str, dict]: + """Small attachment fixtures covering every private-media UI type.""" + directory.mkdir(parents=True, exist_ok=True) + fixtures = {} + rng = random.Random(seed) + for name, mime in ( + ("voice_note.m4a", "audio/mp4"), + ("image_note.jpg", "image/jpeg"), + ("document_note.txt", "text/plain"), + ): + path = directory / name + data = rng.randbytes(1_024) + path.write_bytes(data) + fixtures[name] = { + "path": path, + "sha256": hashlib.sha256(data).hexdigest(), + "bytes": len(data), + "mime": mime, + } + return fixtures + + +# MARK: - setup + +def setup_pair( + a: Device, + b: Device, + apk_a: Path | None, + nickname_a: str, + nickname_b: str, + apk_b: Path | None = None, +) -> None: + if apk_b is None: + apk_b = apk_a + for device, nickname, apk in ((a, nickname_a, apk_a), (b, nickname_b, apk_b)): + device.reset_bluetooth() + device.enable_bluetooth() + if apk is not None: + device.install(apk) + device.clear_app_data() + device.grant_permissions() + device.wake() + device.launch() + device.cmd_ok("start") + device.cmd_ok("set_nickname", name=nickname) + wait_for_mutual_discovery(a, b) + + +def whoami(device: Device) -> dict: + return device.cmd_ok("whoami") + + +def wait_for_peer(device: Device, peer_id: str, timeout_s: int = 90) -> dict: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + result = device.cmd_ok("peers") + for peer in result.get("peers", []): + if peer.get("id") == peer_id: + return peer + device.cmd_ok("announce") + time.sleep(3) + raise MeshLabError(f"[{device.alias}] peer {peer_id} not discovered within {timeout_s}s") + + +def wait_for_mutual_discovery(a: Device, b: Device) -> None: + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + fa = pool.submit(wait_for_peer, a, id_b) + fb = pool.submit(wait_for_peer, b, id_a) + fa.result() + fb.result() + + +# MARK: - scenarios + +def scenario_dm(a: Device, b: Device) -> dict: + """Handshake, then exchange DMs in both directions with content assertions.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + + hs = a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b) + hs_back = b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a) + + token_ab = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "dm_recv", 60_000, peer=id_a, contains=token_ab) + time.sleep(2) + send = pool.submit(a.cmd_ok, "dm_send", 30_000, peer=id_b, content=f"hello b {token_ab}") + recv_result, send_result = recv.result(), send.result() + assert token_ab in recv_result["content"], recv_result + + token_ba = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(a.cmd_ok, "dm_recv", 60_000, peer=id_b, contains=token_ba) + time.sleep(2) + send = pool.submit(b.cmd_ok, "dm_send", 30_000, peer=id_a, content=f"hello a {token_ba}") + recv_result2, send_result2 = recv.result(), send.result() + assert token_ba in recv_result2["content"], recv_result2 + + return { + "handshake_a_to_b": hs, "handshake_b_to_a": hs_back, + "a_to_b": {"send": send_result, "recv": recv_result}, + "b_to_a": {"send": send_result2, "recv": recv_result2}, + } + + +def scenario_favorite_verification(a: Device, b: Device) -> dict: + """Assert the three-state favorite exchange and local cryptographic verification.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + identity_a = whoami(a)["identity_fingerprint"] + + a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b) + b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a) + + def wait_for_status( + device: Device, + command: str, + peer_id: str, + predicate, + timeout_s: int = 30, + ) -> dict: + deadline = time.monotonic() + timeout_s + last: dict = {} + while time.monotonic() < deadline: + last = device.cmd_ok(command, peer=peer_id) + if predicate(last): + return last + time.sleep(1) + raise MeshLabError( + f"[{device.alias}] {command} did not reach the expected state: {last}" + ) + + # Start from a known non-favorite relationship without clearing either app's data. + a.cmd_ok("favorite_set", peer=id_b, enabled=False) + b.cmd_ok("favorite_set", peer=id_a, enabled=False) + neutral_a = wait_for_status( + a, + "favorite_status", + id_b, + lambda state: not state["is_favorite"] and not state["they_favorited_us"], + ) + neutral_b = wait_for_status( + b, + "favorite_status", + id_a, + lambda state: not state["is_favorite"] and not state["they_favorited_us"], + ) + + # A favorites B. B must show the orange outline while its own favorite remains false. + a.cmd_ok("favorite_set", peer=id_b, enabled=True) + received_only = wait_for_status( + b, + "favorite_status", + id_a, + lambda state: ( + not state["is_favorite"] + and state["they_favorited_us"] + and state["star_state"] == "outlined_orange" + ), + ) + + # B favorites back. Both relationships become mutual and B's star becomes filled. + b.cmd_ok("favorite_set", peer=id_a, enabled=True) + mutual_b = wait_for_status( + b, + "favorite_status", + id_a, + lambda state: state["is_mutual"] and state["star_state"] == "filled", + ) + mutual_a = wait_for_status( + a, + "favorite_status", + id_b, + lambda state: state["is_mutual"] and state["star_state"] == "filled", + ) + + # The code B displays for A must be A's SHA-256 Noise identity fingerprint. + verification_before = b.cmd_ok("verification_status", peer=id_a) + if verification_before.get("fingerprint") != identity_a: + raise MeshLabError("displayed verification fingerprint does not match peer identity") + b.cmd_ok("verification_set", peer=id_a, enabled=True) + verification_after = wait_for_status( + b, + "verification_status", + id_a, + lambda state: state["verified"], + ) + + return { + "neutral": {"a": neutral_a, "b": neutral_b}, + "received_favorite": received_only, + "mutual": {"a": mutual_a, "b": mutual_b}, + "verification": { + "fingerprint_matches_peer_identity": True, + "before": verification_before, + "after": verification_after, + }, + } + + +def scenario_broadcast(a: Device, b: Device) -> dict: + """Public broadcast from A received by B.""" + id_a = whoami(a)["peer_id"] + token = f"bc-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "msg_recv", 60_000, contains=token) + time.sleep(2) + send = pool.submit(a.cmd_ok, "broadcast_msg", 30_000, content=f"broadcast {token}") + recv_result, send_result = recv.result(), send.result() + assert recv_result["from"] == id_a, recv_result + return {"send": send_result, "recv": recv_result} + + +def _ptt_one_way( + sender: Device, + receiver: Device, + sender_id: str, + receiver_id: str, + scope: str, +) -> dict: + # Long enough to expose sustained GATT/codec backpressure while remaining a quick gate. + send_args: dict[str, object] = {"duration_ms": 3_000} + if scope == "dm": + send_args["peer"] = receiver_id + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit( + receiver.cmd_ok, + "ptt_recv", + 240_000, + peer=sender_id, + scope="public" if scope == "public" else "dm", + ) + time.sleep(2) + send = pool.submit(sender.cmd_ok, "ptt_send", 240_000, **send_args) + recv_result, send_result = recv.result(), send.result() + if not recv_result.get("live_observed") or recv_result.get("frames", 0) <= 0: + raise MeshLabError(f"live {scope} burst was not assembled before fallback: {recv_result}") + if recv_result.get("burst_id") != send_result.get("burst_id"): + raise MeshLabError( + f"live {scope} burst/final-note identity mismatch: send={send_result} recv={recv_result}" + ) + encoded = int(send_result.get("encoded_frames", 0)) + queued = int(send_result.get("queued_pcm_frames", 0)) + sent_packets = int(send_result.get("data_packets", 0)) + received_packets = int(recv_result.get("data_packets", 0)) + expected_packets = int(recv_result.get("expected_packets", 0)) + missing_packets = int(recv_result.get("missing_packets", -1)) + received_frames = int(recv_result.get("frames", 0)) + if int(send_result.get("dropped_oversize_frames", -1)) != 0: + raise MeshLabError(f"live {scope} encoder produced an unsent AAC frame: {send_result}") + # AAC encoders may emit up to two priming access units in addition to one unit per PCM block. + if queued <= 0 or encoded < queued or encoded > queued + 2 or sent_packets != encoded: + raise MeshLabError(f"live {scope} encoder/packetizer continuity failed: {send_result}") + outbound_packets = int(send_result.get("outbound_packets", 0)) + delivered_packets = int(send_result.get("delivered_packets", -1)) + if outbound_packets != delivered_packets or outbound_packets != sent_packets + 2: + raise MeshLabError(f"live {scope} network dispatch did not drain in order: {send_result}") + minimum_realtime_pcm_frames = int(3_000 / 64) - 2 + if queued < minimum_realtime_pcm_frames: + raise MeshLabError(f"live {scope} capture fell behind real time: {send_result}") + if missing_packets != 0: + raise MeshLabError(f"live {scope} burst contained sequence gaps: {recv_result}") + if not (received_packets == expected_packets == sent_packets and received_frames == encoded): + raise MeshLabError( + f"live {scope} frame counts differ across the physical link: " + f"send={send_result} recv={recv_result}" + ) + decoded_samples = int(recv_result.get("decoded_samples", 0)) + if decoded_samples < max(1, received_frames - 2) * 1_024: + raise MeshLabError(f"live {scope} decoded PCM is truncated: {recv_result}") + rms = float(recv_result.get("rms", 0.0)) + silent_fraction = float(recv_result.get("silent_block_fraction", 1.0)) + longest_silent_run = int(recv_result.get("longest_silent_block_run", 999)) + crossings_per_second = float(recv_result.get("zero_crossings_per_second", 0.0)) + if rms < 0.05 or silent_fraction > 0.10 or longest_silent_run > 2: + raise MeshLabError(f"live {scope} decoded tone is silent or broken up: {recv_result}") + if not 650.0 <= crossings_per_second <= 1_150.0: + raise MeshLabError(f"live {scope} decoded tone continuity is distorted: {recv_result}") + return {"send": send_result, "recv": recv_result} + + +def scenario_ptt_dm(a: Device, b: Device) -> dict: + """Gap-free Noise-encrypted PTT tone plus finalized note in both directions.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b) + b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a) + return { + "a_to_b": _ptt_one_way(a, b, id_a, id_b, "dm"), + "b_to_a": _ptt_one_way(b, a, id_b, id_a, "dm"), + } + + +def scenario_ptt_broadcast(a: Device, b: Device) -> dict: + """Gap-free signed public PTT tone plus finalized note in both directions.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + return { + "a_to_b": _ptt_one_way(a, b, id_a, id_b, "public"), + "b_to_a": _ptt_one_way(b, a, id_b, id_a, "public"), + } + + +def scenario_file( + a: Device, + b: Device, + fixtures: dict[str, dict], + private: bool = False, + recipient: str | None = None, +) -> dict: + """File transfer A -> B with sha256 integrity verification.""" + id_b = whoami(b)["peer_id"] + b.clear_incoming() # avoid name-uniquified collisions across runs + results = {} + for name, fixture in fixtures.items(): + remote = a.push_fixture(fixture["path"]) + send_kwargs: dict[str, object] = {"path": remote} + if private: + send_kwargs["peer"] = recipient or id_b + if fixture.get("mime"): + send_kwargs["mime"] = fixture["mime"] + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name) + time.sleep(2) + send = pool.submit(a.cmd_ok, "file_send", 240_000, **send_kwargs) + recv_result, send_result = recv.result(), send.result() + digest_ok = recv_result["sha256"] == fixture["sha256"] + results[name] = { + "send": send_result, "recv": recv_result, + "expected_sha256": fixture["sha256"], "digest_match": digest_ok, + } + if not digest_ok: + raise MeshLabError( + f"file '{name}' digest mismatch: {recv_result['sha256']} != {fixture['sha256']}" + ) + return results + + +def scenario_private_media(a: Device, b: Device) -> dict: + """Voice, image, and generic file sends through the private-chat contact ID.""" + identity = whoami(b) + noise_public_key = bytes.fromhex(identity["noise_public_key"]) + conversation_id = f"contact_{hashlib.sha256(noise_public_key).hexdigest()}" + fixtures = make_private_media_fixtures( + Path(tempfile.mkdtemp(prefix="meshlab-private-media-")) + ) + return scenario_file( + a, + b, + fixtures, + private=True, + recipient=conversation_id, + ) + + +def scenario_raw(a: Device, b: Device) -> dict: + """Raw packet injection (unsigned announce-type packet) reaches the mesh.""" + payload = b"meshlab-raw-" + uuid.uuid4().hex[:8].encode() + result = a.cmd_ok("raw_send", 30_000, type="05", payload_hex=payload.hex()) + return {"send": result} + + +# MARK: - session / identity churn scenarios + +def _dm_roundtrip(a: Device, b: Device, id_a: str, id_b: str) -> dict: + """Exchange DMs in both directions with content assertions.""" + token_ab = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(b.cmd_ok, "dm_recv", 60_000, peer=id_a, contains=token_ab) + time.sleep(2) + send = pool.submit(a.cmd_ok, "dm_send", 30_000, peer=id_b, content=f"hello b {token_ab}") + recv_ab, send_ab = recv.result(), send.result() + assert token_ab in recv_ab["content"], recv_ab + + token_ba = f"dm-{uuid.uuid4().hex[:8]}" + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit(a.cmd_ok, "dm_recv", 60_000, peer=id_b, contains=token_ba) + time.sleep(2) + send = pool.submit(b.cmd_ok, "dm_send", 30_000, peer=id_a, content=f"hello a {token_ba}") + recv_ba, send_ba = recv.result(), send.result() + assert token_ba in recv_ba["content"], recv_ba + return {"a_to_b": recv_ab, "b_to_a": recv_ba} + + +def wait_session_established(device: Device, peer_id: str, timeout_s: int = 90) -> dict: + deadline = time.monotonic() + timeout_s + last: dict = {} + while time.monotonic() < deadline: + last = device.cmd_ok("session", peer=peer_id) + if last.get("established"): + return last + time.sleep(2) + raise MeshLabError( + f"[{device.alias}] session with {peer_id} not established within {timeout_s}s (last: {last})" + ) + + +def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None: + """Wait for rediscovery, then force a direct GATT connection both ways. + + Backgrounded devices drop to POWER_SAVER duty cycles (1 s scan per 60 s), so + passively waiting for the mesh to reform takes minutes. The explicit connect + makes restart scenarios deterministic. The address↔peer mapping is learned from + direct-link announces and can lag peer-list discovery after a restart, so the + connect attempt is retried while the peer announces. + """ + wait_for_peer(a, id_b, timeout_s=120) + wait_for_peer(b, id_a, timeout_s=120) + for device, peer, announcer in ((a, id_b, b), (b, id_a, a)): + connected = False + last: dict = {} + for _attempt in range(4): + last = device.cmd("connect", timeout_ms=45_000, peer=peer) + if last.get("status") == "ok" and last.get("direct"): + connected = True + break + # Already acceptable if the mesh formed a direct link on its own. + peers = device.cmd_ok("peers").get("peers", []) + match = next((p for p in peers if p.get("id") == peer), None) + if match and match.get("direct"): + connected = True + break + try: + announcer.cmd_ok("announce") + except MeshLabError: + pass + time.sleep(4) + if not connected: + raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}") + + +def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict: + """Retry explicit handshakes; inits can be lost while links settle.""" + last: dict = {} + for _ in range(attempts): + last = device.cmd("handshake", timeout_ms=per_attempt_s * 1000, peer=peer_id) + if last.get("status") == "ok": + return last + time.sleep(2) + raise MeshLabError(f"[{device.alias}] handshake with {peer_id} failed after {attempts} attempts (last: {last})") + + +def scenario_session_recovery(a: Device, b: Device) -> dict: + """Process death on B: identity must persist, in-memory Noise sessions are lost. + + Expected recovery flow: A's DM sent with its stale session is dropped by B + (B has no session and no kick path on pure decrypt failure); B's outgoing DM + auto-triggers a fresh handshake; subsequent DMs must flow both ways. + """ + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + a.cmd_ok("handshake", 60_000, peer=id_b) + b.cmd_ok("handshake", 60_000, peer=id_a) + baseline = _dm_roundtrip(a, b, id_a, id_b) + + b.force_stop() + b.wake() + b.launch() + b.cmd_ok("start") + b.cmd_ok("set_nickname", name="bob") + id_b_after = whoami(b)["peer_id"] + if id_b_after != id_b: + raise MeshLabError(f"identity changed across process death: {id_b} -> {id_b_after}") + + wait_for_peer(a, id_b, timeout_s=120) + wait_for_peer(b, id_a, timeout_s=120) + ensure_direct_link(a, b, id_a, id_b) + + # A -> B with A's stale session: B lost its in-memory session; drop expected. + a.cmd_ok("dm_send", 30_000, peer=id_b, content=f"stale-{uuid.uuid4().hex[:8]}") + # B -> A: no session on B, sendPrivateMessage auto-fires the re-handshake. + # The fire-and-forget handshake has no retry, so repeat the trigger, then + # fall back to explicit handshake commands if the auto-path stays stuck. + session_b: dict = {} + for _attempt in range(3): + b.cmd_ok("dm_send", 30_000, peer=id_a, content=f"trigger-{uuid.uuid4().hex[:8]}") + try: + session_b = wait_session_established(b, id_a, timeout_s=20) + break + except MeshLabError: + continue + if not session_b: + force_handshake(b, id_a) + session_b = wait_session_established(b, id_a, timeout_s=30) + + session_a = wait_session_established(a, id_b) + recovered = _dm_roundtrip(a, b, id_a, id_b) + return { + "identity_preserved": True, + "baseline": baseline, + "session_a": session_a, + "session_b": session_b, + "recovered": recovered, + } + + +def scenario_identity_reset(a: Device, b: Device) -> dict: + """pm clear on B mid-session: new identity, rediscovery, fresh handshake and DMs.""" + id_a = whoami(a)["peer_id"] + id_b_old = whoami(b)["peer_id"] + a.cmd_ok("handshake", 60_000, peer=id_b_old) + b.cmd_ok("handshake", 60_000, peer=id_a) + _dm_roundtrip(a, b, id_a, id_b_old) + + b.clear_app_data() + b.grant_permissions() + b.wake() + b.launch() + b.cmd_ok("start") + b.cmd_ok("set_nickname", name="bob") + id_b_new = whoami(b)["peer_id"] + if id_b_new == id_b_old: + raise MeshLabError("identity survived pm clear") + + wait_for_peer(a, id_b_new, timeout_s=180) + ensure_direct_link(a, b, id_a, id_b_new) + force_handshake(a, id_b_new) + force_handshake(b, id_a) + recovered = _dm_roundtrip(a, b, id_a, id_b_new) + + # Inspect how A treats the dead peer's stale session (evidence, not an assertion). + stale = a.cmd("session", peer=id_b_old) + return { + "old_peer_id": id_b_old, + "new_peer_id": id_b_new, + "identity_changed": True, + "recovered": recovered, + "stale_session_on_a": stale, + } + + +def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> dict: + """Oversized broadcast file must be rejected sender-side (>256 fragments).""" + fixture = fixtures["medium_512k.bin"] + remote = a.push_fixture(fixture["path"]) + send = a.cmd("file_send", timeout_ms=60_000, path=remote) + rejected = send.get("status") == "error" and "rejected" in send.get("error", "") + if not rejected: + raise MeshLabError(f"expected sender-side rejection, got: {send}") + # Receiver must not see any file appear. + recv = b.cmd("file_recv", timeout_ms=15_000, name_contains="medium_512k") + if recv.get("status") == "ok": + raise MeshLabError(f"receiver unexpectedly saved an oversized file: {recv}") + return {"send": send, "receiver_saw_file": False} + + +SCENARIOS = { + "dm": scenario_dm, + "favorite_verification": scenario_favorite_verification, + "broadcast": scenario_broadcast, + "ptt_dm": scenario_ptt_dm, + "ptt_broadcast": scenario_ptt_broadcast, + # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only + # the small fixture is end-to-end receivable. + "file": lambda a, b: scenario_file( + a, b, + make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]), + ), + "file_oversize": lambda a, b: scenario_file_oversize( + a, b, make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-"))) + ), + # Private media is hard-capped at 256 fragments (PrivateMediaTransfer), so only + # the small fixture fits; larger sizes are expected to be rejected by the sender. + "file_private": lambda a, b: scenario_file( + a, b, + make_fixtures(Path(tempfile.mkdtemp(prefix="meshlab-fixtures-")), names=["small_1k.bin"]), + private=True, + ), + "media_private": scenario_private_media, + "raw": scenario_raw, + "session_recovery": scenario_session_recovery, + "identity_reset": scenario_identity_reset, +} + +# Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends, +# the watch must receive with matching digests). +WATCH_SCENARIOS = [ + "dm", + "favorite_verification", + "broadcast", + "ptt_dm", + "ptt_broadcast", + "raw", + "file", + "file_private", + "session_recovery", + "identity_reset", +] + + +def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict: + started = time.time() + evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]} + try: + supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS) + if name == "all": + results = {} + failures = [] + for n in supported: + sub = run_scenario(n, a, b, out) + results[n] = sub.get("results", {"error": sub.get("error", "unknown")}) + if sub["status"] != "pass": + failures.append(n) + evidence["results"] = results + if failures: + raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}") + elif name not in supported: + raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'") + else: + evidence["results"] = SCENARIOS[name](a, b) + evidence["status"] = "pass" + except (MeshLabError, AssertionError) as error: + evidence["status"] = "fail" + evidence["error"] = str(error) + evidence["logcat"] = {d.alias: d.logcat_dump() for d in (a, b)} + evidence["duration_s"] = round(time.time() - started, 1) + if out is not None: + out.mkdir(parents=True, exist_ok=True) + (out / f"{name}-evidence.json").write_text(json.dumps(evidence, indent=2, default=str)) + return evidence + + +# MARK: - CLI + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + + setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover") + setup.add_argument("--serial-a", required=True) + setup.add_argument("--serial-b") + setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)") + setup.add_argument("--apk", type=Path, default=None) + setup.add_argument("--watch-apk", type=Path, default=None) + setup.add_argument("--nickname-a", default="alice") + setup.add_argument("--nickname-b", default="bob") + + scenario = commands.add_parser("scenario", help="run a test scenario on two devices") + scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"]) + scenario.add_argument("--serial-a", required=True) + scenario.add_argument("--serial-b") + scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)") + scenario.add_argument("--out", type=Path, default=None, help="evidence output directory") + + raw = commands.add_parser("cmd", help="send a raw test-hook command to one device") + raw.add_argument("--serial", required=True) + raw.add_argument("cmd") + raw.add_argument("--extra", action="append", default=[], help="key=value string extra (repeatable)") + raw.add_argument("--extra-int", action="append", default=[], help="key=value int extra (repeatable)") + raw.add_argument("--timeout-ms", type=int, default=60_000) + return parser + + +def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]: + """Device A is always the phone; device B is a watch when --serial-watch is given.""" + a = Device(args.serial_a, "alpha") + if getattr(args, "serial_watch", None): + return a, WatchDevice(args.serial_watch) + if not getattr(args, "serial_b", None): + raise MeshLabError("either --serial-b or --serial-watch is required") + return a, Device(args.serial_b, "beta") + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "setup": + a, b = _resolve_devices(args) + nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b + setup_pair( + a, b, args.apk, args.nickname_a, nickname_b, + apk_b=args.watch_apk if isinstance(b, WatchDevice) else None, + ) + print(json.dumps({"status": "ok", "step": "setup"})) + elif args.command == "scenario": + a, b = _resolve_devices(args) + evidence = run_scenario(args.name, a, b, args.out) + print(json.dumps(evidence, indent=2, default=str)) + return 0 if evidence["status"] == "pass" else 1 + elif args.command == "cmd": + extras: dict[str, object] = {} + extras: dict[str, object] = {} + for item in args.extra: + key, _, value = item.partition("=") + extras[key] = value + for item in args.extra_int: + key, _, value = item.partition("=") + extras[key] = int(value) + result = Device(args.serial, "device").cmd(args.cmd, timeout_ms=args.timeout_ms, **extras) + print(json.dumps(result, indent=2, default=str)) + return 0 if result.get("status") == "ok" else 1 + return 0 + except MeshLabError as error: + print(f"mesh lab error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release_gate/release_gate.py b/tools/release_gate/release_gate.py new file mode 100644 index 00000000..fbec7e32 --- /dev/null +++ b/tools/release_gate/release_gate.py @@ -0,0 +1,744 @@ +#!/usr/bin/env python3 +"""Create, record, validate, and archive the physical release gate. + +The host-side CLI is the control channel. It coordinates operators over USB or +local files and never sends control traffic through the mesh under test. +Artifacts intentionally contain logical device aliases and aggregate evidence, +not device identifiers, addresses, peer IDs, or message contents. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import sys +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + + +SCHEMA_VERSION = 1 +RESULT_STATUSES = {"pending", "pass", "fail", "blocked", "unsupported"} +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +DISALLOWED_KEYS = { + "serial", + "serial_number", + "udid", + "imei", + "bluetooth_address", + "mac_address", + "ip_address", + "peer_id", + "username", + "user_name", + "email", + "account", + "device_name", +} +HASH_VALUE_KEYS = { + "commit", + "sha256", + "digest", + "scenario_manifest_sha256", + "fixture_sha256", + "vector_manifest_digest", + "corpus_digest", +} +SENSITIVE_PATTERNS = ( + re.compile(r"(?:^|[\s/])Users/[^/\s]+"), + re.compile(r"(?:^|[\s/])home/[^/\s]+"), + re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"), + re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), + re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b"), + re.compile(r"\b[0-9A-Fa-f]{16,}\b"), +) +MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 + + +class GateError(ValueError): + """A release-gate artifact violated an executable contract.""" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise GateError(f"could not read JSON {path.name}: {error}") from error + if not isinstance(value, dict): + raise GateError(f"{path.name} must contain a JSON object") + return value + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def canonical_json_digest(value: dict[str, Any]) -> str: + encoded = json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + return sha256_bytes(encoded) + + +def validate_privacy(value: Any, path: tuple[str, ...] = ()) -> None: + if isinstance(value, dict): + for key, child in value.items(): + normalized = str(key).lower() + if normalized in DISALLOWED_KEYS: + raise GateError(f"disallowed identifying field: {'.'.join(path + (str(key),))}") + validate_privacy(child, path + (str(key),)) + return + if isinstance(value, list): + for index, child in enumerate(value): + validate_privacy(child, path + (str(index),)) + return + if not isinstance(value, str): + return + final_key = path[-1].lower().replace("-", "_") if path else "" + for index, pattern in enumerate(SENSITIVE_PATTERNS): + if index == len(SENSITIVE_PATTERNS) - 1 and ( + final_key in HASH_VALUE_KEYS + or final_key.endswith("_digest") + or final_key.endswith("_sha256") + ): + continue + if pattern.search(value): + raise GateError(f"potential identifying value at {'.'.join(path)}") + + +def validate_manifest(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + if manifest.get("schema_version") != SCHEMA_VERSION: + raise GateError("unsupported scenario schema_version") + scenarios = manifest.get("scenarios") + if not isinstance(scenarios, list) or not scenarios: + raise GateError("scenario manifest must contain scenarios") + by_id: dict[str, dict[str, Any]] = {} + for scenario in scenarios: + if not isinstance(scenario, dict): + raise GateError("each scenario must be an object") + scenario_id = scenario.get("id") + if not isinstance(scenario_id, str) or not re.fullmatch( + r"[A-Z0-9]{3}-\d{3}", scenario_id + ): + raise GateError(f"invalid scenario id: {scenario_id!r}") + if scenario_id in by_id: + raise GateError(f"duplicate scenario id: {scenario_id}") + if scenario.get("category") not in { + "transport", + "android-platform", + "android-to-android", + "cross-client", + "endurance", + }: + raise GateError(f"invalid category for {scenario_id}") + if scenario.get("required") is not True: + raise GateError(f"release scenario {scenario_id} must be required") + if not scenario.get("participants") or not scenario.get("evidence"): + raise GateError(f"scenario {scenario_id} lacks participants or evidence") + by_id[scenario_id] = scenario + validate_privacy(manifest) + return by_id + + +def validate_matrix( + matrix: dict[str, Any], + expected_commit: str | None = None, + *, + allow_placeholders: bool = False, +) -> dict[str, dict[str, Any]]: + if matrix.get("schema_version") != SCHEMA_VERSION: + raise GateError("unsupported matrix schema_version") + commit = matrix.get("commit") + if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit): + raise GateError("matrix commit must be a lowercase full Git commit") + if expected_commit is not None and commit != expected_commit: + raise GateError("matrix commit does not match the release candidate") + clients = matrix.get("clients") + required_clients = {"android-current", "android-legacy", "ios-current"} + if not isinstance(clients, dict) or not required_clients.issubset(clients): + raise GateError("matrix must version current Android, legacy Android, and current iOS") + for client_name in required_clients: + client = clients.get(client_name) + if not isinstance(client, dict) or not isinstance(client.get("version"), str): + raise GateError(f"{client_name} must declare a version") + if not allow_placeholders and client["version"].startswith("replace-with-"): + raise GateError(f"{client_name} still contains a template version") + current_commit = clients["android-current"].get("commit") + if current_commit != commit and not (allow_placeholders and commit == "0" * 40): + raise GateError("current Android client commit must match the matrix commit") + lab_capabilities = matrix.get("lab_capabilities") + if not isinstance(lab_capabilities, list) or any( + not isinstance(capability, str) or not SAFE_ID_RE.fullmatch(capability) + for capability in lab_capabilities + ): + raise GateError("matrix must declare logical lab_capabilities") + devices = matrix.get("devices") + if not isinstance(devices, list): + raise GateError("matrix devices must be a list") + by_alias: dict[str, dict[str, Any]] = {} + for device in devices: + if not isinstance(device, dict): + raise GateError("each device must be an object") + alias = device.get("alias") + if not isinstance(alias, str) or not SAFE_ID_RE.fullmatch(alias): + raise GateError(f"invalid logical device alias: {alias!r}") + if alias in by_alias: + raise GateError(f"duplicate device alias: {alias}") + if device.get("physical") is not True: + raise GateError(f"{alias} is not a physical device") + if device.get("platform") not in {"android", "ios"}: + raise GateError(f"{alias} has an unsupported platform") + if not isinstance(device.get("capabilities"), list): + raise GateError(f"{alias} must declare capabilities") + if not allow_placeholders and ( + not isinstance(device.get("model"), str) + or device["model"].startswith("replace-with-") + ): + raise GateError(f"{alias} still contains template values") + by_alias[alias] = device + android = [device for device in devices if device.get("platform") == "android"] + if len(android) < 3: + raise GateError("three physical Android devices are required for relay scenarios") + api_levels = {device.get("api_level") for device in android} + manufacturers = {device.get("manufacturer_class") for device in android} + if len(api_levels) < 2 or not all(isinstance(level, int) for level in api_levels): + raise GateError("Android matrix must cover at least two API levels") + if len(manufacturers) < 2 or None in manufacturers: + raise GateError("Android matrix must cover at least two manufacturer classes") + for device in android: + capabilities = set(device["capabilities"]) + if not {"ble-central", "ble-peripheral"}.issubset(capabilities): + raise GateError(f"{device['alias']} lacks required BLE roles") + if not any("wifi-aware" in device["capabilities"] for device in android): + raise GateError("at least one Android device must support Wi-Fi Aware") + if not any(device.get("platform") == "ios" for device in devices): + raise GateError("a physical iOS device is required") + validate_privacy(matrix) + return by_alias + + +def _fixture_bytes(seed: bytes, size: int) -> bytes: + output = bytearray() + counter = 0 + while len(output) < size: + output.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()) + counter += 1 + return bytes(output[:size]) + + +def create_fixtures(directory: Path, run_id: str) -> dict[str, Any]: + fixture_directory = directory / "fixtures" + fixture_directory.mkdir() + definitions = ( + ("empty.bin", 0, False), + ("small.bin", 4 * 1024, False), + ("lab-résumé-秘密.bin", 256 * 1024, False), + ("maximum.bin", MAX_FILE_SIZE_BYTES, True), + ("oversized.bin", MAX_FILE_SIZE_BYTES + 1, True), + ) + fixtures: list[dict[str, Any]] = [] + for name, size, sparse in definitions: + path = fixture_directory / name + if sparse: + with path.open("wb") as stream: + stream.truncate(size) + else: + path.write_bytes(_fixture_bytes(run_id.encode("utf-8"), size)) + fixtures.append( + { + "name": name, + "size_bytes": size, + "sparse": sparse, + "fixture_sha256": sha256_file(path), + } + ) + manifest = {"schema_version": SCHEMA_VERSION, "fixtures": fixtures} + write_json(fixture_directory / "manifest.json", manifest) + return manifest + + +def initialize_run( + manifest: dict[str, Any], + matrix: dict[str, Any], + output: Path, + commit: str, + run_id: str, + *, + started_at: str | None = None, +) -> dict[str, Any]: + scenarios = validate_manifest(manifest) + devices = validate_matrix(matrix, commit) + if not SAFE_ID_RE.fullmatch(run_id): + raise GateError("run id must be a non-identifying lowercase logical id") + missing_aliases = { + participant + for scenario in scenarios.values() + for participant in scenario["participants"] + if participant not in devices + } + if missing_aliases: + raise GateError(f"matrix lacks scenario aliases: {sorted(missing_aliases)}") + for scenario_id, scenario in scenarios.items(): + available = set(matrix["lab_capabilities"]) + for participant in scenario["participants"]: + available.update(devices[participant]["capabilities"]) + missing_capabilities = set(scenario["capabilities"]) - available + if missing_capabilities: + raise GateError( + f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}" + ) + output.mkdir(parents=True, exist_ok=False) + write_json(output / "manifest.json", manifest) + write_json(output / "matrix.json", matrix) + fixtures = create_fixtures(output, run_id) + results = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "commit": commit, + "started_at": started_at or utc_now(), + "completed_at": None, + "scenario_manifest_sha256": canonical_json_digest(manifest), + "fixture_manifest_sha256": canonical_json_digest(fixtures), + "scenario_results": { + scenario_id: { + "status": "pending", + "participants": scenario["participants"], + "evidence": {}, + "reason_code": None, + "updated_at": None, + "history": [], + } + for scenario_id, scenario in scenarios.items() + }, + } + write_json(output / "results.json", results) + (output / "trace.jsonl").write_text("", encoding="utf-8") + return results + + +def _parse_scalar(value: str) -> Any: + lowered = value.lower() + if lowered in {"true", "false"}: + return lowered == "true" + try: + return int(value) + except ValueError: + try: + return float(value) + except ValueError: + return value + + +def parse_evidence(values: Iterable[str]) -> dict[str, Any]: + evidence: dict[str, Any] = {} + for value in values: + if "=" not in value: + raise GateError("evidence must use key=value") + key, raw = value.split("=", 1) + if not SAFE_ID_RE.fullmatch(key): + raise GateError(f"invalid evidence key: {key!r}") + evidence[key] = _parse_scalar(raw) + validate_privacy(evidence) + return evidence + + +def record_result( + run_directory: Path, + scenario_id: str, + status: str, + evidence: dict[str, Any], + reason_code: str | None, + *, + updated_at: str | None = None, +) -> dict[str, Any]: + manifest = load_json(run_directory / "manifest.json") + scenarios = validate_manifest(manifest) + if scenario_id not in scenarios: + raise GateError(f"unknown scenario: {scenario_id}") + if status not in RESULT_STATUSES - {"pending"}: + raise GateError(f"invalid terminal status: {status}") + if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code): + raise GateError("reason code must be a non-identifying stable code") + if status == "pass" and not evidence: + raise GateError("passing a scenario requires structured evidence") + if status == "pass" and reason_code is not None: + raise GateError("passing a scenario cannot have a failure reason code") + if status in {"fail", "blocked", "unsupported"} and reason_code is None: + raise GateError(f"{status} requires a stable reason code") + if any(not SAFE_ID_RE.fullmatch(str(key)) for key in evidence): + raise GateError("evidence keys must be stable logical identifiers") + validate_privacy(evidence) + results = load_json(run_directory / "results.json") + result = results["scenario_results"][scenario_id] + terminal_update = { + "status": status, + "evidence": evidence, + "reason_code": reason_code, + "updated_at": updated_at or utc_now(), + } + result.setdefault("history", []).append(terminal_update.copy()) + result.update( + terminal_update + ) + results["completed_at"] = ( + result["updated_at"] + if all( + item.get("status") == "pass" + for item in results["scenario_results"].values() + ) + else None + ) + write_json(run_directory / "results.json", results) + return results + + +def append_trace_event( + run_directory: Path, + scenario_id: str, + source_alias: str, + event: str, + outcome: str, + reason_code: str | None, + metrics: dict[str, Any], + *, + timestamp: str | None = None, +) -> dict[str, Any]: + manifest = load_json(run_directory / "manifest.json") + matrix = load_json(run_directory / "matrix.json") + scenarios = validate_manifest(manifest) + devices = validate_matrix(matrix, allow_placeholders=False) + if scenario_id not in scenarios: + raise GateError(f"unknown scenario: {scenario_id}") + if source_alias not in devices: + raise GateError(f"unknown source alias: {source_alias}") + for label, value in (("event", event), ("outcome", outcome)): + if not SAFE_ID_RE.fullmatch(value): + raise GateError(f"invalid {label}") + if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code): + raise GateError("invalid reason code") + if any(not SAFE_ID_RE.fullmatch(str(key)) for key in metrics): + raise GateError("trace metric keys must be stable logical identifiers") + if any( + not isinstance(value, (int, float, bool)) + or isinstance(value, float) and not math.isfinite(value) + for value in metrics.values() + ): + raise GateError("trace metrics must be numeric or boolean aggregates") + trace = { + "timestamp": timestamp or utc_now(), + "scenario_id": scenario_id, + "source_alias": source_alias, + "event": event, + "outcome": outcome, + "reason_code": reason_code, + "metrics": metrics, + } + validate_privacy(trace) + with (run_directory / "trace.jsonl").open("a", encoding="utf-8") as stream: + stream.write(json.dumps(trace, sort_keys=True, ensure_ascii=False) + "\n") + return trace + + +def _validate_trace( + run_directory: Path, + scenarios: dict[str, dict[str, Any]], + devices: dict[str, dict[str, Any]], +) -> tuple[int, set[str]]: + path = run_directory / "trace.jsonl" + if not path.exists(): + raise GateError("trace.jsonl is missing") + count = 0 + traced_scenarios: set[str] = set() + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + try: + event = json.loads(line) + except json.JSONDecodeError as error: + raise GateError(f"invalid trace line {line_number}") from error + if not isinstance(event, dict): + raise GateError(f"trace line {line_number} must be an object") + if event.get("scenario_id") not in scenarios: + raise GateError(f"trace line {line_number} has an unknown scenario") + if event.get("source_alias") not in devices: + raise GateError(f"trace line {line_number} has an unknown source") + if set(event) != { + "timestamp", + "scenario_id", + "source_alias", + "event", + "outcome", + "reason_code", + "metrics", + }: + raise GateError(f"trace line {line_number} has unexpected fields") + if not isinstance(event.get("metrics"), dict) or any( + not isinstance(value, (int, float, bool)) + for value in event["metrics"].values() + ): + raise GateError(f"trace line {line_number} has invalid metrics") + validate_privacy(event) + count += 1 + traced_scenarios.add(event["scenario_id"]) + return count, traced_scenarios + + +def validate_run(run_directory: Path, *, allow_incomplete: bool = False) -> dict[str, Any]: + manifest = load_json(run_directory / "manifest.json") + matrix = load_json(run_directory / "matrix.json") + results = load_json(run_directory / "results.json") + scenarios = validate_manifest(manifest) + devices = validate_matrix(matrix, results.get("commit")) + if results.get("schema_version") != SCHEMA_VERSION: + raise GateError("unsupported results schema_version") + if results.get("scenario_manifest_sha256") != canonical_json_digest(manifest): + raise GateError("scenario manifest changed after run initialization") + fixture_manifest = load_json(run_directory / "fixtures" / "manifest.json") + if results.get("fixture_manifest_sha256") != canonical_json_digest(fixture_manifest): + raise GateError("fixture manifest changed after run initialization") + actual = results.get("scenario_results") + if not isinstance(actual, dict) or set(actual) != set(scenarios): + raise GateError("results must contain exactly every declared scenario") + validate_privacy(results) + for scenario_id, scenario in scenarios.items(): + result = actual[scenario_id] + if result.get("participants") != scenario["participants"]: + raise GateError(f"{scenario_id} participants changed") + if any(alias not in devices for alias in result["participants"]): + raise GateError(f"{scenario_id} references an unknown device") + available = set(matrix["lab_capabilities"]) + for participant in result["participants"]: + available.update(devices[participant]["capabilities"]) + missing_capabilities = set(scenario["capabilities"]) - available + if missing_capabilities: + raise GateError( + f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}" + ) + status = result.get("status") + if status not in RESULT_STATUSES: + raise GateError(f"{scenario_id} has invalid status") + if not allow_incomplete and status != "pass": + raise GateError(f"{scenario_id} is not passing: {status}") + if status == "pass": + evidence = result.get("evidence") + missing = set(scenario["evidence"]) - set(evidence or {}) + if missing: + raise GateError(f"{scenario_id} lacks evidence: {sorted(missing)}") + if scenario.get("minimum_duration_minutes") is not None and ( + evidence.get("duration-minutes", 0) + < scenario["minimum_duration_minutes"] + ): + raise GateError(f"{scenario_id} did not meet minimum duration") + if scenario.get("minimum_cycles") is not None and ( + evidence.get("cycle-count", 0) < scenario["minimum_cycles"] + ): + raise GateError(f"{scenario_id} did not meet minimum cycles") + trace_events, traced_scenarios = _validate_trace(run_directory, scenarios, devices) + if not allow_incomplete: + missing_traces = set(scenarios) - traced_scenarios + if missing_traces: + raise GateError( + f"passing scenarios lack structured traces: {sorted(missing_traces)}" + ) + if not results.get("completed_at"): + raise GateError("complete results must record completed_at") + summary = { + status: sum( + 1 for result in actual.values() if result.get("status") == status + ) + for status in sorted(RESULT_STATUSES) + } + summary["trace_events"] = trace_events + summary["complete"] = all( + result.get("status") == "pass" for result in actual.values() + ) + return summary + + +def render_summary(run_directory: Path) -> str: + results = load_json(run_directory / "results.json") + summary = validate_run(run_directory, allow_incomplete=True) + rows = [ + "# Physical release-gate result", + "", + f"- Run: `{results['run_id']}`", + f"- Commit: `{results['commit']}`", + f"- Complete: `{str(summary['complete']).lower()}`", + f"- Trace events: {summary['trace_events']}", + "", + "| Status | Count |", + "|---|---:|", + ] + rows.extend( + f"| {status} | {summary[status]} |" for status in sorted(RESULT_STATUSES) + ) + return "\n".join(rows) + "\n" + + +def create_bundle(run_directory: Path, output: Path) -> None: + validate_run(run_directory) + if output.exists(): + raise GateError("refusing to overwrite an existing release-gate bundle") + members = [ + Path("manifest.json"), + Path("matrix.json"), + Path("results.json"), + Path("trace.jsonl"), + Path("fixtures/manifest.json"), + ] + generated = {"summary.md": render_summary(run_directory).encode("utf-8")} + checksums: list[str] = [] + payloads: dict[str, bytes] = {} + for member in members: + payload = (run_directory / member).read_bytes() + payloads[member.as_posix()] = payload + payloads.update(generated) + for name in sorted(payloads): + checksums.append(f"{sha256_bytes(payloads[name])} {name}") + payloads["SHA256SUMS"] = ("\n".join(checksums) + "\n").encode("utf-8") + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name in sorted(payloads): + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, payloads[name]) + + +def _default_manifest() -> Path: + return Path(__file__).with_name("scenarios.json") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + manifest = subparsers.add_parser("validate-manifest") + manifest.add_argument("--manifest", type=Path, default=_default_manifest()) + + matrix = subparsers.add_parser("validate-matrix") + matrix.add_argument("--matrix", type=Path, required=True) + matrix.add_argument("--commit") + matrix.add_argument("--allow-template", action="store_true") + + initialize = subparsers.add_parser("init") + initialize.add_argument("--manifest", type=Path, default=_default_manifest()) + initialize.add_argument("--matrix", type=Path, required=True) + initialize.add_argument("--output", type=Path, required=True) + initialize.add_argument("--commit", required=True) + initialize.add_argument("--run-id", required=True) + + record = subparsers.add_parser("record") + record.add_argument("--run", type=Path, required=True) + record.add_argument("--scenario", required=True) + record.add_argument("--status", choices=sorted(RESULT_STATUSES - {"pending"}), required=True) + record.add_argument("--evidence", action="append", default=[]) + record.add_argument("--reason-code") + + trace = subparsers.add_parser("trace") + trace.add_argument("--run", type=Path, required=True) + trace.add_argument("--scenario", required=True) + trace.add_argument("--source", required=True) + trace.add_argument("--event", required=True) + trace.add_argument("--outcome", required=True) + trace.add_argument("--reason-code") + trace.add_argument("--metric", action="append", default=[]) + + validate = subparsers.add_parser("validate") + validate.add_argument("--run", type=Path, required=True) + validate.add_argument("--allow-incomplete", action="store_true") + + summary = subparsers.add_parser("summary") + summary.add_argument("--run", type=Path, required=True) + + bundle = subparsers.add_parser("bundle") + bundle.add_argument("--run", type=Path, required=True) + bundle.add_argument("--output", type=Path, required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "validate-manifest": + scenarios = validate_manifest(load_json(args.manifest)) + print(f"valid scenarios: {len(scenarios)}") + elif args.command == "validate-matrix": + devices = validate_matrix( + load_json(args.matrix), + args.commit, + allow_placeholders=args.allow_template, + ) + print(f"valid devices: {len(devices)}") + elif args.command == "init": + initialize_run( + load_json(args.manifest), + load_json(args.matrix), + args.output, + args.commit, + args.run_id, + ) + print(args.output) + elif args.command == "record": + record_result( + args.run, + args.scenario, + args.status, + parse_evidence(args.evidence), + args.reason_code, + ) + elif args.command == "trace": + append_trace_event( + args.run, + args.scenario, + args.source, + args.event, + args.outcome, + args.reason_code, + parse_evidence(args.metric), + ) + elif args.command == "validate": + print( + json.dumps( + validate_run(args.run, allow_incomplete=args.allow_incomplete), + sort_keys=True, + ) + ) + elif args.command == "summary": + print(render_summary(args.run), end="") + elif args.command == "bundle": + create_bundle(args.run, args.output) + print(args.output) + return 0 + except GateError as error: + print(f"release gate error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release_gate/scenarios.json b/tools/release_gate/scenarios.json new file mode 100644 index 00000000..d9b50907 --- /dev/null +++ b/tools/release_gate/scenarios.json @@ -0,0 +1,251 @@ +{ + "schema_version": 1, + "scenario_version": "1.0", + "scenarios": [ + { + "id": "TRN-001", + "category": "transport", + "title": "Complete the physical BLE, GATT, MTU, Wi-Fi Aware, and failover matrix", + "participants": ["android-low", "android-current", "android-aware"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + "evidence": ["matrix-check-count", "mtu-case-count", "failure-injection-count", "shutdown-check-count"] + }, + { + "id": "AND-001", + "category": "android-platform", + "title": "Complete API-level, manufacturer, permission-revocation, and background gates", + "participants": ["android-low", "android-current", "android-aware"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + "evidence": ["api-level-count", "manufacturer-count", "permission-revocation-count", "background-case-count"] + }, + { + "id": "A2A-001", + "category": "android-to-android", + "title": "Discover, connect, announce, disconnect, and reconnect", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["connection-transitions", "packet-correlation-count", "failure-reasons"] + }, + { + "id": "A2A-002", + "category": "android-to-android", + "title": "Exchange public and private messages in both directions", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["message-counts", "packet-correlation-count"] + }, + { + "id": "A2A-003", + "category": "android-to-android", + "title": "Advance delivery and read receipts", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["receipt-transitions"] + }, + { + "id": "A2A-004", + "category": "android-to-android", + "title": "Transfer image, audio, and generic files at multiple sizes", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["fixture-digests", "transfer-progress", "delivery-digests"] + }, + { + "id": "A2A-005", + "category": "android-to-android", + "title": "Relay across three Android devices", + "participants": ["android-low", "android-current", "android-relay"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["route-transitions", "ttl-values", "packet-correlation-count"] + }, + { + "id": "A2A-006", + "category": "android-to-android", + "title": "Partition and recover store-and-forward delivery", + "participants": ["android-low", "android-current", "android-relay"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["partition-window", "queue-counts", "delivery-counts"] + }, + { + "id": "A2A-007", + "category": "android-to-android", + "title": "Toggle Bluetooth during active transfers", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["radio-transitions", "transfer-terminal-state"] + }, + { + "id": "A2A-008", + "category": "android-to-android", + "title": "Lock screens and background both apps during mesh operation", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["lifecycle-transitions", "service-state", "delivery-counts"] + }, + { + "id": "A2A-009", + "category": "android-to-android", + "title": "Kill and recreate one process", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["process-generation", "restored-state", "reconnect-count"] + }, + { + "id": "A2A-010", + "category": "android-to-android", + "title": "Resolve simultaneous and duplicate links", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["candidate-links", "canonical-link-count"] + }, + { + "id": "A2A-011", + "category": "android-to-android", + "title": "Fail over through Wi-Fi Aware", + "participants": ["android-current", "android-aware"], + "required": true, + "capabilities": ["wifi-aware"], + "evidence": ["transport-selection", "failover-window", "delivery-counts"] + }, + { + "id": "XCL-001", + "category": "cross-client", + "title": "Current Android interoperates with current iOS", + "participants": ["android-current", "ios-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["client-versions", "payload-counts"] + }, + { + "id": "XCL-002", + "category": "cross-client", + "title": "Current rewrite contracts interoperate with the last supported Android", + "participants": ["android-current", "android-legacy"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["client-versions", "payload-counts"] + }, + { + "id": "XCL-003", + "category": "cross-client", + "title": "Legacy announcements without capabilities remain compatible", + "participants": ["android-current", "android-legacy"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["announcement-version", "peer-state"] + }, + { + "id": "XCL-004", + "category": "cross-client", + "title": "Older clients safely ignore current capability announcements", + "participants": ["android-current", "android-legacy"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["announcement-version", "peer-state"] + }, + { + "id": "XCL-005", + "category": "cross-client", + "title": "Canonical private media and prerelease decode-only alias interoperate", + "participants": ["android-current", "ios-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["wire-type", "delivery-digests", "downgrade-decision"] + }, + { + "id": "XCL-006", + "category": "cross-client", + "title": "Golden vectors match across implementations", + "participants": ["android-current", "ios-current", "android-legacy"], + "required": true, + "capabilities": [], + "evidence": ["vector-manifest-digest", "comparison-counts"] + }, + { + "id": "XCL-007", + "category": "cross-client", + "title": "Malformed and unauthenticated inputs are rejected consistently", + "participants": ["android-current", "ios-current", "android-legacy"], + "required": true, + "capabilities": [], + "evidence": ["corpus-digest", "rejection-counts", "crash-count"] + }, + { + "id": "XCL-008", + "category": "cross-client", + "title": "Nostr fallback messages and receipts interoperate", + "participants": ["android-current", "ios-current"], + "required": true, + "capabilities": ["local-relay", "tor"], + "evidence": ["relay-fixture", "event-id-count", "receipt-counts"] + }, + { + "id": "END-001", + "category": "endurance", + "title": "Multi-hour discovery, connect, and disconnect soak", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "minimum_duration_minutes": 240, + "evidence": ["duration-minutes", "connection-counts", "failure-counts"] + }, + { + "id": "END-002", + "category": "endurance", + "title": "Repeated large-transfer and cancellation cycles", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "minimum_cycles": 50, + "evidence": ["cycle-count", "delivery-counts", "cancellation-counts"] + }, + { + "id": "END-003", + "category": "endurance", + "title": "Resource growth remains bounded", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["memory-samples", "thread-samples", "fd-samples", "wakelock-samples", "battery-samples"] + }, + { + "id": "END-004", + "category": "endurance", + "title": "Foreground service survives with screens off", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["service-state", "screen-state", "delivery-counts"] + }, + { + "id": "END-005", + "category": "endurance", + "title": "Nostr survives network and Tor availability changes", + "participants": ["android-current"], + "required": true, + "capabilities": ["local-relay", "tor"], + "evidence": ["network-transitions", "tor-transitions", "receipt-counts"] + }, + { + "id": "END-006", + "category": "endurance", + "title": "Shutdown releases radios, sockets, jobs, and wake locks", + "participants": ["android-low", "android-current"], + "required": true, + "capabilities": ["ble-central", "ble-peripheral"], + "evidence": ["resource-terminal-state", "late-callback-count"] + } + ] +} diff --git a/tools/release_gate/test_release_gate.py b/tools/release_gate/test_release_gate.py new file mode 100644 index 00000000..3ca39ac8 --- /dev/null +++ b/tools/release_gate/test_release_gate.py @@ -0,0 +1,367 @@ +import json +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +from tools.release_gate import android_lab +from tools.release_gate.release_gate import ( + GateError, + append_trace_event, + canonical_json_digest, + create_bundle, + initialize_run, + load_json, + parse_evidence, + record_result, + validate_manifest, + validate_matrix, + validate_privacy, + validate_run, +) + + +COMMIT = "1" * 40 +TOOL_DIRECTORY = Path(__file__).parent + + +def valid_matrix(): + return { + "schema_version": 1, + "commit": COMMIT, + "clients": { + "android-current": {"version": "2.0.0-rc1", "commit": COMMIT}, + "android-legacy": {"version": "1.9.0"}, + "ios-current": {"version": "2.0.0"}, + }, + "lab_capabilities": ["local-relay", "tor"], + "devices": [ + { + "alias": "android-low", + "platform": "android", + "model": "model-low", + "manufacturer_class": "vendor-a", + "api_level": 28, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + { + "alias": "android-current", + "platform": "android", + "model": "model-current", + "manufacturer_class": "vendor-b", + "api_level": 35, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + }, + { + "alias": "android-relay", + "platform": "android", + "model": "model-relay", + "manufacturer_class": "vendor-c", + "api_level": 33, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + { + "alias": "android-aware", + "platform": "android", + "model": "model-aware", + "manufacturer_class": "vendor-b", + "api_level": 35, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"], + }, + { + "alias": "android-legacy", + "platform": "android", + "model": "model-legacy", + "manufacturer_class": "vendor-a", + "api_level": 28, + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + { + "alias": "ios-current", + "platform": "ios", + "model": "ios-model", + "manufacturer_class": "apple", + "physical": True, + "capabilities": ["ble-central", "ble-peripheral"], + }, + ], + } + + +class ReleaseGateTest(unittest.TestCase): + def setUp(self): + self.manifest = load_json(TOOL_DIRECTORY / "scenarios.json") + + def test_manifest_covers_every_release_category(self): + scenarios = validate_manifest(self.manifest) + self.assertEqual(27, len(scenarios)) + self.assertEqual( + { + "transport", + "android-platform", + "android-to-android", + "cross-client", + "endurance", + }, + {scenario["category"] for scenario in scenarios.values()}, + ) + + def test_documented_matrix_template_is_schema_valid_but_not_runnable(self): + template = load_json(TOOL_DIRECTORY / "device-matrix.example.json") + self.assertEqual(6, len(validate_matrix(template, allow_placeholders=True))) + with self.assertRaises(GateError): + validate_matrix(template) + + def test_matrix_enforces_distinct_api_manufacturer_and_counterpart_clients(self): + matrix = valid_matrix() + self.assertEqual(6, len(validate_matrix(matrix, COMMIT))) + + for device in matrix["devices"]: + if device["platform"] == "android": + device["manufacturer_class"] = "one-vendor" + with self.assertRaisesRegex(GateError, "manufacturer"): + validate_matrix(matrix, COMMIT) + + def test_privacy_policy_rejects_identifiers_paths_addresses_and_long_ids(self): + rejected = ( + {"serial": "device-selector"}, + {"note": "/" + "home/operator/result"}, + {"note": "operator@example.test"}, + {"note": "192.0.2.1"}, + {"note": "aa:bb:cc:dd:ee:ff"}, + {"note": "0123456789abcdef"}, + ) + for value in rejected: + with self.subTest(value=value), self.assertRaises(GateError): + validate_privacy(value) + validate_privacy({"commit": COMMIT, "packet-correlation-count": 3}) + + def test_initialize_creates_disposable_fixtures_and_pending_results(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + results = initialize_run( + self.manifest, + valid_matrix(), + run, + COMMIT, + "rc-run", + started_at="2026-01-01T00:00:00+00:00", + ) + self.assertTrue(all( + result["status"] == "pending" + for result in results["scenario_results"].values() + )) + fixtures = load_json(run / "fixtures" / "manifest.json")["fixtures"] + sizes = {fixture["name"]: fixture["size_bytes"] for fixture in fixtures} + self.assertEqual(0, sizes["empty.bin"]) + self.assertEqual(50 * 1024 * 1024, sizes["maximum.bin"]) + self.assertEqual(50 * 1024 * 1024 + 1, sizes["oversized.bin"]) + self.assertEqual( + results["scenario_manifest_sha256"], + canonical_json_digest(self.manifest), + ) + summary = validate_run(run, allow_incomplete=True) + self.assertEqual(27, summary["pending"]) + self.assertFalse(summary["complete"]) + + def test_record_requires_structured_evidence_and_rejects_pii(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + with self.assertRaises(GateError): + record_result(run, "A2A-001", "pass", {}, None) + with self.assertRaises(GateError): + record_result( + run, + "A2A-001", + "pass", + {"connection-transitions": "operator@example.test"}, + None, + ) + with self.assertRaisesRegex(GateError, "reason code"): + record_result(run, "A2A-001", "fail", {}, None) + recorded = record_result( + run, + "A2A-001", + "blocked", + {}, + "counterpart-unavailable", + updated_at="2026-01-01T00:00:00+00:00", + ) + self.assertEqual( + ["blocked"], + [ + item["status"] + for item in recorded["scenario_results"]["A2A-001"]["history"] + ], + ) + + def test_complete_run_requires_all_evidence_traces_and_endurance_bounds(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + scenarios = validate_manifest(self.manifest) + for scenario_id, scenario in scenarios.items(): + evidence = {key: 1 for key in scenario["evidence"]} + if "duration-minutes" in evidence: + evidence["duration-minutes"] = 240 + if "cycle-count" in evidence: + evidence["cycle-count"] = 50 + record_result( + run, + scenario_id, + "pass", + evidence, + None, + updated_at="2026-01-01T04:00:00+00:00", + ) + append_trace_event( + run, + scenario_id, + scenario["participants"][0], + "scenario-terminal", + "pass", + None, + {"assertion-count": len(evidence)}, + timestamp="2026-01-01T04:00:00+00:00", + ) + summary = validate_run(run) + self.assertTrue(summary["complete"]) + self.assertEqual(27, summary["pass"]) + self.assertEqual(27, summary["trace_events"]) + + bundle = Path(temporary) / "release-gate.zip" + create_bundle(run, bundle) + with zipfile.ZipFile(bundle) as archive: + self.assertEqual( + { + "SHA256SUMS", + "fixtures/manifest.json", + "manifest.json", + "matrix.json", + "results.json", + "summary.md", + "trace.jsonl", + }, + set(archive.namelist()), + ) + with self.assertRaisesRegex(GateError, "overwrite"): + create_bundle(run, bundle) + + def test_manifest_tampering_after_initialization_is_detected(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + manifest = load_json(run / "manifest.json") + manifest["scenario_version"] = "tampered" + (run / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaisesRegex(GateError, "changed"): + validate_run(run, allow_incomplete=True) + + def test_trace_accepts_only_aggregate_metrics(self): + with tempfile.TemporaryDirectory() as temporary: + run = Path(temporary) / "rc-run" + initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run") + with self.assertRaisesRegex(GateError, "numeric"): + append_trace_event( + run, + "A2A-001", + "android-current", + "packet", + "observed", + None, + {"raw-packet": "payload"}, + ) + + def test_evidence_parser_is_typed_and_privacy_checked(self): + self.assertEqual( + {"count": 3, "ratio": 0.5, "clean": True}, + parse_evidence(["count=3", "ratio=0.5", "clean=true"]), + ) + with self.assertRaises(GateError): + parse_evidence(["note=10.0.0.1"]) + + @mock.patch("tools.release_gate.android_lab.find_adb", return_value="adb") + def test_adb_device_count_never_returns_selectors(self, _find_adb): + completed = subprocess.CompletedProcess( + ["adb", "devices"], + 0, + "List of devices attached\nselector-one\tdevice\nselector-two\toffline\n", + "", + ) + count = android_lab.count_connected_devices(runner=lambda *args, **kwargs: completed) + self.assertEqual(1, count) + + @mock.patch("tools.release_gate.android_lab.run_adb") + def test_adb_probe_emits_only_logical_device_metadata(self, run_adb): + run_adb.side_effect = [ + "35", + "feature:android.hardware.wifi.aware", + "Vendor", + "Model", + ] + probe = android_lab.probe_device("ephemeral-selector", "android-current") + self.assertEqual("android-current", probe["alias"]) + self.assertNotIn("serial", probe) + self.assertIn("wifi-aware", probe["capabilities"]) + + @mock.patch("tools.release_gate.android_lab.run_adb") + def test_disposable_cleanup_targets_the_real_application_id(self, run_adb): + run_adb.side_effect = ["", "Success"] + + android_lab.prepare_disposable_device("ephemeral-selector", confirmed=True) + + self.assertEqual( + [ + mock.call( + "ephemeral-selector", + ["shell", "am", "force-stop", "com.bitchat.droid"], + ), + mock.call( + "ephemeral-selector", + ["shell", "pm", "clear", "com.bitchat.droid"], + ), + ], + run_adb.call_args_list, + ) + + @mock.patch("tools.release_gate.android_lab.run_adb") + def test_resource_snapshot_returns_only_aggregate_metrics(self, run_adb): + run_adb.side_effect = [ + "123", + "TOTAL 2048", + "7", + "11", + "WakeLock com.bitchat.droid\nWakeLock another.package", + "level: 73", + ] + metrics = android_lab.collect_resource_snapshot("ephemeral-selector") + self.assertEqual( + { + "process-running": True, + "total-pss-kb": 2048, + "thread-count": 7, + "fd-count": 11, + "app-wakelock-count": 1, + "battery-level-percent": 73, + }, + metrics, + ) + self.assertEqual( + mock.call( + "ephemeral-selector", + ["shell", "pidof", "com.bitchat.droid"], + ), + run_adb.call_args_list[0], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/reproducible-builds/Dockerfile b/tools/reproducible-builds/Dockerfile new file mode 100644 index 00000000..a2a59a92 --- /dev/null +++ b/tools/reproducible-builds/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 +FROM eclipse-temurin:21.0.11_10-jdk-noble@sha256:35685c7e23352983a48882d97cd9875f5284c228db71d1e2476e5e6c1bab1080 + +SHELL ["/bin/bash", "-euo", "pipefail", "-c"] + +COPY tools/reproducible-builds/TOOLCHAIN.env /tmp/TOOLCHAIN.env +COPY tools/reproducible-builds/sdk-metadata /tmp/sdk-metadata + +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk + +RUN source /tmp/TOOLCHAIN.env \ + && mkdir -p "$ANDROID_HOME/build-tools" "$ANDROID_HOME/licenses" "$ANDROID_HOME/platforms" /tmp/android-packages \ + && printf '%s\n' "$ANDROID_SDK_LICENSE_SHA1" > "$ANDROID_HOME/licenses/android-sdk-license" \ + && curl -fsSL \ + "https://dl.google.com/android/repository/${ANDROID_BUILD_TOOLS_ARCHIVE}" \ + -o "/tmp/${ANDROID_BUILD_TOOLS_ARCHIVE}" \ + && echo "${ANDROID_BUILD_TOOLS_SHA256} /tmp/${ANDROID_BUILD_TOOLS_ARCHIVE}" | sha256sum -c - \ + && mkdir -p /tmp/android-packages/build-tools \ + && cd /tmp/android-packages/build-tools \ + && jar xf "/tmp/${ANDROID_BUILD_TOOLS_ARCHIVE}" \ + && mv "/tmp/android-packages/build-tools/android-${ANDROID_PLATFORM_VERSION}" \ + "$ANDROID_HOME/build-tools/${ANDROID_BUILD_TOOLS_VERSION}" \ + && cp /tmp/sdk-metadata/build-tools-37.0.0.xml \ + "$ANDROID_HOME/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/package.xml" \ + && find "$ANDROID_HOME/build-tools/${ANDROID_BUILD_TOOLS_VERSION}" \ + -maxdepth 1 -type f -exec chmod 755 {} + \ + && curl -fsSL \ + "https://dl.google.com/android/repository/${ANDROID_PLATFORM_ARCHIVE}" \ + -o "/tmp/${ANDROID_PLATFORM_ARCHIVE}" \ + && echo "${ANDROID_PLATFORM_SHA256} /tmp/${ANDROID_PLATFORM_ARCHIVE}" | sha256sum -c - \ + && cd "$ANDROID_HOME/platforms" \ + && jar xf "/tmp/${ANDROID_PLATFORM_ARCHIVE}" \ + && cp /tmp/sdk-metadata/platform-37.0.xml \ + "$ANDROID_HOME/platforms/android-${ANDROID_PLATFORM_VERSION}/package.xml" \ + && test -f "$ANDROID_HOME/platforms/android-${ANDROID_PLATFORM_VERSION}/android.jar" \ + && curl -fsSL \ + "https://dl.google.com/android/repository/${ANDROID_PLATFORM_TOOLS_ARCHIVE}" \ + -o "/tmp/${ANDROID_PLATFORM_TOOLS_ARCHIVE}" \ + && echo "${ANDROID_PLATFORM_TOOLS_SHA256} /tmp/${ANDROID_PLATFORM_TOOLS_ARCHIVE}" | sha256sum -c - \ + && cd "$ANDROID_HOME" \ + && jar xf "/tmp/${ANDROID_PLATFORM_TOOLS_ARCHIVE}" \ + && cp /tmp/sdk-metadata/platform-tools-37.0.0.xml \ + "$ANDROID_HOME/platform-tools/package.xml" \ + && find "$ANDROID_HOME/platform-tools" -maxdepth 1 -type f -exec chmod 755 {} + \ + && rm -rf /tmp/android-packages \ + "/tmp/${ANDROID_BUILD_TOOLS_ARCHIVE}" \ + "/tmp/${ANDROID_PLATFORM_ARCHIVE}" \ + "/tmp/${ANDROID_PLATFORM_TOOLS_ARCHIVE}" + +ENV PATH=/opt/android-sdk/build-tools/37.0.0:${PATH} + +ENV TZ=UTC +ENV LC_ALL=C.UTF-8 + +WORKDIR /workspace + +ENTRYPOINT ["/bin/bash", "tools/reproducible-builds/build-release.sh"] diff --git a/tools/reproducible-builds/TOOLCHAIN.env b/tools/reproducible-builds/TOOLCHAIN.env new file mode 100644 index 00000000..75d47323 --- /dev/null +++ b/tools/reproducible-builds/TOOLCHAIN.env @@ -0,0 +1,14 @@ +JAVA_VERSION=21.0.11+10 +JAVA_MAJOR_VERSION=21 +GRADLE_VERSION=9.6.1 +ANDROID_COMPILE_SDK=37 +ANDROID_PLATFORM_VERSION=37.0 +ANDROID_PLATFORM_ARCHIVE=platform-37.0_r02.zip +ANDROID_PLATFORM_SHA256=840b23e827f96e64aea4c89a1194aac3dc5f6bad37edb231c5c795d890330e8d +ANDROID_SDK_LICENSE_SHA1=24333f8a63b6825ea9c5514f83c2829b004d1fee +ANDROID_PLATFORM_TOOLS_VERSION=37.0.0 +ANDROID_PLATFORM_TOOLS_ARCHIVE=platform-tools_r37.0.0-linux.zip +ANDROID_PLATFORM_TOOLS_SHA256=198ae156ab285fa555987219af237b31102fefe8b9d2bc274708a8d4f2865a07 +ANDROID_BUILD_TOOLS_VERSION=37.0.0 +ANDROID_BUILD_TOOLS_ARCHIVE=build-tools_r37_linux.zip +ANDROID_BUILD_TOOLS_SHA256=01af179347cbcd9c208b7f8171f7b21f6dd1d2f85bcd15e88caa51d5d7b86060 diff --git a/tools/reproducible-builds/build-in-container.sh b/tools/reproducible-builds/build-in-container.sh new file mode 100755 index 00000000..76bcc15d --- /dev/null +++ b/tools/reproducible-builds/build-in-container.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +IMAGE_NAME="bitchat-android-reproducible-builder:21.0.11" +OUTPUT_DIR="${1:-$PROJECT_ROOT/.reproducible-build/release}" +GRADLE_HOME_NAME="${BITCHAT_CONTAINER_GRADLE_HOME_NAME:-gradle-home-container}" +CONTAINER_LOCAL_PROPERTIES="$SCRIPT_DIR/container-local.properties" + +if ! command -v docker >/dev/null 2>&1; then + echo "error: Docker is required for the canonical container build" >&2 + exit 1 +fi +if [ ! -f "$CONTAINER_LOCAL_PROPERTIES" ]; then + echo "error: missing canonical container local.properties" >&2 + exit 1 +fi + +if [ "${BITCHAT_ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git -C "$PROJECT_ROOT" status --porcelain --untracked-files=normal)" ]; then + echo "error: reproducible builds require a clean source tree" >&2 + exit 1 +fi + +source_commit="$(git -C "$PROJECT_ROOT" rev-parse HEAD)" +source_date_epoch="$(git -C "$PROJECT_ROOT" log -1 --format=%ct)" + +if ! [[ "$GRADLE_HOME_NAME" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "error: BITCHAT_CONTAINER_GRADLE_HOME_NAME must be a simple directory name" >&2 + exit 1 +fi + +mkdir -p "$PROJECT_ROOT/.reproducible-build" +staging_root="$(mktemp -d "$PROJECT_ROOT/.reproducible-build/source.XXXXXX")" +cleanup() { + rm -rf -- "$staging_root" +} +trap cleanup EXIT + +# Build from the exact committed tree rather than the host checkout. This keeps +# ignored files and Android Studio state out of the canonical build and avoids +# nested bind mounts, which are not portable across Docker runtimes. +git -C "$PROJECT_ROOT" archive --format=tar "$source_commit" | + tar -xf - -C "$staging_root" +cp "$CONTAINER_LOCAL_PROPERTIES" "$staging_root/local.properties" + +gradle_home="$PROJECT_ROOT/.reproducible-build/$GRADLE_HOME_NAME" +mkdir -p "$gradle_home" + +docker build \ + --platform linux/amd64 \ + --file "$SCRIPT_DIR/Dockerfile" \ + --tag "$IMAGE_NAME" \ + "$PROJECT_ROOT" + +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" + +docker run \ + --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + --env BITCHAT_ALLOW_DIRTY="${BITCHAT_ALLOW_DIRTY:-0}" \ + --env BITCHAT_GRADLE_USER_HOME=/gradle-home \ + --env BITCHAT_SOURCE_COMMIT="$source_commit" \ + --env BITCHAT_SOURCE_TREE_VERIFIED=1 \ + --env HOME=/tmp/build-home \ + --env SOURCE_DATE_EPOCH="$source_date_epoch" \ + --volume "$staging_root:/workspace" \ + --volume "$gradle_home:/gradle-home" \ + --volume "$OUTPUT_DIR:/output" \ + "$IMAGE_NAME" \ + /output diff --git a/tools/reproducible-builds/build-release.sh b/tools/reproducible-builds/build-release.sh new file mode 100755 index 00000000..f76e09f9 --- /dev/null +++ b/tools/reproducible-builds/build-release.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="${1:-$PROJECT_ROOT/.reproducible-build/release}" + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/TOOLCHAIN.env" + +if [ "${BITCHAT_SOURCE_TREE_VERIFIED:-0}" != "1" ] && + [ "${BITCHAT_ALLOW_DIRTY:-0}" != "1" ] && + [ -n "$(git -C "$PROJECT_ROOT" status --porcelain --untracked-files=normal)" ]; then + echo "error: reproducible builds require a clean source tree" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" +if [ -n "$(find "$OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + echo "error: output directory must be empty: $OUTPUT_DIR" >&2 + exit 1 +fi + +actual_java_version="$(java -XshowSettings:properties -version 2>&1 | sed -n 's/^ *java.version = //p' | head -1)" +expected_java_version="${JAVA_VERSION%%+*}" +if [ "$actual_java_version" != "$expected_java_version" ]; then + echo "error: JDK $expected_java_version is required; found $actual_java_version" >&2 + exit 1 +fi + +"$PROJECT_ROOT/tools/arti-build/verify-checksums.sh" + +export GRADLE_USER_HOME="${BITCHAT_GRADLE_USER_HOME:-$PROJECT_ROOT/.reproducible-build/gradle-home}" +export LC_ALL=C.UTF-8 +export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$PROJECT_ROOT" log -1 --format=%ct)}" +export TZ=UTC +if ! [[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]]; then + echo "error: SOURCE_DATE_EPOCH must be an integer" >&2 + exit 1 +fi + +gradle_args=( + # R8 otherwise randomizes input traversal and uses multiple compiler threads. + # The optimized DEX can still match while its embedded ProGuard map differs. + -Dcom.android.tools.r8.deterministicdebugging=1 + -Pkotlin.compiler.execution.strategy=in-process + -Pkotlin.incremental=false + --no-build-cache + --no-configuration-cache + --no-daemon + --no-watch-fs + --rerun-tasks +) + +cd "$PROJECT_ROOT" +./gradlew "${gradle_args[@]}" :app:clean :app:bundleRelease + +aab_source="$PROJECT_ROOT/app/build/outputs/bundle/release/app-release.aab" +if [ ! -f "$aab_source" ]; then + echo "error: expected phone release AAB not found" >&2 + exit 1 +fi +cp "$aab_source" "$OUTPUT_DIR/bitchat-android-release-unsigned.aab" + +./gradlew "${gradle_args[@]}" :wear:clean :wear:bundleRelease + +wear_aab_source="$PROJECT_ROOT/wear/build/outputs/bundle/release/wear-release.aab" +if [ ! -f "$wear_aab_source" ]; then + echo "error: expected Wear release AAB not found" >&2 + exit 1 +fi +cp "$wear_aab_source" "$OUTPUT_DIR/bitchat-android-wear-release-unsigned.aab" + +# AGP cannot build split APKs and an app bundle from the same intermediates. +./gradlew "${gradle_args[@]}" :app:clean :app:assembleRelease + +declare -A apk_names=( + ["app-arm64-v8a-release-unsigned.apk"]="bitchat-android-arm64-unsigned.apk" + ["app-armeabi-v7a-release-unsigned.apk"]="bitchat-android-armv7-unsigned.apk" + ["app-universal-release-unsigned.apk"]="bitchat-android-universal-unsigned.apk" + ["app-x86-release-unsigned.apk"]="bitchat-android-x86-unsigned.apk" + ["app-x86_64-release-unsigned.apk"]="bitchat-android-x86_64-unsigned.apk" +) + +for source_name in "${!apk_names[@]}"; do + source_path="$PROJECT_ROOT/app/build/outputs/apk/release/$source_name" + if [ ! -f "$source_path" ]; then + echo "error: expected release APK not found: $source_name" >&2 + exit 1 + fi + cp "$source_path" "$OUTPUT_DIR/${apk_names[$source_name]}" +done + +./gradlew "${gradle_args[@]}" :wear:clean :wear:assembleRelease + +wear_apk_source="$PROJECT_ROOT/wear/build/outputs/apk/release/wear-release-unsigned.apk" +if [ ! -f "$wear_apk_source" ]; then + echo "error: expected Wear release APK not found" >&2 + exit 1 +fi +cp "$wear_apk_source" "$OUTPUT_DIR/bitchat-android-wear-unsigned.apk" + +source_commit="${BITCHAT_SOURCE_COMMIT:-$(git -C "$PROJECT_ROOT" rev-parse HEAD)}" +if ! [[ "$source_commit" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then + echo "error: source commit must be a full Git object ID" >&2 + exit 1 +fi +native_manifest_sha256="$(sha256sum "$PROJECT_ROOT/tools/arti-build/SHA256SUMS" | awk '{print $1}')" + +cat > "$OUTPUT_DIR/BUILDINFO.json" < SHA256SUMS.unsigned +) + +echo "Reproducible unsigned release written to $OUTPUT_DIR" diff --git a/tools/reproducible-builds/compare-archive-payloads.sh b/tools/reproducible-builds/compare-archive-payloads.sh new file mode 100755 index 00000000..6cb5a9ea --- /dev/null +++ b/tools/reproducible-builds/compare-archive-payloads.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +FIRST_ARCHIVE="${1:?usage: compare-archive-payloads.sh FIRST.apk|aab SECOND.apk|aab}" +SECOND_ARCHIVE="${2:?usage: compare-archive-payloads.sh FIRST.apk|aab SECOND.apk|aab}" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +if ! command -v unzip >/dev/null 2>&1; then + echo "error: unzip is required" >&2 + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + SHA256=(sha256sum) +elif command -v shasum >/dev/null 2>&1; then + SHA256=(shasum -a 256) +else + echo "error: sha256sum or shasum is required" >&2 + exit 1 +fi + +write_payload_manifest() { + local archive="$1" + local output="$2" + + : > "$output" + while IFS= read -r entry; do + case "$entry" in + */|META-INF/MANIFEST.MF|META-INF/*.SF|META-INF/*.RSA|META-INF/*.DSA|META-INF/*.EC) + continue + ;; + esac + local digest + digest="$(unzip -p "$archive" "$entry" | "${SHA256[@]}" | awk '{print $1}')" + printf '%s %s\n' "$digest" "$entry" >> "$output" + done < <(unzip -Z1 "$archive" | LC_ALL=C sort) +} + +write_payload_manifest "$FIRST_ARCHIVE" "$TEMP_DIR/first" +write_payload_manifest "$SECOND_ARCHIVE" "$TEMP_DIR/second" + +if ! diff -u "$TEMP_DIR/first" "$TEMP_DIR/second"; then + echo "error: signed/unsigned archive payloads differ" >&2 + exit 1 +fi + +echo "Archive entry names and uncompressed payload bytes match (signing metadata excluded)." diff --git a/tools/reproducible-builds/compare-release.sh b/tools/reproducible-builds/compare-release.sh new file mode 100755 index 00000000..39608304 --- /dev/null +++ b/tools/reproducible-builds/compare-release.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +FIRST_DIR="${1:?usage: compare-release.sh FIRST_DIR SECOND_DIR}" +SECOND_DIR="${2:?usage: compare-release.sh FIRST_DIR SECOND_DIR}" + +artifacts=( + BUILDINFO.json + SHA256SUMS.unsigned + bitchat-android-arm64-unsigned.apk + bitchat-android-armv7-unsigned.apk + bitchat-android-release-unsigned.aab + bitchat-android-universal-unsigned.apk + bitchat-android-wear-release-unsigned.aab + bitchat-android-wear-unsigned.apk + bitchat-android-x86-unsigned.apk + bitchat-android-x86_64-unsigned.apk +) + +for artifact in "${artifacts[@]}"; do + if ! cmp -s "$FIRST_DIR/$artifact" "$SECOND_DIR/$artifact"; then + echo "error: reproducibility mismatch: $artifact" >&2 + if command -v diffoscope >/dev/null 2>&1; then + diffoscope "$FIRST_DIR/$artifact" "$SECOND_DIR/$artifact" || true + fi + exit 1 + fi + echo "MATCH $artifact" +done + +echo "All unsigned APK, AAB, checksum, and build-info bytes match." diff --git a/tools/reproducible-builds/container-local.properties b/tools/reproducible-builds/container-local.properties new file mode 100644 index 00000000..daae45e6 --- /dev/null +++ b/tools/reproducible-builds/container-local.properties @@ -0,0 +1 @@ +sdk.dir=/opt/android-sdk diff --git a/tools/reproducible-builds/install-android-build-tools.sh b/tools/reproducible-builds/install-android-build-tools.sh new file mode 100755 index 00000000..bf2bf1b5 --- /dev/null +++ b/tools/reproducible-builds/install-android-build-tools.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_ROOT="${1:?usage: install-android-build-tools.sh SDK_ROOT}" + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/TOOLCHAIN.env" + +for command in curl sha256sum unzip; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "error: $command is required" >&2 + exit 1 + fi +done + +destination="$SDK_ROOT/build-tools/$ANDROID_BUILD_TOOLS_VERSION" +if [ -e "$destination" ]; then + echo "error: Android Build Tools destination already exists: $destination" >&2 + exit 1 +fi + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT +archive="$TEMP_DIR/$ANDROID_BUILD_TOOLS_ARCHIVE" + +curl -fsSL \ + "https://dl.google.com/android/repository/$ANDROID_BUILD_TOOLS_ARCHIVE" \ + -o "$archive" +echo "$ANDROID_BUILD_TOOLS_SHA256 $archive" | sha256sum -c - +unzip -q "$archive" -d "$TEMP_DIR/unpacked" + +mkdir -p "$SDK_ROOT/build-tools" +mv "$TEMP_DIR/unpacked/android-$ANDROID_PLATFORM_VERSION" "$destination" +test "$( + sed -n 's/^Pkg.Revision=//p' "$destination/source.properties" | tr -d '\r' +)" = "$ANDROID_BUILD_TOOLS_VERSION" + +echo "Installed checksum-verified Android Build Tools $ANDROID_BUILD_TOOLS_VERSION." diff --git a/tools/reproducible-builds/prepare-github-release.sh b/tools/reproducible-builds/prepare-github-release.sh new file mode 100755 index 00000000..b87e7843 --- /dev/null +++ b/tools/reproducible-builds/prepare-github-release.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +set -euo pipefail + +RELEASE_DIR="${1:?usage: prepare-github-release.sh RELEASE_DIR}" + +if [ ! -d "$RELEASE_DIR" ]; then + echo "error: release directory not found" >&2 + exit 1 +fi +RELEASE_DIR="$(cd "$RELEASE_DIR" && pwd)" + +if command -v sha256sum >/dev/null 2>&1; then + SHA256=(sha256sum) +elif command -v shasum >/dev/null 2>&1; then + SHA256=(shasum -a 256) +else + echo "error: sha256sum or shasum is required" >&2 + exit 1 +fi + +required=( + BUILDINFO.json + SHA256SUMS + SHA256SUMS.unsigned + bitchat-android-arm64-unsigned.apk + bitchat-android-arm64.apk + bitchat-android-armv7-unsigned.apk + bitchat-android-play-upload.aab + bitchat-android-release-unsigned.aab + bitchat-android-universal-unsigned.apk + bitchat-android-universal.apk + bitchat-android-wear-play-upload.aab + bitchat-android-wear-release-unsigned.aab + bitchat-android-wear-unsigned.apk + bitchat-android-wear.apk + bitchat-android-x86-unsigned.apk + bitchat-android-x86_64-unsigned.apk + bitchat-android-x86_64.apk +) +for artifact in "${required[@]}"; do + if [ ! -f "$RELEASE_DIR/$artifact" ]; then + echo "error: required release artifact missing: $artifact" >&2 + exit 1 + fi +done + +for artifact_path in "$RELEASE_DIR"/*; do + if [ ! -f "$artifact_path" ]; then + echo "error: unexpected non-file in release directory: $(basename "$artifact_path")" >&2 + exit 1 + fi + artifact="$(basename "$artifact_path")" + case "$artifact" in + BUILDINFO.json|SHA256SUMS|SHA256SUMS.unsigned|\ + bitchat-android-arm64-unsigned.apk|bitchat-android-arm64.apk|\ + bitchat-android-armv7-unsigned.apk|\ + bitchat-android-play-upload.aab|bitchat-android-release-unsigned.aab|\ + bitchat-android-universal-unsigned.apk|bitchat-android-universal.apk|\ + bitchat-android-wear-play-upload.aab|bitchat-android-wear-release-unsigned.aab|\ + bitchat-android-wear-unsigned.apk|bitchat-android-wear.apk|\ + bitchat-android-x86-unsigned.apk|\ + bitchat-android-x86_64-unsigned.apk|bitchat-android-x86_64.apk) + ;; + *) + echo "error: unexpected release artifact: $artifact" >&2 + exit 1 + ;; + esac +done + +for destination in BITCHAT_BUILDINFO.json BITCHAT_SHA256SUMS BITCHAT_SHA256SUMS.unsigned; do + if [ -e "$RELEASE_DIR/$destination" ]; then + echo "error: public release manifest already exists: $destination" >&2 + exit 1 + fi +done + +( + cd "$RELEASE_DIR" + "${SHA256[@]}" -c SHA256SUMS +) + +mv "$RELEASE_DIR/BUILDINFO.json" "$RELEASE_DIR/BITCHAT_BUILDINFO.json" +mv "$RELEASE_DIR/SHA256SUMS.unsigned" "$RELEASE_DIR/BITCHAT_SHA256SUMS.unsigned" +sed \ + -e 's/ BUILDINFO.json$/ BITCHAT_BUILDINFO.json/' \ + -e 's/ SHA256SUMS.unsigned$/ BITCHAT_SHA256SUMS.unsigned/' \ + "$RELEASE_DIR/SHA256SUMS" > "$RELEASE_DIR/BITCHAT_SHA256SUMS" +rm "$RELEASE_DIR/SHA256SUMS" + +( + cd "$RELEASE_DIR" + "${SHA256[@]}" -c BITCHAT_SHA256SUMS +) + +echo "Release assets are checksummed and ready for manual GitHub publication." diff --git a/tools/reproducible-builds/sdk-metadata/build-tools-37.0.0.xml b/tools/reproducible-builds/sdk-metadata/build-tools-37.0.0.xml new file mode 100644 index 00000000..5a405083 --- /dev/null +++ b/tools/reproducible-builds/sdk-metadata/build-tools-37.0.0.xml @@ -0,0 +1,16 @@ + + + + + + 37 + 0 + 0 + + Android SDK Build-Tools 37 + + diff --git a/tools/reproducible-builds/sdk-metadata/platform-37.0.xml b/tools/reproducible-builds/sdk-metadata/platform-37.0.xml new file mode 100644 index 00000000..dba2134d --- /dev/null +++ b/tools/reproducible-builds/sdk-metadata/platform-37.0.xml @@ -0,0 +1,20 @@ + + + + + 37.0 + + 22 + true + + + + 2 + + Android SDK Platform 37.0 + + diff --git a/tools/reproducible-builds/sdk-metadata/platform-tools-37.0.0.xml b/tools/reproducible-builds/sdk-metadata/platform-tools-37.0.0.xml new file mode 100644 index 00000000..5936e574 --- /dev/null +++ b/tools/reproducible-builds/sdk-metadata/platform-tools-37.0.0.xml @@ -0,0 +1,16 @@ + + + + + + 37 + 0 + 0 + + Android SDK Platform-Tools + + diff --git a/tools/reproducible-builds/sign-play-bundle.sh b/tools/reproducible-builds/sign-play-bundle.sh new file mode 100755 index 00000000..abc51a97 --- /dev/null +++ b/tools/reproducible-builds/sign-play-bundle.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RELEASE_DIR="${1:?usage: sign-play-bundle.sh RELEASE_DIR}" + +: "${BITCHAT_PLAY_UPLOAD_KEYSTORE:?BITCHAT_PLAY_UPLOAD_KEYSTORE is required}" +: "${BITCHAT_PLAY_UPLOAD_KEY_ALIAS:?BITCHAT_PLAY_UPLOAD_KEY_ALIAS is required}" +: "${BITCHAT_PLAY_KEYSTORE_PASSWORD:?BITCHAT_PLAY_KEYSTORE_PASSWORD is required}" +: "${BITCHAT_PLAY_KEY_PASSWORD:?BITCHAT_PLAY_KEY_PASSWORD is required}" + +if [ ! -d "$RELEASE_DIR" ]; then + echo "error: release directory not found" >&2 + exit 1 +fi +RELEASE_DIR="$(cd "$RELEASE_DIR" && pwd)" + +if [ ! -f "$BITCHAT_PLAY_UPLOAD_KEYSTORE" ]; then + echo "error: Play upload keystore not found" >&2 + exit 1 +fi +KEYSTORE="$( + cd "$(dirname "$BITCHAT_PLAY_UPLOAD_KEYSTORE")" + printf '%s/%s\n' "$PWD" "$(basename "$BITCHAT_PLAY_UPLOAD_KEYSTORE")" +)" + +if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/jarsigner" ]; then + JARSIGNER="$JAVA_HOME/bin/jarsigner" +elif command -v jarsigner >/dev/null 2>&1; then + JARSIGNER="$(command -v jarsigner)" +else + echo "error: jarsigner is required; set JAVA_HOME to the pinned JDK" >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + SHA256=(sha256sum) +elif command -v shasum >/dev/null 2>&1; then + SHA256=(shasum -a 256) +else + echo "error: sha256sum or shasum is required" >&2 + exit 1 +fi + +( + cd "$RELEASE_DIR" + "${SHA256[@]}" -c SHA256SUMS.unsigned +) + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT +STOREPASS_FILE="$TEMP_DIR/storepass" +KEYPASS_FILE="$TEMP_DIR/keypass" +printf '%s\n' "$BITCHAT_PLAY_KEYSTORE_PASSWORD" > "$STOREPASS_FILE" +printf '%s\n' "$BITCHAT_PLAY_KEY_PASSWORD" > "$KEYPASS_FILE" +chmod 600 "$STOREPASS_FILE" "$KEYPASS_FILE" + +unsigned_names=( + "bitchat-android-release-unsigned.aab" + "bitchat-android-wear-release-unsigned.aab" +) +signed_names=( + "bitchat-android-play-upload.aab" + "bitchat-android-wear-play-upload.aab" +) + +for ((index = 0; index < ${#unsigned_names[@]}; index++)); do + unsigned_aab="$RELEASE_DIR/${unsigned_names[$index]}" + signed_aab="$RELEASE_DIR/${signed_names[$index]}" + + if [ ! -f "$unsigned_aab" ]; then + echo "error: unsigned Play AAB not found: ${unsigned_names[$index]}" >&2 + exit 1 + fi + if [ -e "$signed_aab" ]; then + echo "error: signed Play upload AAB already exists: ${signed_names[$index]}" >&2 + exit 1 + fi + + "$JARSIGNER" \ + -keystore "$KEYSTORE" \ + -storepass:file "$STOREPASS_FILE" \ + -keypass:file "$KEYPASS_FILE" \ + -digestalg SHA-256 \ + -signedjar "$signed_aab" \ + "$unsigned_aab" \ + "$BITCHAT_PLAY_UPLOAD_KEY_ALIAS" + + "$JARSIGNER" -verify "$signed_aab" >/dev/null + "$SCRIPT_DIR/compare-archive-payloads.sh" "$unsigned_aab" "$signed_aab" +done + +( + cd "$RELEASE_DIR" + { + for artifact in *; do + [ "$artifact" = "SHA256SUMS" ] && continue + [ -f "$artifact" ] || continue + "${SHA256[@]}" "$artifact" + done + } | sort -k2 > SHA256SUMS +) + +echo "Verified unsigned phone and Wear AABs were signed locally with the Play upload key and checksummed." diff --git a/tools/reproducible-builds/sign-release.sh b/tools/reproducible-builds/sign-release.sh new file mode 100755 index 00000000..6808b60e --- /dev/null +++ b/tools/reproducible-builds/sign-release.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RELEASE_DIR="${1:?usage: sign-release.sh RELEASE_DIR}" + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/TOOLCHAIN.env" + +: "${BITCHAT_GITHUB_KEYSTORE:?BITCHAT_GITHUB_KEYSTORE is required}" +: "${BITCHAT_GITHUB_KEY_ALIAS:?BITCHAT_GITHUB_KEY_ALIAS is required}" +: "${BITCHAT_GITHUB_KEYSTORE_PASSWORD:?BITCHAT_GITHUB_KEYSTORE_PASSWORD is required}" +: "${BITCHAT_GITHUB_KEY_PASSWORD:?BITCHAT_GITHUB_KEY_PASSWORD is required}" + +if [ ! -f "$BITCHAT_GITHUB_KEYSTORE" ]; then + echo "error: GitHub release keystore not found" >&2 + exit 1 +fi +KEYSTORE="$( + cd "$(dirname "$BITCHAT_GITHUB_KEYSTORE")" + printf '%s/%s\n' "$PWD" "$(basename "$BITCHAT_GITHUB_KEYSTORE")" +)" + +ANDROID_SDK_HOME="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +if [ -z "$ANDROID_SDK_HOME" ]; then + echo "error: ANDROID_SDK_ROOT or ANDROID_HOME is required" >&2 + exit 1 +fi + +APKSIGNER="$ANDROID_SDK_HOME/build-tools/$ANDROID_BUILD_TOOLS_VERSION/apksigner" +ZIPALIGN="$ANDROID_SDK_HOME/build-tools/$ANDROID_BUILD_TOOLS_VERSION/zipalign" +if [ ! -x "$APKSIGNER" ] || [ ! -x "$ZIPALIGN" ]; then + echo "error: Android Build Tools $ANDROID_BUILD_TOOLS_VERSION are required" >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + SHA256=(sha256sum) +elif command -v shasum >/dev/null 2>&1; then + SHA256=(shasum -a 256) +else + echo "error: sha256sum or shasum is required" >&2 + exit 1 +fi + +( + cd "$RELEASE_DIR" + "${SHA256[@]}" -c SHA256SUMS.unsigned +) + +EXPECTED_CERT_SHA256="$( + sed -n 's/^BITCHAT_GITHUB_RELEASE_CERT_SHA256=//p' "$PROJECT_ROOT/gradle.properties" | + tr -d ':\r[:space:]' | + tr '[:upper:]' '[:lower:]' +)" +if ! [[ "$EXPECTED_CERT_SHA256" =~ ^[a-f0-9]{64}$ ]]; then + echo "error: expected GitHub release certificate fingerprint is invalid" >&2 + exit 1 +fi + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT +export BITCHAT_GITHUB_KEYSTORE_PASSWORD +export BITCHAT_GITHUB_KEY_PASSWORD + +unsigned_names=( + "bitchat-android-arm64-unsigned.apk" + "bitchat-android-universal-unsigned.apk" + "bitchat-android-wear-unsigned.apk" + "bitchat-android-x86_64-unsigned.apk" +) +signed_names=( + "bitchat-android-arm64.apk" + "bitchat-android-universal.apk" + "bitchat-android-wear.apk" + "bitchat-android-x86_64.apk" +) + +for ((index = 0; index < ${#unsigned_names[@]}; index++)); do + unsigned_name="${unsigned_names[$index]}" + signed_name="${signed_names[$index]}" + unsigned_apk="$RELEASE_DIR/$unsigned_name" + signed_apk="$RELEASE_DIR/$signed_name" + first_signed="$TEMP_DIR/first.apk" + second_signed="$TEMP_DIR/second.apk" + + if [ -e "$signed_apk" ]; then + echo "error: signed APK already exists: $signed_name" >&2 + exit 1 + fi + "$ZIPALIGN" -c -P 16 4 "$unsigned_apk" + + for output_apk in "$first_signed" "$second_signed"; do + "$APKSIGNER" sign \ + --ks "$KEYSTORE" \ + --ks-key-alias "$BITCHAT_GITHUB_KEY_ALIAS" \ + --ks-pass env:BITCHAT_GITHUB_KEYSTORE_PASSWORD \ + --key-pass env:BITCHAT_GITHUB_KEY_PASSWORD \ + --deterministic-dsa-signing true \ + --min-sdk-version 26 \ + --v1-signing-enabled false \ + --v2-signing-enabled true \ + --v3-signing-enabled true \ + --v4-signing-enabled false \ + --out "$output_apk" \ + "$unsigned_apk" + done + + if ! cmp -s "$first_signed" "$second_signed"; then + echo "error: signing is not deterministic for $unsigned_name" >&2 + exit 1 + fi + + mv "$first_signed" "$signed_apk" + actual_cert_sha256="$( + "$APKSIGNER" verify --print-certs "$signed_apk" | + sed -n 's/.*certificate SHA-256 digest: //p' | + head -1 | + tr -d ':\r[:space:]' | + tr '[:upper:]' '[:lower:]' + )" + if [ "$actual_cert_sha256" != "$EXPECTED_CERT_SHA256" ]; then + echo "error: signing certificate fingerprint mismatch" >&2 + exit 1 + fi + "$APKSIGNER" verify --verbose "$signed_apk" >/dev/null + rm -f "$second_signed" +done + +( + cd "$RELEASE_DIR" + { + for artifact in *; do + [ "$artifact" = "SHA256SUMS" ] && continue + [ -f "$artifact" ] || continue + "${SHA256[@]}" "$artifact" + done + } | sort -k2 > SHA256SUMS +) + +echo "Verified unsigned APKs were deterministically signed locally and checksummed." diff --git a/tools/reproducible-builds/verify-github-release.sh b/tools/reproducible-builds/verify-github-release.sh new file mode 100755 index 00000000..c2776686 --- /dev/null +++ b/tools/reproducible-builds/verify-github-release.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TAG="${1:?usage: verify-github-release.sh TAG [--no-rebuild]}" +MODE="${2:-}" +REPOSITORY="${BITCHAT_GITHUB_REPOSITORY:-permissionlesstech/bitchat-android}" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "error: release tag must look like vX.Y.Z" >&2 + exit 1 +fi +if [ -n "$MODE" ] && [ "$MODE" != "--no-rebuild" ]; then + echo "error: unknown option: $MODE" >&2 + exit 1 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "error: gh is required" >&2 + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + SHA256=(sha256sum) +elif command -v shasum >/dev/null 2>&1; then + SHA256=(shasum -a 256) +else + echo "error: sha256sum or shasum is required" >&2 + exit 1 +fi + +DOWNLOAD_DIR="$TEMP_DIR/github" +mkdir -p "$DOWNLOAD_DIR" +gh release download "$TAG" \ + --repo "$REPOSITORY" \ + --dir "$DOWNLOAD_DIR" \ + --pattern 'BITCHAT_BUILDINFO.json' \ + --pattern 'BITCHAT_SHA256SUMS' \ + --pattern 'BITCHAT_SHA256SUMS.unsigned' \ + --pattern 'bitchat-android-*.apk' \ + --pattern 'bitchat-android-*.aab' + +attested_artifacts=( + BITCHAT_BUILDINFO.json + BITCHAT_SHA256SUMS.unsigned + bitchat-android-arm64-unsigned.apk + bitchat-android-armv7-unsigned.apk + bitchat-android-release-unsigned.aab + bitchat-android-universal-unsigned.apk + bitchat-android-wear-release-unsigned.aab + bitchat-android-wear-unsigned.apk + bitchat-android-x86-unsigned.apk + bitchat-android-x86_64-unsigned.apk +) +for artifact in "${attested_artifacts[@]}"; do + if [ ! -f "$DOWNLOAD_DIR/$artifact" ]; then + echo "error: attested canonical artifact missing: $artifact" >&2 + exit 1 + fi + gh attestation verify "$DOWNLOAD_DIR/$artifact" --repo "$REPOSITORY" >/dev/null +done +echo "GitHub provenance attestations for the canonical unsigned build verified." + +( + cd "$DOWNLOAD_DIR" + "${SHA256[@]}" -c BITCHAT_SHA256SUMS +) +echo "GitHub release checksums verified." + +mv "$DOWNLOAD_DIR/BITCHAT_BUILDINFO.json" "$DOWNLOAD_DIR/BUILDINFO.json" +mv "$DOWNLOAD_DIR/BITCHAT_SHA256SUMS" "$DOWNLOAD_DIR/SHA256SUMS" +mv "$DOWNLOAD_DIR/BITCHAT_SHA256SUMS.unsigned" "$DOWNLOAD_DIR/SHA256SUMS.unsigned" + +if [ "$MODE" = "--no-rebuild" ]; then + exit 0 +fi + +release_commit="$(sed -n 's/.*"sourceCommit": *"\([^"]*\)".*/\1/p' "$DOWNLOAD_DIR/BUILDINFO.json")" +local_commit="$(git -C "$PROJECT_ROOT" rev-parse HEAD)" +if [ "$release_commit" != "$local_commit" ]; then + echo "error: check out release commit $release_commit before rebuilding" >&2 + exit 1 +fi + +LOCAL_DIR="$PROJECT_ROOT/.reproducible-build/verify-$TAG" +if [ -e "$LOCAL_DIR" ]; then + echo "error: local verification output already exists: $LOCAL_DIR" >&2 + exit 1 +fi + +"$SCRIPT_DIR/build-in-container.sh" "$LOCAL_DIR" +"$SCRIPT_DIR/compare-release.sh" "$LOCAL_DIR" "$DOWNLOAD_DIR" +echo "GitHub release $TAG was reproduced successfully from source." diff --git a/tools/reproducible-builds/verify-no-host-paths.sh b/tools/reproducible-builds/verify-no-host-paths.sh new file mode 100755 index 00000000..b4b277c5 --- /dev/null +++ b/tools/reproducible-builds/verify-no-host-paths.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ARTIFACT_DIR="${1:?usage: verify-no-host-paths.sh ARTIFACT_DIR}" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +archive_index=0 +for artifact in "$ARTIFACT_DIR"/*.apk "$ARTIFACT_DIR"/*.aab; do + [ -f "$artifact" ] || continue + archive_index=$((archive_index + 1)) + archive_dir="$TEMP_DIR/$archive_index" + mkdir -p "$archive_dir" + if command -v unzip >/dev/null 2>&1; then + unzip -qq "$artifact" 'lib/*/*.so' 'base/lib/*/*.so' -d "$archive_dir" 2>/dev/null || true + elif command -v jar >/dev/null 2>&1; then + ( + cd "$archive_dir" + jar xf "$artifact" + ) + else + echo "error: unzip or jar is required to inspect release artifacts" >&2 + exit 1 + fi +done + +while IFS= read -r -d '' library; do + if strings "$library" | grep -Eq '/Users/|/home/|[A-Za-z]:\\\\Users\\\\'; then + echo "error: host-specific path detected in a release artifact" >&2 + exit 1 + fi +done < <(find "$TEMP_DIR" -type f -name '*.so' -print0) + +echo "Release artifacts contain no host-specific native paths." diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 00000000..a4d37f68 --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,211 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.bitchat.watch" + compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() + + defaultConfig { + applicationId = "com.bitchat.droid" + minSdk = 33 // Wear OS 4 (Pixel Watch 1+): the S+ Bluetooth permissions the app + // declares only exist from API 31, and API 30 would additionally require location + // for BLE scan results, which the app deliberately refuses. + targetSdk = libs.versions.targetSdk.get().toInt() + // Wear releases use a separate high range because Play requires every artifact in + // one application ID to have a unique version code across all form factors. + versionCode = 1_000_000_004 + versionName = "0.1.3" + + vectorDrawables { + useSupportLibrary = true + } + } + + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + vcsInfo { + include = false + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + lint { + abortOnError = false + checkReleaseBuilds = false + } +} + +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) +} + +// Shared bitchat protocol stack: compiled from :app sources in place (never moved/copied by hand). +// AGP's source directory sets no longer support include/exclude filters, so a Sync task +// materializes a filtered mirror into build/sharedSrc and that directory is added as a source +// root. The app sources remain the single source of truth; extend the include list below (don't +// copy files into wear/src) when the compiler reveals a missing transitive dependency. +// Deliberately excluded: ui (except the DebugSettingsManager the mesh layer references), +// onboarding, nostr (except pure-Kotlin Bech32), net, geohash, wifi-aware, hotspot, voice +// features, and the phone's foreground service. +val sharedSourceIncludes = listOf( + "com/bitchat/android/protocol/**", + "com/bitchat/android/noise/**", + "com/bitchat/android/crypto/**", + "com/bitchat/android/identity/**", + "com/bitchat/android/mesh/**", + "com/bitchat/android/model/**", + "com/bitchat/android/sync/**", + "com/bitchat/android/favorites/**", + "com/bitchat/android/services/AppStateStore.kt", + "com/bitchat/android/services/ContactDirectory.kt", + "com/bitchat/android/services/ContactIdentityResolver.kt", + "com/bitchat/android/services/ConversationRepository.kt", + "com/bitchat/android/services/ConversationStorageCipher.kt", + "com/bitchat/android/services/PrivateMessageArrivalOrder.kt", + "com/bitchat/android/services/SeenMessageStore.kt", + "com/bitchat/android/services/VerificationService.kt", + "com/bitchat/android/services/meshgraph/**", + "com/bitchat/android/service/TransportBridgeService.kt", + "com/bitchat/android/nostr/Bech32.kt", + "com/bitchat/android/nostr/GeohashAliasRegistry.kt", + "com/bitchat/android/features/file/FileUtils.kt", + "com/bitchat/android/features/voice/**", + "com/bitchat/android/ui/debug/DebugSettingsManager.kt", + "com/bitchat/android/ui/debug/DebugPreferenceManager.kt", + "com/bitchat/android/ui/NotificationTextUtils.kt", + "com/bitchat/android/util/AppConstants.kt", + "com/bitchat/android/util/ByteArrayExtensions.kt", + "com/bitchat/android/util/ByteArrayWrapper.kt", + "com/bitchat/android/util/BinaryEncodingUtils.kt", +) +val sharedSourceExcludes = listOf( + "com/bitchat/android/model/FileSharingManager.kt", + // Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service + // (MeshCore-style) in M2 instead of reusing these. + "com/bitchat/android/mesh/BluetoothMeshService.kt", + "com/bitchat/android/mesh/UnifiedMeshService.kt", + // Phone permission policy additionally requires location (legacy BLE); the watch app + // declares Bluetooth permissions only, so it ships its own same-FQN variant in + // wear/src/main (Bluetooth-only check). + "com/bitchat/android/mesh/BluetoothPermissionManager.kt", +) + +val syncSharedAppSources = tasks.register("syncSharedAppSources") { + from("../app/src/main/java") { + include(sharedSourceIncludes) + exclude(sharedSourceExcludes) + } + into(layout.buildDirectory.dir("sharedSrc")) +} + +// The app's own unit tests for the shared packages also run in the wear module, so shared +// behavior is continuously verified on both targets. Includes the app's JVM shims for +// android.util.Log/Base64 (app/src/test/kotlin/android) that the shared code needs on the JVM. +val syncSharedAppTests = tasks.register("syncSharedAppTests") { + from("../app/src/test/java") { + include( + "com/bitchat/android/protocol/**", + "com/bitchat/android/crypto/**", + "com/bitchat/android/mesh/**", + ) + } + from("../app/src/test/kotlin") { + include( + "android/**", + "com/bitchat/android/mesh/**", + "com/bitchat/FileTransferTest.kt", + ) + } + into(layout.buildDirectory.dir("sharedTestSrc")) +} + +android { + sourceSets { + getByName("main") { + java.srcDir("build/sharedSrc") + kotlin.srcDir("build/sharedSrc") + } + getByName("test") { + java.srcDir("build/sharedTestSrc") + kotlin.srcDir("build/sharedTestSrc") + } + } +} + +tasks.withType().configureEach { + dependsOn(syncSharedAppSources) +} +tasks.matching { it.name.contains("UnitTest", ignoreCase = true) }.configureEach { + dependsOn(syncSharedAppTests) +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + + // Wear Compose + implementation(libs.androidx.wear.compose.foundation) + implementation(libs.androidx.wear.compose.material3) + implementation(libs.androidx.wear.tooling.preview) + implementation(libs.androidx.compose.material.icons.extended) + + // Lifecycle + implementation(libs.bundles.lifecycle) + implementation(libs.androidx.lifecycle.process) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Cryptography (shared Noise/encryption stack) + implementation(libs.bouncycastle.bcprov) + + // JSON (BitchatMessage model) + implementation(libs.gson) + + // Security preferences (Noise identity persistence) + implementation(libs.androidx.security.crypto) + + // Testing + testImplementation(libs.bundles.testing) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/wear/gradle.lockfile b/wear/gradle.lockfile new file mode 100644 index 00000000..8b9ed0e2 --- /dev/null +++ b/wear/gradle.lockfile @@ -0,0 +1,418 @@ +# 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 :wear:dependencies --write-locks +androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity-ktx:1.13.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.animation:animation-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.animation:animation-core-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.animation:animation-core:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-graphics-android:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.animation:animation-graphics-android:1.9.0=releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-graphics:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.animation:animation-graphics:1.9.0=releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.animation:animation:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.10.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.foundation:foundation:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-android:1.3.1=debugRuntimeClasspath,debugUnitTestRuntimeClasspath +androidx.compose.material3:material3-desktop:1.3.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath +androidx.compose.material3:material3:1.3.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +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.9.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.9.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-geometry-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-geometry:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-graphics-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-graphics:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-text-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-text:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-tooling-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath +androidx.compose.ui:ui-tooling-data-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath +androidx.compose.ui:ui-tooling-data:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath +androidx.compose.ui:ui-tooling-preview-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-preview-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath +androidx.compose.ui:ui-tooling-preview:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-preview:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath +androidx.compose.ui:ui-tooling:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath +androidx.compose.ui:ui-unit-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-unit:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-util-android:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui-util:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.compose.ui:ui:1.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-shapes-android:1.0.1=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-shapes-desktop:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath +androidx.graphics:graphics-shapes:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-java8:2.11.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.startup:startup-runtime:1.2.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath +androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.wear.compose:compose-foundation:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.wear.compose:compose-material-core:1.6.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.wear.compose:compose-material3:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.wear:wear-tooling-preview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window:1.5.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,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.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: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,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.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.8.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,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.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.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,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.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.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=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.squareup.okhttp3:okhttp:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.squareup.okio:okio-jvm:3.17.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +com.squareup.okio:okio:3.17.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +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.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,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,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=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 +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=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.hamcrest:hamcrest-library:1.3=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: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: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: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:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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,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,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=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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,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.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 diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro new file mode 100644 index 00000000..001b0a09 --- /dev/null +++ b/wear/proguard-rules.pro @@ -0,0 +1,13 @@ +# Gson reflection targets in the shared bitchat sources (persisted state payloads). +-keep class com.bitchat.android.favorites.** { *; } +-keep class com.bitchat.android.services.SeenMessageStore$* { *; } +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# Kotlin metadata needed by reflection-based serialization. +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Tink references JSR-305 annotations not present on Android. +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.concurrent.GuardedBy diff --git a/wear/src/debug/AndroidManifest.xml b/wear/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..20c0f07f --- /dev/null +++ b/wear/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/PttTestAudioAnalyzer.kt b/wear/src/debug/java/com/bitchat/watch/testhook/PttTestAudioAnalyzer.kt new file mode 100644 index 00000000..231b4a55 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/PttTestAudioAnalyzer.kt @@ -0,0 +1,142 @@ +package com.bitchat.watch.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() + } + } +} diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt new file mode 100644 index 00000000..1f6d5cd8 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -0,0 +1,558 @@ +package com.bitchat.watch.testhook + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.bitchat.android.model.RoutedPacket +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.model.BitchatFilePacket +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.service.WearMeshForegroundService +import com.bitchat.watch.ui.WearPeerIdentityState +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Headless engine behind [WearTestHookReceiver]. Drives [WearMeshService] and observes + * [AppStateStore] flows. Command set mirrors the phone's TestHookDriver (minus file transfer, + * which is deferred on the watch). + */ +object WearTestHookDriver { + + private const val TAG = WearTestHookReceiver.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 + + 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")) + "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")) + "raw_send" -> rawSend(context, intent) + "file_recv" -> fileRecv(context, intent) + "ptt_recv" -> pttRecv(context, intent) + "ptt_send" -> pttSend(context, intent) + "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 { + val mesh = mesh(context) + try { + context.startForegroundService(Intent(context, WearMeshForegroundService::class.java)) + } catch (e: Exception) { + // Background FGS starts are restricted (API 31+); mesh_lab launches the app first, + // but fall back to a service-less mesh start so the command still works. + Log.w(TAG, "foreground service start failed, starting mesh directly: ${e.message}") + } + mesh.startServices() + return ok("start").put("peer_id", mesh.myPeerID) + } + + private fun stop(context: Context): JSONObject { + try { + WearMeshService.peek()?.stopServices() + } catch (e: Exception) { + Log.w(TAG, "stopServices failed: ${e.message}") + } + context.stopService(Intent(context, WearMeshForegroundService::class.java)) + 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", mesh.nickname) + } + + private fun setNickname(context: Context, name: String): JSONObject { + mesh(context).setNickname(name) + AppStateStore.setNickname(name) + 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)) + } + + private suspend fun connect(peerID: String, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS) + val mesh = WearMeshService.peek() ?: return err("connect", "mesh service not running") + // The address↔peer mapping is learned from direct-link announces and can lag + // peer-list discovery (especially right after a restart); poll while announcing. + val deadline = System.currentTimeMillis() + timeoutMs / 2 + var address: String? = mesh.getDeviceAddressForPeer(peerID) + while (address == null && System.currentTimeMillis() < deadline) { + mesh.sendBroadcastAnnounce() + delay(1_000) + address = mesh.getDeviceAddressForPeer(peerID) + } + if (address == null) { + return err("connect", "no device address known for peer $peerID (scan first)") + } + val accepted = mesh.connectToPeer(peerID) + 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): JSONObject { + mesh(context).sendChannelMessage(content, emptyList(), null) + return ok("broadcast_msg").put("content", content) + } + + 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.sendPrivateMessageWithId(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 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)) + } + val found = withTimeoutOrNull(timeoutMs) { + 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("msg_id", found.id) + } + + // MARK: - Favorite and verification state + + private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + if (!WearPeerIdentityState.setFavorite(peerID, enabled, mesh)) { + return err("favorite_set", "peer Noise identity is unavailable") + } + return favoriteStatus(context, peerID) + } + + private fun favoriteStatus(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + val identity = WearPeerIdentityState.snapshot(peerID, mesh) + return ok("favorite_status") + .put("peer", peerID) + .put("is_favorite", identity.isFavorite) + .put("they_favorited_us", identity.theyFavoritedUs) + .put("is_mutual", identity.isFavorite && identity.theyFavoritedUs) + .put( + "star_state", + when (identity.favoriteIndicator) { + com.bitchat.watch.ui.FavoriteIndicator.Favorite -> "filled" + com.bitchat.watch.ui.FavoriteIndicator.FavoritedUs -> "outlined_orange" + com.bitchat.watch.ui.FavoriteIndicator.None -> "outlined" + } + ) + } + + private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + if (!WearPeerIdentityState.setVerified(peerID, enabled, mesh)) { + return err("verification_set", "peer fingerprint is unavailable") + } + return verificationStatus(context, peerID) + } + + private fun verificationStatus(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + WearPeerIdentityState.initialize(context) + val identity = WearPeerIdentityState.snapshot(peerID, mesh) + return ok("verification_status") + .put("peer", peerID) + .put("fingerprint", identity.fingerprint ?: JSONObject.NULL) + .put("verified", identity.isVerified) + } + + // 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: - File transfer (receive only; the watch does not send files via test hook) + + private suspend fun fileRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L) + 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", + java.security.MessageDigest.getInstance("SHA-256") + .digest(candidate.readBytes()).toHex() + ) + } + } + delay(250) + } + return err("file_recv", "timeout after ${timeoutMs}ms") + } + + private suspend fun pttRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L) + 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 { + kotlinx.coroutines.withContext(kotlinx.coroutines.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) + } + + private suspend fun pttSend(context: Context, intent: Intent): JSONObject { + val peerID = intent.getStringExtra("peer") + val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L) + val mesh = mesh(context) + LiveVoicePreferences.setEnabled(context, true) + if (peerID != null && !mesh.hasEstablishedSession(peerID)) { + val handshake = handshake(context, peerID, intent) + if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send") + } + val recorder = LiveVoiceCapture( + File(context.filesDir, "voicenotes/outgoing"), + LiveVoiceTarget { payload -> mesh.sendVoiceFrame(peerID, payload) }, + 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 = finalFile.readBytes() + val packet = BitchatFilePacket( + fileName = finalFile.name, + fileSize = content.size.toLong(), + mimeType = "audio/mp4", + content = content + ) + if (peerID == null) mesh.sendFileBroadcast(packet) + else mesh.sendFilePrivateEncrypted(peerID, packet) + return ok("ptt_send") + .put("live", true) + .put("scope", if (peerID == 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) + } + + // MARK: - State + + 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", mesh.nickname) + .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): WearMeshService = WearMeshService.getOrCreate(context) + + private fun peerInfosJson(mesh: WearMeshService, peerIds: List): 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 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 + } + } +} diff --git a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt new file mode 100644 index 00000000..080185d0 --- /dev/null +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookReceiver.kt @@ -0,0 +1,61 @@ +package com.bitchat.watch.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 for the watch app (debug builds only). Mirrors the phone's protocol: + * + * adb shell am broadcast -a com.bitchat.watch.TEST_HOOK \ + * --es cmd --es id [command extras...] + * + * Result is written to cache/testhook/results/.json (readable via run-as com.bitchat.watch) + * and logged under tag TestHook. + */ +class WearTestHookReceiver : BroadcastReceiver() { + + companion object { + const val TAG = "TestHook" + const val ACTION = "com.bitchat.watch.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) { + WearTestHookDriver.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() + pendingResult.finish() + } +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..531286cf --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt new file mode 100644 index 00000000..a6e8a164 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt @@ -0,0 +1,45 @@ +package com.bitchat.android.mesh + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat + +/** + * Wear variant of the phone's BluetoothPermissionManager. + * + * The phone version additionally requires ACCESS_FINE/COARSE_LOCATION (legacy BLE scanning + * behavior on older phones). The watch app deliberately declares no location permissions — + * on Wear OS, BLUETOOTH_SCAN with the `neverForLocation` flag is sufficient — so only the + * Bluetooth runtime permissions are checked here. + * + * Same fully-qualified name as the phone class, which is excluded from the wear shared-source + * sync (see wear/build.gradle.kts), so there is exactly one definition in this compilation. + */ +class BluetoothPermissionManager(private val context: Context) { + + fun hasBluetoothPermissions(): Boolean { + val permissions = mutableListOf() + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + permissions.addAll( + listOf( + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN + ) + ) + } else { + permissions.addAll( + listOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN + ) + ) + } + + return permissions.all { + ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt new file mode 100644 index 00000000..bd640a84 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -0,0 +1,17 @@ +package com.bitchat.android.service + +/** + * Wear shim for the phone's MeshServiceHolder. + * + * The shared `DebugSettingsManager` (compiled from app sources) references this holder only to + * toggle BLE transport from the phone's debug UI, which does not exist on the watch. The real + * holder is typed against `BluetoothMeshService`, which the watch deliberately does not include. + */ +object MeshServiceHolder { + + interface BleToggle { + fun setBleTransportEnabled(enabled: Boolean) + } + + val meshService: BleToggle? = null +} diff --git a/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt b/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt new file mode 100644 index 00000000..2b491274 --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/wifiaware/WifiAwareController.kt @@ -0,0 +1,11 @@ +package com.bitchat.android.wifiaware + +/** + * Wear shim for the phone's WifiAwareController. + * + * Referenced only by the shared `DebugSettingsManager` debug-UI toggle. Wi-Fi Aware is out of + * scope for the watch (Bluetooth mesh only), so this is a no-op. + */ +object WifiAwareController { + fun setEnabled(value: Boolean) = Unit +} diff --git a/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt new file mode 100644 index 00000000..8a52c1d8 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/BitchatWatchApplication.kt @@ -0,0 +1,15 @@ +package com.bitchat.watch + +import android.app.Application +import com.bitchat.android.mesh.PowerManager +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.ui.WearPeerIdentityState + +class BitchatWatchApplication : Application() { + override fun onCreate() { + super.onCreate() + PowerManager.getInstance(applicationContext) + WearNotificationCoordinator.getInstance(applicationContext) + WearPeerIdentityState.initialize(applicationContext) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt new file mode 100644 index 00000000..dc07a949 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -0,0 +1,566 @@ +package com.bitchat.watch + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.OutlinedButton +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.service.WearMeshForegroundService +import com.bitchat.watch.ui.ChatScreen +import com.bitchat.watch.ui.DmScreen +import com.bitchat.watch.ui.NicknameSetupScreen +import com.bitchat.watch.ui.PeopleScreen +import com.bitchat.watch.ui.UserDetailScreen +import com.bitchat.watch.ui.VerificationCodeScreen +import com.bitchat.watch.ui.WearChatState +import com.bitchat.watch.ui.WearFormScreen +import com.bitchat.watch.ui.sendPrivateMessage +import com.bitchat.watch.ui.sendPublicMessage +import com.bitchat.watch.ui.theme.BitchatWearTheme + +sealed interface WearScreen { + data object Chat : WearScreen + data object People : WearScreen + data object Nickname : WearScreen + data class Dm(val peerID: String) : WearScreen + data class UserDetail(val peerID: String) : WearScreen + data class Verification(val peerID: String) : WearScreen + data class TextInput(val peerID: String?) : WearScreen +} + +class MainActivity : ComponentActivity() { + + private var hasPermissions by mutableStateOf(false) + private var bluetoothEnabled by mutableStateOf(false) + private var nicknameChosen by mutableStateOf(false) + private var notificationsGranted by mutableStateOf(false) + private var notificationPromptDismissed by mutableStateOf(false) + private var pendingLaunchRequest by mutableStateOf(null) + private var nextLaunchRequestID = 0L + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + nicknameChosen = getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) + .getBoolean("nickname_chosen", false) + pendingLaunchRequest = restoreWearLaunchRequest( + savedInstanceState?.getStringArrayList(KEY_PENDING_LAUNCH_REQUEST) + ) + nextLaunchRequestID = maxOf( + savedInstanceState?.getLong(KEY_NEXT_LAUNCH_REQUEST_ID, 0L) ?: 0L, + pendingLaunchRequest?.id ?: 0L + ) + val notificationPeer = consumePrivateMessagePeer(intent) + if (savedInstanceState == null && notificationPeer != null) { + requestLaunch(WearLaunchTarget.Dm(notificationPeer)) + } + refreshState() + setContent { + BitchatWearTheme { + when { + !hasPermissions -> PermissionRequestScreen(onGranted = { refreshState() }) + !bluetoothEnabled -> BluetoothEnableScreen(onEnabled = { refreshState() }) + !nicknameChosen -> NicknameSetupScreen( + initialNickname = WearMeshService.getOrCreate(applicationContext).nickname + ) { name -> + WearMeshService.getOrCreate(applicationContext).setNickname(name) + getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) + .edit().putBoolean("nickname_chosen", true).apply() + nicknameChosen = true + } + !notificationsGranted && !notificationPromptDismissed -> + NotificationPermissionScreen( + onResult = { granted -> + notificationPromptDismissed = !granted + refreshState() + }, + onSkip = { notificationPromptDismissed = true } + ) + else -> WearNavHost( + launchRequest = pendingLaunchRequest, + onLaunchRequestHandled = { requestID -> + if (pendingLaunchRequest?.id == requestID) { + pendingLaunchRequest = null + } + } + ) + } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putLong(KEY_NEXT_LAUNCH_REQUEST_ID, nextLaunchRequestID) + pendingLaunchRequest?.let { + outState.putStringArrayList( + KEY_PENDING_LAUNCH_REQUEST, + it.toSavedStateValues() + ) + } + super.onSaveInstanceState(outState) + } + + override fun onResume() { + super.onResume() + com.bitchat.android.features.voice.LiveVoiceManager.getInstance(applicationContext) + .setAppForeground(true) + WearChatState.setAppInForeground(true) + WearChatState.openDmPeer?.let { peerID -> + WearChatState.openDm(peerID) + WearNotificationCoordinator.getInstance(applicationContext).clearConversation(peerID) + } + refreshState() + } + + override fun onPause() { + com.bitchat.android.features.voice.LiveVoiceManager.getInstance(applicationContext) + .setAppForeground(false) + WearChatState.setAppInForeground(false) + super.onPause() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + val notificationPeer = consumePrivateMessagePeer(intent) + requestLaunch( + notificationPeer?.let(WearLaunchTarget::Dm) ?: WearLaunchTarget.Chat + ) + } + + private fun refreshState() { + hasPermissions = requiredPermissions().all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + notificationsGranted = notificationPermissionGranted() + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + bluetoothEnabled = adapter?.isEnabled == true + if (hasPermissions && bluetoothEnabled) { + startMeshService() + } + } + + private fun startMeshService() { + WearMeshService.getOrCreate(applicationContext) + startForegroundService(Intent(this, WearMeshForegroundService::class.java)) + } + + private fun consumePrivateMessagePeer(intent: Intent?): String? { + val peerID = privateMessagePeerFromIntent(intent) + intent?.removeExtra(WearNotificationCoordinator.EXTRA_OPEN_DM) + intent?.removeExtra(WearNotificationCoordinator.EXTRA_PEER_ID) + return peerID + } + + private fun privateMessagePeerFromIntent(intent: Intent?): String? { + if (intent?.getBooleanExtra(WearNotificationCoordinator.EXTRA_OPEN_DM, false) != true) { + return null + } + return intent.getStringExtra(WearNotificationCoordinator.EXTRA_PEER_ID) + ?.takeIf { it.isNotBlank() } + } + + private fun requestLaunch(target: WearLaunchTarget) { + pendingLaunchRequest = WearLaunchRequest( + id = ++nextLaunchRequestID, + target = target + ) + } + + private fun notificationPermissionGranted(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + companion object { + private const val KEY_NEXT_LAUNCH_REQUEST_ID = "next_launch_request_id" + private const val KEY_PENDING_LAUNCH_REQUEST = "pending_launch_request" + + fun requiredPermissions(): List = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add(Manifest.permission.BLUETOOTH_SCAN) + add(Manifest.permission.BLUETOOTH_CONNECT) + add(Manifest.permission.BLUETOOTH_ADVERTISE) + } + } + } +} + +internal sealed interface WearLaunchTarget { + data object Chat : WearLaunchTarget + data class Dm(val peerID: String) : WearLaunchTarget +} + +internal data class WearLaunchRequest( + val id: Long, + val target: WearLaunchTarget +) + +internal fun WearLaunchRequest.toSavedStateValues(): ArrayList { + val (type, peerID) = when (val launchTarget = target) { + WearLaunchTarget.Chat -> "chat" to "" + is WearLaunchTarget.Dm -> "dm" to launchTarget.peerID + } + return arrayListOf(id.toString(), type, peerID) +} + +internal fun restoreWearLaunchRequest(values: List?): WearLaunchRequest? { + if (values?.size != 3) return null + val id = values[0].toLongOrNull()?.takeIf { it > 0L } ?: return null + val target = when (values[1]) { + "chat" -> WearLaunchTarget.Chat + "dm" -> values[2] + .takeIf(String::isNotBlank) + ?.let(WearLaunchTarget::Dm) + ?: return null + else -> return null + } + return WearLaunchRequest(id = id, target = target) +} + +internal class WearNavigationState( + initialScreen: WearScreen = WearScreen.Chat, + initialBackStack: List = emptyList() +) { + private val backStack = mutableStateListOf().apply { + addAll(initialBackStack) + } + + var screen by mutableStateOf(initialScreen) + private set + + val canGoBack: Boolean + get() = screen != WearScreen.Chat || backStack.isNotEmpty() + + fun navigate(to: WearScreen) { + backStack.add(screen) + screen = to + } + + fun openChat() { + backStack.clear() + screen = WearScreen.Chat + } + + fun openDmFromNotification(peerID: String) { + backStack.clear() + backStack.add(WearScreen.Chat) + screen = WearScreen.Dm(peerID) + } + + fun goBack(): Boolean { + if (screen is WearScreen.Dm) { + openChat() + return true + } + + val previous = backStack.removeLastOrNull() ?: return false + screen = previous + return true + } + + internal fun toSavedStateValues(): List { + return buildList { + add(SAVED_STATE_VERSION) + (listOf(screen) + backStack).forEach { savedScreen -> + val (type, peerID) = encodeScreen(savedScreen) + add(type) + add(peerID) + } + } + } + + companion object { + private const val SAVED_STATE_VERSION = "1" + + val Saver = listSaver( + save = { it.toSavedStateValues() }, + restore = ::restore + ) + + internal fun restore(values: List): WearNavigationState? { + if ( + values.firstOrNull() != SAVED_STATE_VERSION || + values.size < 3 || + (values.size - 1) % 2 != 0 + ) { + return null + } + + val screens = mutableListOf() + var index = 1 + while (index < values.size) { + val restoredScreen = decodeScreen( + type = values[index], + peerID = values[index + 1] + ) ?: return null + screens += restoredScreen + index += 2 + } + return WearNavigationState( + initialScreen = screens.first(), + initialBackStack = screens.drop(1) + ) + } + + private fun encodeScreen(screen: WearScreen): Pair = when (screen) { + WearScreen.Chat -> "chat" to "" + WearScreen.People -> "people" to "" + WearScreen.Nickname -> "nickname" to "" + is WearScreen.Dm -> "dm" to screen.peerID + is WearScreen.UserDetail -> "user_detail" to screen.peerID + is WearScreen.Verification -> "verification" to screen.peerID + is WearScreen.TextInput -> screen.peerID?.let { "text_dm" to it } + ?: ("text_public" to "") + } + + private fun decodeScreen(type: String, peerID: String): WearScreen? = when (type) { + "chat" -> WearScreen.Chat + "people" -> WearScreen.People + "nickname" -> WearScreen.Nickname + "dm" -> peerID.takeIf(String::isNotBlank)?.let(WearScreen::Dm) + "user_detail" -> peerID + .takeIf(String::isNotBlank) + ?.let(WearScreen::UserDetail) + "verification" -> peerID + .takeIf(String::isNotBlank) + ?.let(WearScreen::Verification) + "text_dm" -> peerID.takeIf(String::isNotBlank)?.let(WearScreen::TextInput) + "text_public" -> WearScreen.TextInput(null) + else -> null + } + } +} + +@Composable +internal fun WearNavHost( + launchRequest: WearLaunchRequest?, + onLaunchRequestHandled: (Long) -> Unit +) { + val navigation = rememberSaveable(saver = WearNavigationState.Saver) { + WearNavigationState() + } + + BackHandler(enabled = navigation.canGoBack) { navigation.goBack() } + + LaunchedEffect(launchRequest) { + launchRequest?.let { request -> + when (val target = request.target) { + WearLaunchTarget.Chat -> navigation.openChat() + is WearLaunchTarget.Dm -> navigation.openDmFromNotification(target.peerID) + } + onLaunchRequestHandled(request.id) + } + } + + AnimatedContent( + targetState = navigation.screen, + transitionSpec = { + fadeIn(tween(com.bitchat.watch.ui.theme.BitchatMotion.EMPHASIZED_MS)) togetherWith + fadeOut(tween(com.bitchat.watch.ui.theme.BitchatMotion.QUICK_MS)) + }, + label = "screenTransition" + ) { current -> + when (current) { + is WearScreen.Chat -> ChatScreen( + onOpenPeople = { navigation.navigate(WearScreen.People) }, + onOpenTextInput = { navigation.navigate(WearScreen.TextInput(null)) } + ) + is WearScreen.People -> PeopleScreen( + onOpenDm = { navigation.navigate(WearScreen.Dm(it)) }, + onEditNickname = { navigation.navigate(WearScreen.Nickname) } + ) + is WearScreen.Nickname -> { + val mesh = WearMeshService.peek() + NicknameSetupScreen( + initialNickname = mesh?.nickname ?: "", + title = "You", + subtitle = "How nearby peers see you", + confirmLabel = "Save", + onConfirm = { name -> + mesh?.setNickname(name) + navigation.goBack() + } + ) + } + is WearScreen.Dm -> DmScreen( + peerID = current.peerID, + onOpenUserDetail = { + navigation.navigate(WearScreen.UserDetail(current.peerID)) + }, + onOpenTextInput = { + navigation.navigate(WearScreen.TextInput(current.peerID)) + } + ) + is WearScreen.UserDetail -> UserDetailScreen( + peerID = current.peerID, + onOpenVerification = { + navigation.navigate(WearScreen.Verification(current.peerID)) + } + ) + is WearScreen.Verification -> VerificationCodeScreen(peerID = current.peerID) + is WearScreen.TextInput -> { + val mesh = WearMeshService.peek() + val sendScope = androidx.compose.runtime.rememberCoroutineScope() + com.bitchat.watch.ui.TextInputScreen( + onSend = { text -> + mesh?.let { m -> + if (current.peerID == null) { + sendPublicMessage(m, text) + } else { + val nick = m.getPeerNickname(current.peerID) ?: current.peerID + sendPrivateMessage(m, current.peerID, nick, text, sendScope) + } + } + navigation.goBack() + } + ) + } + } + } +} + +@Composable +fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) { + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + onResult(granted) + } + + WearFormScreen { + item { + Text( + text = "Message alerts", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + item { + Text( + text = "Alerts for encrypted direct messages", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp, bottom = 10.dp), + ) + } + item { + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + onResult(true) + } + } + ) { + Text("Enable") + } + } + item { + OutlinedButton(onClick = onSkip) { + Text("Not now") + } + } + } +} + +@Composable +fun PermissionRequestScreen(onGranted: () -> Unit) { + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + onGranted() + } + + WearFormScreen { + item { + Text( + text = "bitchat", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + item { + Text( + text = "Needs Bluetooth to mesh with nearby devices", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp, bottom = 12.dp), + ) + } + item { + Button( + onClick = { + launcher.launch(MainActivity.requiredPermissions().toTypedArray()) + } + ) { + Text("Grant access", textAlign = TextAlign.Center) + } + } + } +} + +@Composable +fun BluetoothEnableScreen(onEnabled: () -> Unit) { + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + onEnabled() + } + + WearFormScreen { + item { + Text( + text = "Bluetooth is off", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + item { + Button( + onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }, + modifier = Modifier.padding(top = 10.dp), + ) { + Text("Turn on") + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt new file mode 100644 index 00000000..eeb09295 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -0,0 +1,441 @@ +package com.bitchat.watch.mesh + +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.util.Log +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.favorites.FavoriteControlMessage +import com.bitchat.android.mesh.BluetoothConnectionManager +import com.bitchat.android.mesh.BluetoothConnectionManagerDelegate +import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy +import com.bitchat.android.mesh.MeshCore +import com.bitchat.android.mesh.MeshTransport +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Watch mesh service: composes the shared BLE transport (BluetoothConnectionManager) with the + * shared mesh coordinator (MeshCore), mirroring how the phone's Wi-Fi Aware service is built. + * Bluetooth mesh only — no internet, no other transports. + */ +class WearMeshService private constructor(private val context: Context) { + + companion object { + private const val TAG = "WearMeshService" + private val MAX_TTL: UByte = AppConstants.MESSAGE_TTL_HOPS + private val PEER_DISCONNECT_GRACE_MS: Long = AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS + + @Volatile + private var instance: WearMeshService? = null + + fun getOrCreate(context: Context): WearMeshService { + return instance ?: synchronized(this) { + instance ?: WearMeshService(context.applicationContext).also { instance = it } + } + } + + fun peek(): WearMeshService? = instance + } + + val encryptionService = EncryptionService(context) + val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val bleTransport = BleTransport() + private val meshCore: MeshCore + private var connectionManager: BluetoothConnectionManager + + @Volatile + var nickname: String = loadNickname() + private set + + @Volatile + private var isActive = false + + /** UI hook fired for every incoming private message (after storing). */ + var onPrivateMessage: ((com.bitchat.android.model.BitchatMessage) -> Unit)? = null + + init { + meshCore = MeshCore( + context = context.applicationContext, + scope = serviceScope, + transport = bleTransport, + encryptionService = encryptionService, + myPeerID = myPeerID, + maxTtl = MAX_TTL, + sharedGossipManager = null, + gossipConfigProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + }, + hooks = MeshCore.Hooks( + onMessageReceived = { message -> handleMessageReceived(message) }, + onAnnounceProcessed = { routed, _ -> + // Mirror the phone's BluetoothMeshService: learn the direct BLE + // address↔peerID mapping from direct-link announcements. + DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { obs -> + val observed = connectionManager.observePeerIfCurrent( + obs.relayAddress, + obs.ingressLinkID, + obs.peerID + ) + if (observed) { + meshCore.setDirectConnection(obs.peerID, true) + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(obs.peerID, 1_000) + } catch (_: Exception) { } + } + } + routed.peerID?.let { pid -> + maybeAutoHandshake(pid) + try { + meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) + } catch (_: Exception) { } + } + }, + announcementNicknameProvider = { nickname }, + leavePayloadProvider = { nickname.toByteArray(Charsets.UTF_8) } + ) + ) + connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) + bleTransport.connectionManager = connectionManager + wireBluetoothDelegate() + } + + private inner class BleTransport : MeshTransport { + lateinit var connectionManager: BluetoothConnectionManager + + override val id: String = "BLE" + + override fun broadcastPacket(routed: RoutedPacket): Boolean = + connectionManager.broadcastPacket(routed) + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean = + connectionManager.sendPacketToPeer(peerID, packet) + + override fun sendPacketToLink( + relayAddress: String, + ingressLinkID: String, + packet: BitchatPacket + ): Boolean = connectionManager.sendPacketToLink(relayAddress, ingressLinkID, packet) + + override fun cancelTransfer(transferId: String): Boolean = + connectionManager.cancelTransfer(transferId) + + override fun getDeviceAddressForPeer(peerID: String): String? = + connectionManager.addressPeerMap.entries.firstOrNull { it.value == peerID }?.key + + override fun getDeviceAddressToPeerMapping(): Map = + connectionManager.addressPeerMap.toMap() + + override fun getTransportDebugInfo(): String = connectionManager.getDebugInfo() + } + + private fun wireBluetoothDelegate() { + connectionManager.delegate = object : BluetoothConnectionManagerDelegate { + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) { + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( + packet = packet, + fromPeerID = peerID, + fromNickname = null, + fromDeviceAddress = device?.address, + myPeerID = myPeerID + ) + } catch (_: Exception) { } + meshCore.processIncoming(packet, peerID, device?.address, ingressLinkID) + } + + override fun onDeviceConnected(device: BluetoothDevice) { + Log.i(TAG, "Device connected: ${device.address}") + serviceScope.launch { + delay(200) + meshCore.sendBroadcastAnnounce() + } + } + + override fun onDeviceDisconnected( + device: BluetoothDevice, + linkID: String?, + peerID: String? + ) { + Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)") + try { meshCore.refreshPeerList() } catch (_: Exception) { } + if (peerID != null) { + meshCore.setDirectConnection(peerID, false) + val deviceAddress = device.address + serviceScope.launch { + delay(PEER_DISCONNECT_GRACE_MS) + try { + val linkBack = + connectionManager.addressPeerMap.containsKey(deviceAddress) || + connectionManager.addressPeerMap.containsValue(peerID) + if (!linkBack) { + Log.i(TAG, "Peer $peerID did not return after disconnect; removing") + meshCore.removePeer(peerID) + } + } catch (e: Exception) { + Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}") + } + } + } + } + + override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { + connectionManager.addressPeerMap[deviceAddress]?.let { peerID -> + meshCore.updatePeerRSSI(peerID, rssi) + } + } + } + } + + /** + * Proactively establish a Noise session with peers we have no session for (throttled to + * one attempt per peer per 60 s). Peers may hold a stale session after we restart — the + * protocol has no decrypt-failure kick path, so our fresh handshake replaces it and + * restores encrypted DM/file delivery. + */ + private val handshakeAttempts = java.util.concurrent.ConcurrentHashMap() + + private fun maybeAutoHandshake(peerID: String) { + if (peerID == myPeerID || hasEstablishedSession(peerID)) return + val now = System.currentTimeMillis() + val last = handshakeAttempts[peerID] ?: 0L + if (now - last < 60_000) return + handshakeAttempts[peerID] = now + serviceScope.launch { + delay(1_500) + if (!hasEstablishedSession(peerID)) { + try { + Log.d(TAG, "Auto-initiating Noise handshake with ${peerID.take(8)}") + initiateNoiseHandshake(peerID) + } catch (_: Exception) { } + } + } + } + + private fun handleMessageReceived( + message: com.bitchat.android.model.BitchatMessage + ): Boolean = try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: return false + if (!AppStateStore.addPrivateMessage(peer, message)) return false + try { onPrivateMessage?.invoke(message) } catch (_: Exception) { } + true + } + message.channel != null -> { + AppStateStore.addChannelMessage(message.channel, message) + true + } + else -> { + AppStateStore.addPublicMessage(message) + true + } + } + } catch (_: Exception) { + !message.isPrivate + } + + fun startServices() { + if (isActive) { + Log.w(TAG, "Mesh already active, ignoring duplicate start") + return + } + if (!connectionManager.isReusable()) { + // A previous stopServices() cancelled the manager's coroutine scope; the shared + // API marks such managers single-use, so build a fresh one instead of starting + // a zombie mesh that reports active while scanning nothing. + Log.i(TAG, "Recreating BluetoothConnectionManager after terminal stop") + connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) + bleTransport.connectionManager = connectionManager + wireBluetoothDelegate() + } + val started = connectionManager.startServices() + if (started) { + isActive = true + meshCore.startCore() + serviceScope.launch { + delay(500) + meshCore.sendBroadcastAnnounce() + } + Log.i(TAG, "Mesh services started (peerID: $myPeerID)") + } else { + Log.e(TAG, "Failed to start Bluetooth services (permissions? BT off?)") + } + } + + fun stopServices() { + if (!isActive) return + isActive = false + meshCore.stopCore() + connectionManager.stopServices() + Log.i(TAG, "Mesh services stopped") + } + + fun isRunning(): Boolean = isActive + + fun setNickname(name: String) { + val trimmed = name.trim().take(32) + if (trimmed.isEmpty() || trimmed == nickname) return + nickname = trimmed + saveNickname(trimmed) + if (isActive) { + serviceScope.launch { meshCore.sendBroadcastAnnounce() } + } + } + + fun sendMessage(content: String, mentions: List = emptyList()) { + meshCore.sendMessage(content, mentions, null) + } + + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname) + } + + /** + * Favorite controls travel as encrypted private control messages. If the profile is opened + * while a handshake is still settling, wait briefly rather than silently losing the change. + */ + fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) { + serviceScope.launch { + val deadline = System.currentTimeMillis() + 15_000L + if (!hasEstablishedSession(peerID)) { + runCatching { initiateNoiseHandshake(peerID) } + } + while (!hasEstablishedSession(peerID) && System.currentTimeMillis() < deadline) { + delay(250L) + } + if (!hasEstablishedSession(peerID)) { + Log.w(TAG, "Favorite update could not be sent before the Noise timeout") + return@launch + } + val recipientNickname = getPeerNickname(peerID) ?: peerID.take(8) + meshCore.sendPrivateMessage( + content = FavoriteControlMessage.encode(isFavorite, npub = null), + recipientPeerID = peerID, + recipientNickname = recipientNickname + ) + } + } + + fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID) + + fun hasEstablishedSession(peerID: String): Boolean = meshCore.hasEstablishedSession(peerID) + + fun getSessionState(peerID: String) = meshCore.getSessionState(peerID) + + fun getPeerInfo(peerID: String) = meshCore.getPeerInfo(peerID) + + fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + + fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey() + + fun sendBroadcastAnnounce() = meshCore.sendBroadcastAnnounce() + + fun sendChannelMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + meshCore.sendMessage(content, mentions, channel) + } + + fun sendPrivateMessageWithId( + content: String, + recipientPeerID: String, + recipientNickname: String, + messageID: String? + ) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + + fun getDeviceAddressForPeer(peerID: String): String? = meshCore.getDeviceAddressForPeer(peerID) + + fun getDeviceAddressToPeerMapping(): Map = meshCore.getDeviceAddressToPeerMapping() + + fun connectToPeer(peerID: String): Boolean { + val address = getDeviceAddressForPeer(peerID) ?: return false + return connectionManager.connectToAddress(address) + } + + fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { + meshCore.sendFileBroadcast(file) + } + + fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + meshCore.sendVoiceFrame(recipientPeerID, payload) + } + + /** + * Noise-encrypted private file transfer with session/prep retry (mirrors the phone's + * dispatchFileSend): ensures an established session, then retries transient + * preparation states (AwaitingPeerState/NeedsHandshake) before giving up. + */ + fun sendFilePrivateEncrypted(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { + serviceScope.launch { + val sessionDeadline = System.currentTimeMillis() + 15_000 + while (!hasEstablishedSession(recipientPeerID) && System.currentTimeMillis() < sessionDeadline) { + try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { } + delay(500) + } + val transferId = com.bitchat.android.mesh.MeshPacketUtils.sha256Hex( + file.encode() ?: return@launch + ) + val prepDeadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < prepDeadline) { + when (val prep = meshCore.prepareFilePrivate(recipientPeerID, file, transferId, allowLegacyFallback = false)) { + is com.bitchat.android.mesh.PrivateMediaPreparation.Ready -> { + prep.transfer.commit() + return@launch + } + com.bitchat.android.mesh.PrivateMediaPreparation.AwaitingPeerState, + com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake -> { + if (prep == com.bitchat.android.mesh.PrivateMediaPreparation.NeedsHandshake) { + try { initiateNoiseHandshake(recipientPeerID) } catch (_: Exception) { } + } + delay(500) + } + else -> { + Log.w(TAG, "private voice note preparation failed: $prep") + return@launch + } + } + } + Log.w(TAG, "private voice note preparation timed out") + } + } + + fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID) + + fun getPeerNicknames(): Map = meshCore.getPeerNicknames() + + fun getPeerRSSI(): Map = meshCore.getPeerRSSI() + + fun getPeerNickname(peerID: String): String? = meshCore.getPeerNickname(peerID) + + fun getDebugStatus(): String = meshCore.getDebugStatus( + transportInfo = connectionManager.getDebugInfo(), + deviceMap = connectionManager.addressPeerMap.toMap(), + title = "Wear BLE Mesh Debug Status" + ) + + private fun prefs() = context.getSharedPreferences("bitchat_watch_prefs", Context.MODE_PRIVATE) + + private fun loadNickname(): String = + prefs().getString("nickname", null) ?: "watch-${myPeerID.take(4)}" + + private fun saveNickname(name: String) { + prefs().edit().putString("nickname", name).apply() + } +} diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt new file mode 100644 index 00000000..69f4ed9b --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationCoordinator.kt @@ -0,0 +1,176 @@ +package com.bitchat.watch.notification + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.content.ContextCompat +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.NotificationTextUtils +import com.bitchat.watch.MainActivity +import com.bitchat.watch.R +import com.bitchat.watch.ui.WearChatState +import java.util.concurrent.ConcurrentHashMap + +/** + * Process-wide owner for local Wear notifications. + * + * Mesh delivery must not depend on an Activity being alive, so the foreground service invokes this + * coordinator directly. UI state is consulted only to suppress an alert for the exact DM that is + * currently visible while the app is resumed. + */ +class WearNotificationCoordinator private constructor(context: Context) { + + companion object { + const val EXTRA_OPEN_DM = "com.bitchat.watch.extra.OPEN_DM" + const val EXTRA_PEER_ID = "com.bitchat.watch.extra.PEER_ID" + + private const val MESSAGE_CHANNEL_ID = "bitchat_watch_messages" + private const val CONVERSATION_NOTIFICATION_ID_BASE = 10_000 + + @Volatile + private var instance: WearNotificationCoordinator? = null + + fun getInstance(context: Context): WearNotificationCoordinator { + return instance ?: synchronized(this) { + instance ?: WearNotificationCoordinator(context.applicationContext).also { + instance = it + } + } + } + } + + private data class PendingMessage( + val senderNickname: String, + val preview: String, + val timestamp: Long + ) + + private val appContext = context.applicationContext + private val notificationManager = NotificationManagerCompat.from(appContext) + private val pendingMessages = ConcurrentHashMap>() + + init { + createMessageChannel() + } + + @Synchronized + fun onPrivateMessage( + message: BitchatMessage, + senderPeerID: String, + senderNickname: String + ) { + val shouldNotify = WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = senderPeerID, + senderIsSystem = message.sender == "system", + appInForeground = WearChatState.appInForeground, + openDmPeer = WearChatState.openDmPeer + ) + if (!shouldNotify || !canPostNotifications()) return + + pendingMessages.getOrPut(senderPeerID) { mutableListOf() }.add( + PendingMessage( + senderNickname = senderNickname, + preview = NotificationTextUtils.buildPrivateMessagePreview(message), + timestamp = message.timestamp.time + ) + ) + + postConversationNotification(senderPeerID) + } + + @Synchronized + fun clearConversation(peerID: String) { + pendingMessages.remove(peerID) + notificationManager.cancel(conversationNotificationId(peerID)) + } + + private fun postConversationNotification(peerID: String) { + val messages = pendingMessages[peerID] ?: return + val latest = messages.lastOrNull() ?: return + val sender = Person.Builder() + .setName(latest.senderNickname) + .setKey(peerID) + .build() + val user = Person.Builder() + .setName(appContext.getString(R.string.app_name)) + .build() + val style = NotificationCompat.MessagingStyle(user) + messages.takeLast(5).forEach { pending -> + style.addMessage(pending.preview, pending.timestamp, sender) + } + + val contentIntent = PendingIntent.getActivity( + appContext, + conversationNotificationId(peerID), + Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(EXTRA_OPEN_DM, true) + putExtra(EXTRA_PEER_ID, peerID) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val publicVersion = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(appContext.getString(R.string.notification_new_private_message)) + .setContentText(appContext.getString(R.string.notification_private_message_hidden)) + .build() + + val notification = NotificationCompat.Builder(appContext, MESSAGE_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(latest.senderNickname) + .setContentText(latest.preview) + .setContentIntent(contentIntent) + .setStyle(style) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + .setAutoCancel(true) + .setWhen(latest.timestamp) + .setShowWhen(true) + .build() + + try { + notificationManager.notify(conversationNotificationId(peerID), notification) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun createMessageChannel() { + val channel = NotificationChannel( + MESSAGE_CHANNEL_ID, + appContext.getString(R.string.message_channel_name), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = appContext.getString(R.string.message_channel_description) + enableVibration(true) + setShowBadge(true) + lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE + } + appContext.getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + + private fun canPostNotifications(): Boolean { + val permissionGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + appContext, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + return permissionGranted && notificationManager.areNotificationsEnabled() + } + + private fun conversationNotificationId(peerID: String): Int { + return CONVERSATION_NOTIFICATION_ID_BASE + (peerID.hashCode() and 0x3FFFFFFF) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt new file mode 100644 index 00000000..b0edf915 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/notification/WearNotificationPolicy.kt @@ -0,0 +1,18 @@ +package com.bitchat.watch.notification + +/** + * Pure notification decisions shared by the watch service and unit tests. + */ +object WearNotificationPolicy { + fun shouldNotifyPrivateMessage( + senderPeerID: String, + senderIsSystem: Boolean, + appInForeground: Boolean, + openDmPeer: String? + ): Boolean { + if (senderIsSystem) return false + return !appInForeground || openDmPeer != senderPeerID + } + + fun activePeerCount(peers: Collection): Int = peers.distinct().size +} diff --git a/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt new file mode 100644 index 00000000..ff1e1cbb --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/service/WearMeshForegroundService.kt @@ -0,0 +1,172 @@ +package com.bitchat.watch.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.MainActivity +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.notification.WearNotificationPolicy +import com.bitchat.watch.ui.WearChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Keeps the BLE mesh (scan + advertise + GATT) alive while the app is backgrounded or the watch + * goes ambient. Bluetooth mesh only; no internet connectivity is used or declared. + */ +class WearMeshForegroundService : Service() { + + companion object { + const val CHANNEL_ID = "bitchat_mesh" + const val NOTIFICATION_ID = 1 + } + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var notificationCoordinator: WearNotificationCoordinator + private lateinit var mesh: WearMeshService + private var peerCountJob: Job? = null + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + notificationCoordinator = WearNotificationCoordinator.getInstance(applicationContext) + mesh = WearMeshService.getOrCreate(applicationContext) + createChannel() + startForeground(WearNotificationPolicy.activePeerCount(AppStateStore.peers.value)) + observePeerCount() + mesh.onPrivateMessage = ::handlePrivateMessage + mesh.startServices() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + updateForegroundNotification( + WearNotificationPolicy.activePeerCount(AppStateStore.peers.value) + ) + return START_STICKY + } + + override fun onDestroy() { + peerCountJob?.cancel() + peerCountJob = null + if (::mesh.isInitialized) { + mesh.onPrivateMessage = null + mesh.stopServices() + } + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.mesh_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.mesh_channel_description) + setShowBadge(false) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun startForeground(activePeers: Int) { + val notification = buildNotification(activePeers) + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + } + + private fun observePeerCount() { + peerCountJob = serviceScope.launch { + AppStateStore.peers + .map(WearNotificationPolicy::activePeerCount) + .distinctUntilChanged() + .collect(::updateForegroundNotification) + } + } + + private fun updateForegroundNotification(activePeers: Int) { + if (!canPostNotifications()) return + try { + notificationManager.notify(NOTIFICATION_ID, buildNotification(activePeers)) + } catch (_: SecurityException) { + // Permission can be revoked between the preflight check and notify(). + } + } + + private fun handlePrivateMessage(message: BitchatMessage) { + val senderPeerID = message.senderPeerID ?: return + if (message.sender == "system") return + + WearChatState.onPrivateMessageArrived(senderPeerID) + val senderNickname = mesh.getPeerNickname(senderPeerID) + ?: message.sender.takeIf { it.isNotBlank() && it != senderPeerID } + ?: senderPeerID.take(8) + notificationCoordinator.onPrivateMessage( + message = message, + senderPeerID = senderPeerID, + senderNickname = senderNickname + ) + } + + private fun buildNotification(activePeers: Int): Notification { + val launchIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(getString(R.string.app_name)) + .setContentText( + if (activePeers == 0) { + getString(R.string.mesh_notification_no_peers) + } else { + resources.getQuantityString( + R.plurals.mesh_notification_text, + activePeers, + activePeers + ) + } + ) + .setContentIntent(launchIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + private fun canPostNotifications(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt new file mode 100644 index 00000000..03943ea0 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/BottomBarVisibility.kt @@ -0,0 +1,34 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow + +/** + * Scroll-aware bottom-bar visibility (typical dynamic-hide pattern): the bar hides while the + * user scrolls into history and reappears when they scroll back toward the newest messages. + * Always visible at the bottom (newest). + * + * Lists are normal (top-down) scrollables: value 0 = oldest, maxValue = newest (visual bottom). + */ +@Composable +fun rememberBottomBarVisibility(scrollState: ScrollState): State { + val visible = remember { mutableStateOf(true) } + LaunchedEffect(scrollState) { + var last = 0 + snapshotFlow { scrollState.value to scrollState.maxValue }.collect { (value, max) -> + val atNewest = max - value < 40 + when { + atNewest -> visible.value = true + value < last - 24 -> visible.value = false // scrolling up into history + value > last + 24 -> visible.value = true // back down toward newest + } + last = value + } + } + return visible +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt new file mode 100644 index 00000000..3f6deead --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -0,0 +1,287 @@ +package com.bitchat.watch.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Mic +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.watch.ui.media.WaveformBars +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +/** + * Native Wear bottom action bar (designed for the ScreenScaffold `edgeButton` slot): a keyboard + * button that opens the text input screen, and a push-to-talk mic button — press and hold to + * record, release to send. Recording state lives in [VoiceNoteController], hoisted at screen + * level so [VoiceRecordOverlay] can render full-screen outside this slot. + */ +@Composable +fun ChatActionBar( + onKeyboard: () -> Unit, + voice: VoiceNoteController, + busyTalker: String? = null, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val palette = LocalBitchatPalette.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> if (granted) voice.start() } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(palette.inputButton) + .clickable { onKeyboard() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Keyboard, + contentDescription = "type message", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background( + when { + voice.recording -> MaterialTheme.colorScheme.primary + busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300).copy(alpha = 0.28f) + else -> palette.inputButton + } + ) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val granted = ContextCompat.checkSelfPermission( + context, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + voice.start() + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + // Only a clean release stops here. If the scroll parent steals + // the pointer mid-drag (cancel), keep recording — the + // screen-level release watcher in ChatScaffold stops when the + // finger actually lifts, anywhere on the screen. + if (tryAwaitRelease()) { + voice.stop(send = true) + } + } + ) + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = "push to talk", + tint = when { + voice.recording -> MaterialTheme.colorScheme.onPrimary + busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300) + else -> MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(18.dp) + ) + } + } +} + +/** + * Full-screen push-to-talk overlay: fades in over the chat with a live waveform, elapsed time, + * and a release hint. Rendered as a sibling of the screen content (NOT inside the edgeButton + * slot, which would clip it to the slot bounds). + * + * The big mic button doubles as the slide-to-cancel target: when the user's finger approaches + * it ([hoveringCancel]), it snaps into a red cancel button with a bouncy spring; lifting the + * finger there cancels the recording, dragging back out returns to send mode. + */ +@Composable +fun VoiceRecordOverlay( + voice: VoiceNoteController, + hoveringCancel: Boolean, + proximity: Float, + magnetPull: Offset, + onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit, +) { + val palette = LocalBitchatPalette.current + // The cancel morph, choreographed for feel: + // - color flows green→red CONTINUOUSLY as the finger approaches (finger-driven, so it + // is perfectly fluid), completing to full red on activation + // - the button leans toward the approaching finger (magnetic pull), chasing it with a + // smooth spring so it lags and settles naturally + // - scale blooms with a soft bounce on activation — no rotation, no wobble + val cancelScale by + androidx.compose.animation.core.animateFloatAsState( + targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity, + animationSpec = + androidx.compose.animation.core.spring( + dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, + stiffness = androidx.compose.animation.core.Spring.StiffnessMedium, + ), + label = "cancelSnap", + ) + val pull by + androidx.compose.animation.core.animateOffsetAsState( + targetValue = magnetPull, + animationSpec = + androidx.compose.animation.core.spring( + dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, + stiffness = androidx.compose.animation.core.Spring.StiffnessMedium, + ), + label = "magnetPull", + ) + val cancelColor = + androidx.compose.ui.graphics.lerp( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.error, + if (hoveringCancel) 1f else proximity * 0.85f, + ) + AnimatedVisibility( + visible = voice.recording, + enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)), + exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)), + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + val side = + if (LocalConfiguration.current.isScreenRound) { + roundContentSide(maxWidth.value, maxHeight.value).dp + } else { + minOf(maxWidth, maxHeight) - 24.dp + } + Column( + modifier = Modifier.size(side), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + // Text gets its natural height first. The decorative waveform and cancel icon + // share the remaining room instead of pushing instructions off the display. + BoxWithConstraints( + Modifier.weight(1f).fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val iconSize = minOf(52.dp, maxHeight / 1.32f, maxWidth / 1.32f) + Box( + modifier = + Modifier.onGloballyPositioned { coords -> + onCancelBounds( + androidx.compose.ui.geometry.Rect( + coords.localToRoot( + androidx.compose.ui.geometry.Offset.Zero + ), + coords.size.toSize(), + ) + ) + } + .size(iconSize) + .graphicsLayer { + translationX = pull.x + translationY = pull.y + scaleX = cancelScale + scaleY = cancelScale + } + .clip(CircleShape) + .background(cancelColor), + contentAlignment = Alignment.Center, + ) { + androidx.compose.animation.Crossfade( + targetState = hoveringCancel, + animationSpec = tween(BitchatMotion.STANDARD_MS), + label = "cancelIcon", + ) { cancel -> + Icon( + imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic, + contentDescription = if (cancel) "cancel recording" else null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(iconSize / 2), + ) + } + } + } + WaveformBars( + samples = voice.liveSamples, + progress = 1f, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().height(12.dp), + ) + Text( + text = + (if (voice.isLive) "LIVE " else "") + + "%d:%02d" + .format( + voice.elapsedMs / 1000 / 60, + voice.elapsedMs / 1000 % 60, + ) + + "/0:10", + style = ChatVisualTokens.SystemActionStyle, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = if (hoveringCancel) "Release to cancel" else "Lift finger to send", + style = ChatVisualTokens.SystemActionStyle, + color = + if (hoveringCancel) MaterialTheme.colorScheme.error + else palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt new file mode 100644 index 00000000..ffd08ead --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -0,0 +1,460 @@ +package com.bitchat.watch.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight +import com.bitchat.android.model.BitchatMessage +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +/** + * The shared chat body for global chat and DM threads, following the classic messenger + * pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary, + * scrollbar) with the header and action bar as floating overlays that get out of the way + * while scrolling up into history and return on a deliberate reverse scroll; at the newest message + * they are always visible. + * + * The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or + * hiding them never changes the scroll geometry. Earlier revisions animated the bottom + * clearance and resized the header in the layout path, which shifted content under the + * user's finger mid-gesture (felt as "resistance") and fed back into the dock detection. + */ +@Composable +fun ChatScaffold( + messages: List, + myPeerID: String, + emptyText: String, + voice: VoiceNoteController, + onOpenImage: (String) -> Unit, + header: @Composable (expanded: Boolean) -> Unit, + actionBar: @Composable () -> Unit +) { + val columnState = rememberTransformingLazyColumnState() + + // Haptics on incoming messages. + val context = LocalContext.current + var previousCount by remember { mutableStateOf(messages.size) } + LaunchedEffect(messages.size) { + if (messages.size > previousCount) { + val last = messages.lastOrNull() + if (last != null && last.senderPeerID != myPeerID) { + WearHaptics.knock(context) + } + } + previousCount = messages.size + } + + // Follow intent is changed only by an actual user scroll away from the newest item or by + // reaching the end again. A new item temporarily makes canScrollForward true before layout; + // treating that transient range change as user intent breaks automatic following. + var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) } + val density = LocalDensity.current + val scrollConnection = remember(columnState, density) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + // Both Wear rotary and touch dispatch consumed movement here. Positive list + // movement is toward newer messages. Layout changes never enter this path. + scrollIntent = updatedChatScrollIntent( + current = scrollIntent, + deltaDp = -consumed.y / density.density, + isUserInput = source == NestedScrollSource.UserInput, + atNewest = !columnState.canScrollForward + ) + return Offset.Zero + } + } + } + LaunchedEffect(columnState) { + snapshotFlow { + !columnState.canScrollForward && !columnState.isScrollInProgress + }.collect { atNewest -> + if (atNewest) scrollIntent = ChatScrollIntentState() + } + } + + // Stick to bottom when the user has not intentionally moved into history. + LaunchedEffect(columnState, messages.size) { + if (messages.isNotEmpty() && scrollIntent.followsNewest) { + val expectedSingleMessageKey = messages.singleOrNull()?.id + scrollToNewestAfterItemsMeasured( + expectedItemCount = messages.size, + expectedSingleMessageKey = expectedSingleMessageKey, + measuredLayouts = snapshotFlow { + val layoutInfo = columnState.layoutInfo + MeasuredChatLayout( + itemCount = layoutInfo.totalItemsCount, + singleVisibleItemKey = if (expectedSingleMessageKey != null) { + layoutInfo.visibleItems.singleOrNull()?.key + } else { + null + } + ) + } + ) { + // scrollBy to the end of the range: animateScrollToItem stops as soon as the + // item is partially visible, which left the last message cropped. + // Do not seize the list from an active drag/crown gesture, or follow an + // append whose measurement completed after the user entered history. + followNewestWhenIdle( + scrolling = snapshotFlow { columnState.isScrollInProgress }, + shouldFollow = { scrollIntent.followsNewest } + ) { + columnState.scroll { scrollBy(Float.MAX_VALUE) } + } + } + } + } + + ChatBody( + messages = messages, + myPeerID = myPeerID, + emptyText = emptyText, + voice = voice, + onOpenImage = onOpenImage, + columnState = columnState, + controlsVisible = scrollIntent.controlsVisible, + header = header, + actionBar = actionBar, + modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection) + ) +} + +internal data class MeasuredChatLayout( + val itemCount: Int, + val singleVisibleItemKey: Any? +) + +internal data class ChatScrollIntentState( + val followsNewest: Boolean = true, + val controlsVisible: Boolean = true, + val reversalDp: Float = 0f +) + +internal fun updatedChatScrollIntent( + current: ChatScrollIntentState, + deltaDp: Float, + isUserInput: Boolean, + atNewest: Boolean +): ChatScrollIntentState { + if (atNewest) return ChatScrollIntentState() + if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current + // Hysteresis measures net travel opposite the current controls state, not the sum of + // tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods. + val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp) + .coerceAtLeast(0f) + val threshold = if (current.controlsVisible) 12f else 24f + val toggle = reversal >= threshold + return current.copy( + followsNewest = current.followsNewest && deltaDp >= 0f, + controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible, + reversalDp = if (toggle) 0f else reversal + ) +} + +internal suspend fun scrollToNewestAfterItemsMeasured( + expectedItemCount: Int, + expectedSingleMessageKey: Any?, + measuredLayouts: Flow, + scrollToEnd: suspend () -> Unit +) { + measuredLayouts.first { layout -> + layout.itemCount >= expectedItemCount && + (expectedSingleMessageKey == null || + layout.singleVisibleItemKey == expectedSingleMessageKey) + } + scrollToEnd() +} + +internal suspend fun followNewestWhenIdle( + scrolling: Flow, + shouldFollow: () -> Boolean, + scrollToEnd: suspend () -> Unit +) { + scrolling.first { !it } + if (shouldFollow()) scrollToEnd() +} + +@Composable +private fun ChatBody( + messages: List, + myPeerID: String, + emptyText: String, + voice: VoiceNoteController, + onOpenImage: (String) -> Unit, + columnState: TransformingLazyColumnState, + controlsVisible: Boolean, + header: @Composable (expanded: Boolean) -> Unit, + actionBar: @Composable () -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val context = LocalContext.current + val transformationSpec = rememberTransformationSpec() + val isScreenRound = LocalConfiguration.current.isScreenRound + val headerClearance = with(LocalDensity.current) { + maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp) + } + // Slide-to-cancel: while recording, the finger's position is tracked globally; the + // overlay's mic button reports its bounds and becomes the cancel target when the + // finger hovers it (with generous slack so the snap engages on approach). + var cancelBounds by remember { mutableStateOf(null) } + var fingerPos by remember { mutableStateOf(Offset.Zero) } + var fingerActive by remember { mutableStateOf(false) } + val hoveringCancel = fingerActive && + cancelBounds?.inflate(CANCEL_HOVER_SLANT_PX)?.contains(fingerPos) == true + + // Magnetic attraction: as the finger approaches the target (but is not on it yet), the + // button leans toward the finger and blushes red in proportion to the closeness; + // only actually entering the activation zone snaps it into full cancel mode. + val cancelCenter = cancelBounds?.center + val proximity: Float + val magnetPull: Offset + if (fingerActive && cancelCenter != null) { + val toFinger = fingerPos - cancelCenter + val dist = toFinger.getDistance() + proximity = ((MAGNET_OUTER_PX - dist) / (MAGNET_OUTER_PX - MAGNET_INNER_PX)) + .coerceIn(0f, 1f) + magnetPull = if (dist > 1f) toFinger * (proximity * MAGNET_PULL_PX / dist) + else Offset.Zero + } else { + proximity = 0f + magnetPull = Offset.Zero + } + + // Tactile tick each time the finger enters or leaves the cancel target. + var hoverHapticState by remember { mutableStateOf(false) } + LaunchedEffect(hoveringCancel, voice.recording) { + if (!voice.recording) { + hoverHapticState = false + } else if (hoveringCancel != hoverHapticState) { + WearHaptics.tick(context) + hoverHapticState = hoveringCancel + } + } + + Box( + modifier = modifier + .fillMaxSize() + // Push-to-talk release is tracked globally: once recording, lifting the finger + // ANYWHERE on the screen stops — sending, or cancelling when hovering the + // cancel target. On a 1.4" round screen it is too easy to drift off the small + // mic button (the scrollable parent steals the pointer mid-drag), so the + // button alone must not own the release. + .pointerInput(voice) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (!voice.recording) continue + val change = event.changes.firstOrNull() ?: continue + fingerPos = change.position + fingerActive = true + if (event.changes.any { it.changedToUp() }) { + val cancel = cancelBounds + ?.inflate(CANCEL_HOVER_SLANT_PX) + ?.contains(change.position) == true + fingerActive = false + if (cancel) { + WearHaptics.reject(context) + voice.stop(send = false) + } else { + voice.stop(send = true) + } + } + } + } + } + ) { + ScreenScaffold(scrollState = columnState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current + TransformingLazyColumn( + state = columnState, + // Keep the list full-screen and geometrically unclipped. Wear's transformation + // spec curves rows along the round display; a soft destination-alpha mask then + // makes them fully transparent at the physical edges instead of cutting glyphs. + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithCache { + val edgeMask = if (isScreenRound) { + // Fade toward the actual circular contour so glyphs become + // transparent before the panel can crop their left or right edge. + Brush.radialGradient( + 0f to Color.Black, + CHAT_ROUND_EDGE_OPAQUE_STOP to Color.Black, + 1f to Color.Transparent, + center = Offset(size.width / 2f, size.height / 2f), + radius = size.minDimension / 2f + ) + } else { + val topOpaqueStop = + (CHAT_HEADER_EDGE_FADE.toPx() / size.height).coerceIn(0f, 1f) + val bottomOpaqueStop = + (1f - CHAT_ACTION_BAR_EDGE_FADE.toPx() / size.height) + .coerceIn(topOpaqueStop, 1f) + Brush.verticalGradient( + 0f to Color.Transparent, + topOpaqueStop to Color.Black, + bottomOpaqueStop to Color.Black, + 1f to Color.Transparent + ) + } + onDrawWithContent { + drawContent() + drawRect( + brush = edgeMask, + blendMode = BlendMode.DstIn + ) + } + }, + // Arrangement.Bottom anchors short content to the bottom: the first message + // starts just above the action bar and new messages push history upward. + // The scroll range reserves resting space for the floating controls while the + // full-screen viewport preserves autoscroll and the native transformation focal + // point. Rows may travel behind the overlays only after they have begun the Wear + // edge scale/fade treatment. + verticalArrangement = Arrangement.Bottom, + // Keep Wear Material's responsive horizontal inset while replacing its vertical + // inset with the overlay clearances used before the shape fix. This avoids both + // duplicated padding and a shortened list viewport. + contentPadding = scaffoldPadding.withVerticalClearance( + layoutDirection = layoutDirection, + top = headerClearance, + bottom = CHAT_ACTION_BAR_CLEARANCE + ) + ) { + if (messages.isEmpty()) { + item { + Text( + text = emptyText, + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 48.dp) + ) + } + } + items(messages, key = { it.id }) { message -> + MessageItem( + message = message, + myPeerID = myPeerID, + onOpenImage = onOpenImage, + modifier = Modifier + .transformedHeight(this, transformationSpec) + .graphicsLayer { + with(transformationSpec) { + applyContainerTransformation(scrollProgress) + } + } + ) + } + } + } + + // The header stays put and shrinks to its dense form instead of disappearing; + // as an overlay its size animation never touches the list's scroll geometry. + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + header(controlsVisible) + } + + AnimatedVisibility( + visible = controlsVisible, + modifier = Modifier.align(Alignment.BottomCenter), + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(BitchatMotion.STANDARD_MS) + ) + fadeIn(animationSpec = tween(BitchatMotion.STANDARD_MS)), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(BitchatMotion.STANDARD_MS) + ) + fadeOut(animationSpec = tween(BitchatMotion.STANDARD_MS)) + ) { + Box(modifier = Modifier.padding(bottom = 10.dp)) { + actionBar() + } + } + + VoiceRecordOverlay( + voice = voice, + hoveringCancel = hoveringCancel, + proximity = proximity, + magnetPull = magnetPull, + onCancelBounds = { cancelBounds = it } + ) + } +} + +// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap +// engages as the finger approaches, not only on exact contact. +private const val CANCEL_HOVER_SLANT_PX = 56f +private val CHAT_ACTION_BAR_CLEARANCE = 64.dp +private val CHAT_HEADER_EDGE_FADE = 36.dp +private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp +private const val CHAT_ROUND_EDGE_OPAQUE_STOP = 0.78f +// Magnetic zone geometry (px at watch density): the button starts reacting at +// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation +// boundary); it leans toward the finger by up to MAGNET_PULL_PX. +private const val MAGNET_OUTER_PX = 170f +private const val MAGNET_INNER_PX = 104f +private const val MAGNET_PULL_PX = 22f diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt new file mode 100644 index 00000000..d576cd03 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -0,0 +1,274 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MailOutline +import androidx.compose.material.icons.filled.People +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults +import androidx.wear.compose.foundation.rotary.rotaryScrollable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.lazy.items +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.media.FileMessageChip +import com.bitchat.watch.ui.media.FullScreenImageViewer +import com.bitchat.watch.ui.media.ImageMessageItem +import com.bitchat.watch.ui.media.VoiceNoteItem +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { + val context = LocalContext.current + val messages by AppStateStore.publicMessages.collectAsState() + val peers by AppStateStore.peers.collectAsState() + val unreadDms by WearChatState.unreadDms.collectAsState() + val mesh = WearMeshService.peek() + val myPeerID = mesh?.myPeerID ?: "" + var viewerPath by remember { mutableStateOf(null) } + val liveVoiceManager = remember(context) { + com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context) + } + val busyTalker by liveVoiceManager.activePublicTalker.collectAsState() + val voice = rememberVoiceNoteController( + recorderFactory = { + val target = if ( + mesh != null && + com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context) && + mesh.getPeerNicknames().isNotEmpty() + ) com.bitchat.android.features.voice.LiveVoiceTarget { payload -> + mesh.sendVoiceFrame(null, payload) + } else null + com.bitchat.android.features.voice.VoiceRecorder(context, target) + } + ) { path -> mesh?.let { sendVoiceNote(it, null, path) } } + + androidx.compose.runtime.DisposableEffect(liveVoiceManager) { + liveVoiceManager.showPublicMesh() + onDispose { liveVoiceManager.clearVisibleConversation() } + } + + ChatScaffold( + messages = messages, + myPeerID = myPeerID, + emptyText = "No messages yet\nSay hi to the mesh", + voice = voice, + onOpenImage = { viewerPath = it }, + header = { expanded -> + ChatHeader( + peerCount = peers.size, + unreadDms = unreadDms.values.sum(), + expanded = expanded, + onOpenPeople = onOpenPeople + ) + }, + actionBar = { + ChatActionBar(onKeyboard = onOpenTextInput, voice = voice, busyTalker = busyTalker) + } + ) + + viewerPath?.let { path -> + FullScreenImageViewer(path = path, onClose = { viewerPath = null }) + } +} + +@Composable +private fun ChatHeader( + peerCount: Int, + unreadDms: Int, + expanded: Boolean, + onOpenPeople: () -> Unit, +) { + // Floating title row: full-size at the newest messages, shrinks to its dense form + // while scrolling up into history. Rendered as an overlay, so the animation only + // relayouts this row, never the message list. + val spec = + androidx.compose.animation.core.tween( + BitchatMotion.STANDARD_MS + ) + val iconSize by + androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 14.dp else 12.dp, + animationSpec = spec, + label = "hdrIcon", + ) + val titleSize by + androidx.compose.animation.core.animateFloatAsState( + targetValue = if (expanded) 14f else 12f, + animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS), + label = "hdrTitle", + ) + + // The entire header region opens the People screen. When there are unread DMs the + // title gives way so the people and mail icons (with counts) fit side by side on the + // round screen instead of clipping at the edges. + WearChatHeader( + fontSize = titleSize, + onClickLabel = "Open people", + onClick = onOpenPeople, + ) { + if (unreadDms == 0) { + Text( + text = "bitchat", + style = MaterialTheme.typography.titleSmall, + fontSize = titleSize.sp, + lineHeight = (titleSize * 1.3f).sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp), + ) + } + Icon( + imageVector = Icons.Filled.People, + contentDescription = "people", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(iconSize), + ) + Text( + text = if (peerCount > 99) "99+" else "$peerCount", + style = MaterialTheme.typography.bodySmall, + fontSize = 12.sp, + lineHeight = (titleSize * 1.3f).sp, + maxLines = 1, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 2.dp), + ) + if (unreadDms > 0) { + Icon( + imageVector = Icons.Filled.MailOutline, + contentDescription = "$unreadDms unread messages", + tint = LocalBitchatPalette.current.accentOrange, + modifier = Modifier.padding(start = 6.dp).size(iconSize), + ) + Text( + text = if (unreadDms > 99) "99+" else "$unreadDms", + style = MaterialTheme.typography.bodySmall, + fontSize = 12.sp, + lineHeight = (titleSize * 1.3f).sp, + maxLines = 1, + color = LocalBitchatPalette.current.accentOrange, + modifier = Modifier.padding(start = 2.dp), + ) + } + } +} + +@Composable +fun MessageItem( + message: BitchatMessage, + myPeerID: String, + onOpenImage: (String) -> Unit = {}, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val isSelf = message.senderPeerID == myPeerID + val senderColor = when { + isSelf -> palette.accentOrange + else -> colorForPeer(message.sender + (message.senderPeerID ?: ""), palette) + } + + // Snappy appear animation for incoming messages (BitchatMotion.EMPHASIZED_MS) + var appeared by remember { mutableStateOf(false) } + LaunchedEffect(message.id) { appeared = true } + val alpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS), + label = "msgAlpha" + ) + val offset by androidx.compose.animation.core.animateDpAsState( + targetValue = if (appeared) 0.dp else 6.dp, + animationSpec = androidx.compose.animation.core.tween(BitchatMotion.EMPHASIZED_MS), + label = "msgOffset" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 3.dp) + .offset(y = offset) + .alpha(alpha) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isSelf) "you" else message.sender, + style = ChatVisualTokens.SenderStyle, + color = senderColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Text( + text = " ${formatTime(message.timestamp)}", + style = ChatVisualTokens.TimestampStyle, + color = palette.textTertiary + ) + } + when (message.type) { + BitchatMessageType.Image -> ImageMessageItem( + path = message.content.trim(), + onOpen = onOpenImage + ) + BitchatMessageType.Audio -> VoiceNoteItem( + path = message.content.trim(), + messageID = message.id + ) + BitchatMessageType.File -> { + val path = message.content.trim() + val file = remember(path) { File(path) } + val sizeBytes = remember(path) { file.length() } + FileMessageChip(name = file.name, sizeBytes = sizeBytes) + } + BitchatMessageType.Message -> Text( + text = message.content, + style = ChatVisualTokens.MessageBodyStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 1.dp) + ) + } + } +} + +private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + +private fun formatTime(date: Date): String = timeFormat.format(date) diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt new file mode 100644 index 00000000..d0e675ea --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -0,0 +1,204 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Verified +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults +import androidx.wear.compose.foundation.rotary.rotaryScrollable +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.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.lazy.items +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.notification.WearNotificationCoordinator +import com.bitchat.watch.ui.media.FullScreenImageViewer +import com.bitchat.watch.ui.theme.BitchatMotion +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +@Composable +fun DmScreen( + peerID: String, + onOpenUserDetail: () -> Unit, + onOpenTextInput: () -> Unit +) { + val context = LocalContext.current + val privateMessages by AppStateStore.privateMessages.collectAsState() + val messages = privateMessages[peerID] ?: emptyList() + val mesh = WearMeshService.peek() + val myPeerID = mesh?.myPeerID ?: "" + val palette = LocalBitchatPalette.current + var viewerPath by remember { mutableStateOf(null) } + val liveVoiceManager = remember(context) { + com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context) + } + val voice = rememberVoiceNoteController( + recorderFactory = { + val target = if ( + mesh != null && mesh.hasEstablishedSession(peerID) && + com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context) + ) com.bitchat.android.features.voice.LiveVoiceTarget { payload -> + mesh.sendVoiceFrame(peerID, payload) + } else null + com.bitchat.android.features.voice.VoiceRecorder(context, target) + } + ) { path -> mesh?.let { sendVoiceNote(it, peerID, path) } } + + val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8) + val identityRevision by WearPeerIdentityState.revision.collectAsState() + val identity = remember(peerID, identityRevision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } + var sessionEstablished by remember { + mutableStateOf(mesh?.hasEstablishedSession(peerID) == true) + } + + DisposableEffect(peerID) { + liveVoiceManager.showDirectMessage(peerID) + WearChatState.openDm(peerID) + WearNotificationCoordinator.getInstance(context).clearConversation(peerID) + onDispose { + WearChatState.closeDm() + liveVoiceManager.clearVisibleConversation() + } + } + + LaunchedEffect(peerID) { + if (mesh?.hasEstablishedSession(peerID) != true) { + try { mesh?.initiateNoiseHandshake(peerID) } catch (_: Exception) { } + } + while (true) { + sessionEstablished = mesh?.hasEstablishedSession(peerID) == true + kotlinx.coroutines.delay(2_000) + } + } + + ChatScaffold( + messages = messages, + myPeerID = myPeerID, + emptyText = if (sessionEstablished) "Encrypted channel ready\nSay hi" + else "Setting up encryption…", + voice = voice, + onOpenImage = { viewerPath = it }, + header = { expanded -> + DmHeader( + nickname = nickname, + peerID = peerID, + sessionEstablished = sessionEstablished, + expanded = expanded, + isFavorite = identity.isFavorite, + isVerified = identity.isVerified, + onClick = onOpenUserDetail + ) + }, + actionBar = { + ChatActionBar(onKeyboard = onOpenTextInput, voice = voice) + } + ) + + viewerPath?.let { path -> + FullScreenImageViewer(path = path, onClose = { viewerPath = null }) + } +} + +@Composable +private fun DmHeader( + nickname: String, + peerID: String, + sessionEstablished: Boolean, + expanded: Boolean, + isFavorite: Boolean, + isVerified: Boolean, + onClick: () -> Unit, +) { + val palette = LocalBitchatPalette.current + // Floating title row: full-size at the newest messages, shrinks to its dense form + // while scrolling up into history. Rendered as an overlay, so the animation only + // relayouts this row, never the message list. + val spec = + androidx.compose.animation.core.tween( + BitchatMotion.STANDARD_MS + ) + val headerIconSize by + androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 14.dp else 12.dp, + animationSpec = spec, + label = "dmHdrIcon", + ) + val headerTitleSize by + androidx.compose.animation.core.animateFloatAsState( + targetValue = if (expanded) 14f else 12f, + animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS), + label = "dmHdrTitle", + ) + + WearChatHeader( + fontSize = headerTitleSize, + onClickLabel = "Open user details", + onClick = onClick, + ) { + Text( + text = nickname, + style = MaterialTheme.typography.titleSmall, + fontSize = headerTitleSize.sp, + lineHeight = (headerTitleSize * 1.3f).sp, + fontWeight = FontWeight.Bold, + color = colorForPeer(nickname + peerID, palette), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + NoiseLockIcon( + state = + if (sessionEstablished) NoiseSessionUiState.Established + else NoiseSessionUiState.Handshaking, + size = headerIconSize, + modifier = Modifier.padding(start = 5.dp), + ) + if (isFavorite) { + Icon( + painter = painterResource(R.drawable.ic_spec_star_filled), + contentDescription = "Favorite", + tint = palette.accentOrange, + modifier = Modifier.padding(start = 4.dp).size(headerIconSize), + ) + } + if (isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = "Verified", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp).size(headerIconSize), + ) + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt new file mode 100644 index 00000000..b49e634d --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt @@ -0,0 +1,138 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +/** + * Nickname entry, used both for first-run onboarding and for renaming later. The IME's + * Done action only closes the keyboard so the user can review the name; the confirm + * button is the single commit path. + */ +@Composable +fun NicknameSetupScreen( + initialNickname: String, + title: String = "bitchat", + subtitle: String = "Pick a nickname", + confirmLabel: String = "Join the mesh", + onConfirm: (String) -> Unit, +) { + val palette = LocalBitchatPalette.current + // Pre-fill with the cursor at the end of the existing name, not the start. + var name by remember { + mutableStateOf( + TextFieldValue( + text = initialNickname, + selection = TextRange(initialNickname.length), + ) + ) + } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + WearFormScreen { + item { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + item { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp, bottom = 10.dp), + ) + } + item { + BasicTextField( + value = name, + onValueChange = { newValue -> + val trimmed = newValue.text.trim().take(24) + name = + if (trimmed == newValue.text) { + newValue + } else { + newValue.copy(text = trimmed, selection = TextRange(trimmed.length)) + } + }, + singleLine = true, + textStyle = + ChatVisualTokens.MessageBodyStyle.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + keyboardController?.hide() + } + ), + modifier = + Modifier.fillMaxWidth() + .focusRequester(focusRequester) + .clip(RoundedCornerShape(18.dp)) + .background(palette.inputSurface) + .padding(horizontal = 12.dp, vertical = 8.dp), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.Center) { + if (name.text.isEmpty()) { + Text( + text = "Nickname", + style = ChatVisualTokens.MessageBodyStyle, + color = palette.textTertiary, + ) + } + innerTextField() + } + }, + ) + } + item { + Button( + onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) }, + enabled = name.text.isNotBlank(), + modifier = Modifier.padding(top = 10.dp), + ) { + Text(confirmLabel, textAlign = TextAlign.Center) + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt new file mode 100644 index 00000000..2a4bc6b6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NoiseLockIcon.kt @@ -0,0 +1,76 @@ +package com.bitchat.watch.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +enum class NoiseSessionUiState { Idle, Handshaking, Established } + +/** + * Noise session lock icon, same visual language as the phone's NoiseSessionIcon: quiet grey + * open lock when idle, orange open lock with a soft pulse while the handshake is in flight, + * green closed lock once established. Tint and glyph transitions land together. + */ +@Composable +fun NoiseLockIcon( + state: NoiseSessionUiState, + modifier: Modifier = Modifier, + size: androidx.compose.ui.unit.Dp = 13.dp +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val targetTint = when (state) { + NoiseSessionUiState.Handshaking -> palette.accentOrange + NoiseSessionUiState.Established -> colorScheme.primary + NoiseSessionUiState.Idle -> colorScheme.onSurfaceVariant + } + val tint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(480, easing = FastOutSlowInEasing), + label = "noiseLockTint" + ) + + val pulseAlpha = if (state == NoiseSessionUiState.Handshaking) { + val transition = rememberInfiniteTransition(label = "noiseLockPulse") + transition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(600, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "noiseLockPulseAlpha" + ).value + } else 1f + + Icon( + imageVector = if (state == NoiseSessionUiState.Established) Icons.Filled.Lock + else Icons.Filled.LockOpen, + contentDescription = when (state) { + NoiseSessionUiState.Handshaking -> "handshake in progress" + NoiseSessionUiState.Established -> "encrypted" + NoiseSessionUiState.Idle -> "not encrypted yet" + }, + tint = tint, + modifier = modifier + .size(size) + .alpha(pulseAlpha) + ) +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt new file mode 100644 index 00000000..1994f976 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeerDebugScreen.kt @@ -0,0 +1,136 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Verified +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +/** + * Internal debug screen (M2): raw peer list with RSSI. Kept for troubleshooting; the real + * people screen arrives in M4. + */ +@Composable +fun PeerDebugScreen() { + val peers by AppStateStore.peers.collectAsState() + val mesh = WearMeshService.peek() + val listState = rememberScalingLazyListState() + val palette = LocalBitchatPalette.current + val nicknames = mesh?.getPeerNicknames() ?: emptyMap() + val rssi = mesh?.getPeerRSSI() ?: emptyMap() + val identityRevision by WearPeerIdentityState.revision.collectAsState() + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + item { + ListHeader { + Text( + text = "Peers (${peers.size})", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + } + if (peers.isEmpty()) { + item { + Text( + text = "Scanning for bitchat devices…", + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) + } + } + items(peers) { peerID -> + val nick = nicknames[peerID] ?: peerID.take(8) + val encrypted = mesh?.hasEstablishedSession(peerID) == true + val identity = androidx.compose.runtime.remember(peerID, identityRevision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = nick, + style = MaterialTheme.typography.bodyMedium, + color = colorForPeer(nick + peerID, palette), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (encrypted) { + NoiseLockIcon( + state = NoiseSessionUiState.Established, + size = 11.dp, + modifier = Modifier.padding(start = 4.dp) + ) + } + if (identity.isFavorite) { + Icon( + painter = painterResource(R.drawable.ic_spec_star_filled), + contentDescription = "Favorite", + tint = palette.accentOrange, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } + if (identity.isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = "Verified", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } + rssi[peerID]?.let { + Text( + text = "${it}dBm", + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + modifier = Modifier.padding(start = 6.dp) + ) + } + } + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt new file mode 100644 index 00000000..a27fab14 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -0,0 +1,267 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MailOutline +import androidx.compose.material.icons.filled.Verified +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +@Composable +fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { + val context = LocalContext.current + val peers by AppStateStore.peers.collectAsState() + val unread by WearChatState.unreadDms.collectAsState() + val mesh = WearMeshService.peek() + val listState = rememberScalingLazyListState() + val palette = LocalBitchatPalette.current + val nicknames = mesh?.getPeerNicknames() ?: emptyMap() + val identityRevision by WearPeerIdentityState.revision.collectAsState() + var liveVoiceEnabled by remember { + mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)) + } + + // Peers with unread messages float to the top so they are easy to see and reach. + val sortedPeers = androidx.compose.runtime.remember( + peers, + unread, + nicknames, + identityRevision + ) { + peers.sortedWith( + compareByDescending { (unread[it] ?: 0) > 0 } + .thenBy { (nicknames[it] ?: it).lowercase() } + ) + } + + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = scaffoldPadding + ) { + item { + ListHeader { + Text( + text = "People (${peers.size})", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + } + if (peers.isEmpty()) { + item { + Text( + text = "No one nearby yet\nKeep the app open to mesh", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } + item(key = "self") { + SelfRow( + nickname = mesh?.nickname ?: "me", + onClick = onEditNickname + ) + } + item(key = "live_voice") { + Card( + onClick = { + liveVoiceEnabled = !liveVoiceEnabled + com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled( + context, + liveVoiceEnabled + ) + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp) + ) { + Text( + text = if (liveVoiceEnabled) "Live push-to-talk: on" else "Live push-to-talk: off", + style = ChatVisualTokens.SenderStyle, + color = if (liveVoiceEnabled) MaterialTheme.colorScheme.primary else palette.textTertiary + ) + Text( + text = "Tap to toggle; a voice note is still sent on release", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + items(sortedPeers, key = { it }) { peerID -> + val nick = nicknames[peerID] ?: peerID.take(8) + val identity = WearPeerIdentityState.snapshot(peerID, mesh) + PersonRow( + nickname = nick, + peerID = peerID, + encrypted = mesh?.hasEstablishedSession(peerID) == true, + isFavorite = identity.isFavorite, + isVerified = identity.isVerified, + unreadCount = unread[peerID] ?: 0, + onClick = { onOpenDm(peerID) } + ) + } + } + } +} + +@Composable +private fun SelfRow(nickname: String, onClick: () -> Unit) { + val palette = LocalBitchatPalette.current + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 2.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = nickname, + style = ChatVisualTokens.SenderStyle, + color = palette.accentOrange, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + Text( + text = " (you)", + style = ChatVisualTokens.SenderStyle, + color = palette.textTertiary + ) + } + Text( + text = "Tap to rename", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } +} + +@Composable +private fun PersonRow( + nickname: String, + peerID: String, + encrypted: Boolean, + isFavorite: Boolean, + isVerified: Boolean, + unreadCount: Int, + onClick: () -> Unit +) { + val palette = LocalBitchatPalette.current + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = nickname, + style = ChatVisualTokens.SenderStyle, + color = colorForPeer(nickname + peerID, palette), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (encrypted) { + NoiseLockIcon( + state = NoiseSessionUiState.Established, + size = 11.dp, + modifier = Modifier.padding(start = 4.dp) + ) + } + if (isFavorite) { + Icon( + painter = painterResource(R.drawable.ic_spec_star_filled), + contentDescription = "Favorite", + tint = palette.accentOrange, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } + if (isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = "Verified", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 4.dp) + .size(11.dp) + ) + } + } + if (!encrypted) { + Text( + text = "Tap to chat", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + if (unreadCount > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 6.dp) + ) { + Icon( + imageVector = Icons.Filled.MailOutline, + contentDescription = "$unreadCount unread messages", + tint = palette.accentOrange, + modifier = Modifier.size(13.dp) + ) + Text( + text = "$unreadCount", + style = ChatVisualTokens.SystemActionStyle, + color = palette.accentOrange, + modifier = Modifier.padding(start = 2.dp) + ) + } + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt new file mode 100644 index 00000000..2b79c98f --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt @@ -0,0 +1,107 @@ +package com.bitchat.watch.ui + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.mesh.WearMeshService +import java.io.File +import java.util.Date +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal fun sendPublicMessage(mesh: WearMeshService, content: String) { + mesh.sendMessage(content) + AppStateStore.addPublicMessage( + BitchatMessage( + sender = mesh.nickname, + content = content, + timestamp = Date(), + senderPeerID = mesh.myPeerID, + deliveryStatus = DeliveryStatus.Sent + ) + ) +} + +/** + * DM send with honest delivery state. MeshCore drops pre-handshake content (it only kicks + * off the Noise handshake), so when no session exists we must not echo "Sent": the echo + * stays "Sending" while a retry loop waits for the session and completes the send. + */ +internal fun sendPrivateMessage( + mesh: WearMeshService, + peerID: String, + recipientNickname: String, + content: String, + scope: CoroutineScope +) { + val established = mesh.hasEstablishedSession(peerID) + val messageID = java.util.UUID.randomUUID().toString() + if (established) { + mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID) + } else { + mesh.initiateNoiseHandshake(peerID) + scope.launch { + val deadline = System.currentTimeMillis() + 15_000 + while (System.currentTimeMillis() < deadline) { + if (mesh.hasEstablishedSession(peerID)) { + mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID) + AppStateStore.updatePrivateMessageStatus(messageID, DeliveryStatus.Sent) + return@launch + } + delay(400) + } + // Session never came up: the echo honestly stays "Sending" (AppStateStore + // refuses status downgrades, so it cannot be marked Failed from here). + } + } + AppStateStore.addPrivateMessage( + peerID, + BitchatMessage( + id = messageID, + sender = mesh.nickname, + content = content, + timestamp = Date(), + isPrivate = true, + recipientNickname = recipientNickname, + senderPeerID = mesh.myPeerID, + deliveryStatus = if (established) DeliveryStatus.Sent else DeliveryStatus.Sending + ) + ) +} + +/** + * Send a recorded voice note. Global chat: broadcast file packet. DM thread: Noise-encrypted + * private file transfer. Local echo renders immediately (content = local path, type = Audio). + */ +internal fun sendVoiceNote(mesh: WearMeshService, peerID: String?, path: String) { + val file = File(path) + if (!file.isFile) return + val packet = BitchatFilePacket( + fileName = file.name, + fileSize = file.length(), + mimeType = "audio/mp4", + content = file.readBytes() + ) + if (peerID == null) { + mesh.sendFileBroadcast(packet) + } else { + mesh.sendFilePrivateEncrypted(peerID, packet) + } + val echo = BitchatMessage( + sender = mesh.nickname, + content = path, + type = BitchatMessageType.Audio, + timestamp = Date(), + isPrivate = peerID != null, + senderPeerID = mesh.myPeerID, + deliveryStatus = DeliveryStatus.Sent + ) + if (peerID == null) { + AppStateStore.addPublicMessage(echo) + } else { + AppStateStore.addPrivateMessage(peerID, echo) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt new file mode 100644 index 00000000..df1b92a2 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt @@ -0,0 +1,159 @@ +package com.bitchat.watch.ui + +import android.app.Activity +import android.content.Intent +import android.speech.RecognizerIntent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Mic +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.IconButton +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +/** + * Full-screen text input: field auto-focused so the watch IME (with its built-in dictation) + * opens immediately, plus a dedicated dictation button using the system speech recognizer. + */ +@Composable +fun TextInputScreen(onSend: (String) -> Unit) { + val palette = LocalBitchatPalette.current + val context = androidx.compose.ui.platform.LocalContext.current + var text by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current + + val dictationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val spoken = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + if (!spoken.isNullOrBlank()) { + WearHaptics.tick(context) + onSend(spoken.trim()) + } + } + } + + fun send() { + val trimmed = text.trim() + if (trimmed.isNotEmpty()) { + keyboardController?.hide() + WearHaptics.tick(context) + onSend(trimmed) + text = "" + } + } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center + ) { + BasicTextField( + value = text, + onValueChange = { text = it }, + singleLine = true, + textStyle = ChatVisualTokens.MessageBodyStyle.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { send() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .clip(RoundedCornerShape(18.dp)) + .background(palette.inputSurface) + .padding(horizontal = 14.dp, vertical = 10.dp), + decorationBox = { innerTextField -> + Box { + if (text.isEmpty()) { + Text( + text = "Message", + style = ChatVisualTokens.MessageBodyStyle, + color = palette.textTertiary + ) + } + innerTextField() + } + } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { + dictationLauncher.launch( + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM + ) + putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak your message") + } + ) + }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = "dictate", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { send() }, + enabled = text.isNotBlank(), + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "send", + tint = if (text.isNotBlank()) MaterialTheme.colorScheme.primary + else palette.textTertiary + ) + } + } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt new file mode 100644 index 00000000..fd6a347f --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -0,0 +1,218 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Card +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.watch.R +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import com.bitchat.watch.ui.theme.colorForPeer + +@Composable +fun UserDetailScreen( + peerID: String, + onOpenVerification: () -> Unit +) { + val mesh = WearMeshService.peek() + val revision by WearPeerIdentityState.revision.collectAsState() + val identity = androidx.compose.runtime.remember(peerID, revision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } + val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8) + val listState = rememberScalingLazyListState(initialCenterItemIndex = 0) + val palette = LocalBitchatPalette.current + + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + autoCentering = null, + contentPadding = scaffoldPadding + .withAdditionalPadding( + layoutDirection = layoutDirection, + horizontal = 10.dp + ) + .withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp) + ) { + item { + ListHeader { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = nickname, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = colorForPeer(nickname + peerID, palette), + textAlign = TextAlign.Center + ) + Text( + text = "User details", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + } + + item { + Card( + onClick = { + mesh?.let { + WearPeerIdentityState.setFavorite( + peerID = peerID, + isFavorite = !identity.isFavorite, + mesh = it + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource( + if (identity.isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = when { + identity.isFavorite -> "Favorite" + identity.theyFavoritedUs -> "They favorited you" + else -> "Not a favorite" + }, + tint = if ( + identity.isFavorite || identity.theyFavoritedUs + ) { + palette.accentOrange + } else { + palette.textTertiary + }, + modifier = Modifier.size(24.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Text( + text = favoriteTitle(identity), + style = ChatVisualTokens.SenderStyle, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = favoriteSubtitle(identity), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + } + } + + item { + Card( + onClick = onOpenVerification, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (identity.isVerified) { + Icons.Filled.Verified + } else { + Icons.Filled.Lock + }, + contentDescription = null, + tint = if (identity.isVerified) { + MaterialTheme.colorScheme.primary + } else { + palette.textTertiary + }, + modifier = Modifier.size(22.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Text( + text = if (identity.isVerified) { + "Identity verified" + } else { + "Identity code" + }, + style = ChatVisualTokens.SenderStyle, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Compare identity codes", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } + } + } + + item { + Text( + text = "Peer ${peerID.take(8)}", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + } + } +} + +private fun favoriteTitle(identity: WearPeerIdentitySnapshot): String = when { + identity.isFavorite && identity.theyFavoritedUs -> "Mutual favorite" + identity.isFavorite -> "Favorited" + identity.theyFavoritedUs -> "Favorite back" + else -> "Add favorite" +} + +private fun favoriteSubtitle(identity: WearPeerIdentitySnapshot): String = when { + identity.isFavorite && identity.theyFavoritedUs -> "You favorited each other" + identity.isFavorite -> "Remove from favorites" + identity.theyFavoritedUs -> "They favorited you" + else -> "Keep this person easy to find" +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt new file mode 100644 index 00000000..4644e9bd --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -0,0 +1,185 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +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.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.watch.mesh.WearMeshService +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +@Composable +fun VerificationCodeScreen(peerID: String) { + val mesh = WearMeshService.peek() + val revision by WearPeerIdentityState.revision.collectAsState() + val identity = androidx.compose.runtime.remember(peerID, revision) { + WearPeerIdentityState.snapshot(peerID, mesh) + } + val myFingerprint = WearPeerIdentityState.myFingerprint(mesh) + val listState = rememberScalingLazyListState(initialCenterItemIndex = 0) + val palette = LocalBitchatPalette.current + + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + val layoutDirection = LocalLayoutDirection.current + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + autoCentering = null, + contentPadding = scaffoldPadding + .withAdditionalPadding( + layoutDirection = layoutDirection, + horizontal = 10.dp + ) + .withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp) + ) { + item { + ListHeader { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = if (identity.isVerified) { + Icons.Filled.Verified + } else { + Icons.Outlined.Warning + }, + contentDescription = null, + tint = if (identity.isVerified) { + MaterialTheme.colorScheme.primary + } else { + palette.accentOrange + } + ) + Text( + text = if (identity.isVerified) "Verified" else "Verify identity", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + item { + FingerprintCard( + title = "Their code", + fingerprint = identity.fingerprint + ) + } + + item { + FingerprintCard( + title = "Your code", + fingerprint = myFingerprint + ) + } + + item { + Text( + text = "Compare both full codes in person or over a trusted channel.", + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + item { + Button( + onClick = { + WearPeerIdentityState.setVerified( + peerID = peerID, + verified = !identity.isVerified, + mesh = mesh + ) + }, + enabled = identity.fingerprint != null + ) { + Text( + if (identity.isVerified) { + "Remove verification" + } else { + "Mark verified" + }, + textAlign = TextAlign.Center + ) + } + } + } + } +} + +@Composable +private fun FingerprintCard( + title: String, + fingerprint: String? +) { + val palette = LocalBitchatPalette.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = title, + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + fontWeight = FontWeight.Bold + ) + Text( + text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 16.sp + ), + color = if (fingerprint == null) { + palette.accentOrange + } else { + MaterialTheme.colorScheme.onSurface + }, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } +} + +fun formatVerificationCode(fingerprint: String): String { + return fingerprint + .uppercase() + .chunked(4) + .chunked(2) + .joinToString("\n") { line -> line.joinToString(" ") } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt new file mode 100644 index 00000000..7ec162af --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -0,0 +1,103 @@ +package com.bitchat.watch.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.bitchat.android.features.voice.VoiceRecorder +import com.bitchat.android.features.voice.normalizeAmplitudeSample +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val MAX_RECORDING_MS = 10_000L +private const val MIN_RECORDING_MS = 600L +private const val AMPLITUDE_POLL_MS = 80L +private const val LIVE_BARS = 32 + +/** + * Push-to-talk recording controller: start on press, stop on release, 10 s cap, ~80 ms + * amplitude polls into a rolling live-waveform buffer. Hoisted to screen level so the + * full-screen overlay can render outside the edge-button slot. + */ +class VoiceNoteController( + private val context: Context, + private val scope: CoroutineScope, + private val recorderFactory: () -> VoiceRecorder, + private val onSendVoice: (String) -> Unit +) { + private var recorder: VoiceRecorder? = null + + var recording by mutableStateOf(false) + private set + var isLive by mutableStateOf(false) + private set + var elapsedMs by mutableLongStateOf(0L) + private set + var liveSamples by mutableStateOf(FloatArray(LIVE_BARS)) + private set + + private var pollJob: Job? = null + private var startedAt = 0L + + fun start() { + if (recording) return + val activeRecorder = recorderFactory() + activeRecorder.start() ?: return + recorder = activeRecorder + isLive = activeRecorder.isLive + startedAt = System.currentTimeMillis() + elapsedMs = 0L + liveSamples = FloatArray(LIVE_BARS) + recording = true + WearHaptics.knock(context) + pollJob = scope.launch { + while (true) { + delay(AMPLITUDE_POLL_MS) + val amp = normalizeAmplitudeSample(recorder?.pollAmplitude() ?: 0) + liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp + val elapsed = System.currentTimeMillis() - startedAt + elapsedMs = elapsed + if (elapsed >= MAX_RECORDING_MS) { + stop(send = true) + break + } + } + } + } + + fun stop(send: Boolean) { + if (!recording) return + recording = false + // The send path clicks; the cancel path stays silent here because the caller + // plays its own reject haptic. + if (send) WearHaptics.click(context) + pollJob?.cancel() + pollJob = null + val file = recorder?.stop(canceled = !send) + recorder = null + isLive = false + val elapsed = System.currentTimeMillis() - startedAt + if (send && file != null && elapsed >= MIN_RECORDING_MS) { + onSendVoice(file.absolutePath) + } else { + file?.delete() + } + } +} + +@Composable +fun rememberVoiceNoteController( + recorderFactory: () -> VoiceRecorder, + onSendVoice: (String) -> Unit +): VoiceNoteController { + val context = LocalContext.current + val scope = rememberCoroutineScope() + return remember { VoiceNoteController(context, scope, recorderFactory, onSendVoice) } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt new file mode 100644 index 00000000..aeb19766 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt @@ -0,0 +1,75 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material3.MaterialTheme + +/** The title is outside the scrolling list, so it needs its own physical display bounds. */ +@Composable +internal fun WearChatHeader( + fontSize: Float, + onClickLabel: String, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + val configuration = LocalConfiguration.current + val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() } + val rowHeight = maxOf(48.dp, lineHeight) + val top = (rowHeight - lineHeight) / 2 + val background = MaterialTheme.colorScheme.background + // Follow the existing title animation without animating layout or the hit target. + val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f) + val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp) + BoxWithConstraints( + Modifier.fillMaxWidth().drawWithCache { + val brush = + Brush.verticalGradient( + 0f to background, + 0.65f to background.copy(alpha = 0.95f), + 1f to Color.Transparent, + endY = fadeEnd.toPx(), + ) + onDrawBehind { drawRect(brush) } + }, + contentAlignment = Alignment.TopCenter, + ) { + val safeWidth = + if (configuration.isScreenRound) { + roundBandWidth( + maxWidth.value, + configuration.screenHeightDp.toFloat(), + top.value, + (top + lineHeight).value, + ) + .dp - 8.dp + } else { + maxWidth - 16.dp + } + Row( + modifier = + Modifier.width(safeWidth.coerceAtLeast(48.dp)) + .height(rowHeight) + .clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt new file mode 100644 index 00000000..16dc2c0a --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearChatState.kt @@ -0,0 +1,43 @@ +package com.bitchat.watch.ui + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide UI state for the watch app: unread DM counters and the currently open DM thread. + */ +object WearChatState { + private val _unreadDms = MutableStateFlow>(emptyMap()) + val unreadDms: StateFlow> = _unreadDms.asStateFlow() + + @Volatile + var appInForeground: Boolean = false + private set + + @Volatile + var openDmPeer: String? = null + + @Synchronized + fun onPrivateMessageArrived(peerID: String) { + if (appInForeground && openDmPeer == peerID) return + _unreadDms.value = _unreadDms.value + (peerID to ((_unreadDms.value[peerID] ?: 0) + 1)) + } + + fun setAppInForeground(inForeground: Boolean) { + appInForeground = inForeground + } + + @Synchronized + fun openDm(peerID: String) { + openDmPeer = peerID + _unreadDms.value = _unreadDms.value - peerID + } + + @Synchronized + fun closeDm() { + openDmPeer = null + } + + fun unreadCount(peerID: String): Int = _unreadDms.value[peerID] ?: 0 +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt new file mode 100644 index 00000000..738e56cf --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearContentPadding.kt @@ -0,0 +1,53 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +/** + * Preserve the responsive, shape-aware padding supplied by Wear Material while allowing a + * screen to reserve additional room for its own content or floating controls. + */ +internal fun PaddingValues.withAdditionalPadding( + layoutDirection: LayoutDirection, + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp +): PaddingValues { + return PaddingValues( + start = calculateStartPadding(layoutDirection) + horizontal, + top = calculateTopPadding() + vertical, + end = calculateEndPadding(layoutDirection) + horizontal, + bottom = calculateBottomPadding() + vertical + ) +} + +/** + * Keep the responsive horizontal inset supplied by Wear Material while replacing its vertical + * padding with screen-owned clearances. This keeps the lazy list full-screen for correct Wear + * transformation and scroll calculations without stacking duplicate vertical insets. + */ +internal fun PaddingValues.withVerticalClearance( + layoutDirection: LayoutDirection, + top: Dp, + bottom: Dp +): PaddingValues { + return PaddingValues( + start = calculateStartPadding(layoutDirection), + top = top, + end = calculateEndPadding(layoutDirection), + bottom = bottom + ) +} + +private fun PaddingValues.calculateStartPadding(layoutDirection: LayoutDirection): Dp = + when (layoutDirection) { + LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection) + LayoutDirection.Rtl -> calculateRightPadding(layoutDirection) + } + +private fun PaddingValues.calculateEndPadding(layoutDirection: LayoutDirection): Dp = + when (layoutDirection) { + LayoutDirection.Ltr -> calculateRightPadding(layoutDirection) + LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection) + } diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt b/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt new file mode 100644 index 00000000..894e43a6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearDisplayGeometry.kt @@ -0,0 +1,16 @@ +package com.bitchat.watch.ui + +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.sqrt + +/** Width of the narrowest chord across a centered horizontal band in a round display. */ +internal fun roundBandWidth(width: Float, height: Float, top: Float, bottom: Float): Float { + val radius = min(width, height) / 2f + val distance = maxOf(abs(top - height / 2f), abs(bottom - height / 2f)) + return 2f * sqrt((radius * radius - distance * distance).coerceAtLeast(0f)) +} + +/** A centered square whose four corners fit inside the physical circle, with a small inset. */ +internal fun roundContentSide(width: Float, height: Float): Float = + (min(width, height) / sqrt(2f) - 4f).coerceAtLeast(0f) diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt new file mode 100644 index 00000000..75cf3703 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt @@ -0,0 +1,30 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.ScreenScaffold + +/** Independently scrollable form items remain reachable on small watches and at large fonts. */ +@Composable +internal fun WearFormScreen(content: ScalingLazyListScope.() -> Unit) { + val state = rememberScalingLazyListState(initialCenterItemIndex = 0) + val direction = LocalLayoutDirection.current + ScreenScaffold(scrollState = state) { padding -> + ScalingLazyColumn( + state = state, + modifier = Modifier.fillMaxSize(), + autoCentering = null, + contentPadding = + padding + .withAdditionalPadding(direction, horizontal = 14.dp) + .withVerticalClearance(direction, top = 28.dp, bottom = 28.dp), + content = content, + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt new file mode 100644 index 00000000..6e8fc8e2 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt @@ -0,0 +1,35 @@ +package com.bitchat.watch.ui + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager + +/** + * Tactile accents for the watch's important moments. Uses predefined vibration effects so + * the feel stays consistent with the rest of Wear OS. + */ +object WearHaptics { + /** Firm knock: recording started, message received. */ + fun knock(context: Context) = vibrate(context, VibrationEffect.EFFECT_HEAVY_CLICK) + + /** Crisp click: recording stopped, message sent. */ + fun click(context: Context) = vibrate(context, VibrationEffect.EFFECT_CLICK) + + /** Double tap: destructive/cancel confirmation. */ + fun reject(context: Context) = vibrate(context, VibrationEffect.EFFECT_DOUBLE_CLICK) + + /** Light tick: small confirmations. */ + fun tick(context: Context) = vibrate(context, VibrationEffect.EFFECT_TICK) + + private fun vibrate(context: Context, effect: Int) { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Vibrator::class.java) + } ?: return + vibrator.vibrate(VibrationEffect.createPredefined(effect)) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt b/wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt new file mode 100644 index 00000000..0a636251 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearPeerIdentityState.kt @@ -0,0 +1,140 @@ +package com.bitchat.watch.ui + +import android.content.Context +import com.bitchat.android.favorites.FavoriteRelationship +import com.bitchat.android.favorites.FavoritesChangeListener +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.watch.mesh.WearMeshService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +enum class FavoriteIndicator { + None, + FavoritedUs, + Favorite +} + +data class WearPeerIdentitySnapshot( + val isFavorite: Boolean, + val theyFavoritedUs: Boolean, + val favoriteIndicator: FavoriteIndicator, + val fingerprint: String?, + val isVerified: Boolean +) + +fun favoriteIndicator( + isFavorite: Boolean, + theyFavoritedUs: Boolean +): FavoriteIndicator = when { + isFavorite -> FavoriteIndicator.Favorite + theyFavoritedUs -> FavoriteIndicator.FavoritedUs + else -> FavoriteIndicator.None +} + +/** + * Process-wide bridge between the shared identity stores and the Watch Compose UI. + * + * Favorites are keyed by the authenticated Noise public key, while screens navigate with the + * short mesh peer ID. The shared persistence service resolves that mapping and notifies this + * object whenever either side changes the relationship. + */ +object WearPeerIdentityState : FavoritesChangeListener { + private val _revision = MutableStateFlow(0L) + val revision: StateFlow = _revision.asStateFlow() + + @Volatile + private var initialized = false + + private lateinit var identityManager: SecureIdentityStateManager + + fun initialize(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + FavoritesPersistenceService.initialize(context.applicationContext) + identityManager = SecureIdentityStateManager(context.applicationContext) + FavoritesPersistenceService.shared.addListener(this) + initialized = true + } + publishChange() + } + + fun snapshot(peerID: String, mesh: WearMeshService?): WearPeerIdentitySnapshot { + check(initialized) { "WearPeerIdentityState must be initialized by the application" } + val relationship = relationship(peerID, mesh) + val fingerprint = mesh?.getPeerFingerprint(peerID) + ?: relationship?.peerNoisePublicKey?.let(identityManager::generateFingerprint) + val isVerified = fingerprint != null && + identityManager.getVerifiedFingerprints().any { + it.equals(fingerprint, ignoreCase = true) + } + val isFavorite = relationship?.isFavorite == true + val theyFavoritedUs = relationship?.theyFavoritedUs == true + return WearPeerIdentitySnapshot( + isFavorite = isFavorite, + theyFavoritedUs = theyFavoritedUs, + favoriteIndicator = favoriteIndicator(isFavorite, theyFavoritedUs), + fingerprint = fingerprint, + isVerified = isVerified + ) + } + + fun setFavorite( + peerID: String, + isFavorite: Boolean, + mesh: WearMeshService + ): Boolean { + check(initialized) { "WearPeerIdentityState must be initialized by the application" } + val peerInfo = mesh.getPeerInfo(peerID) ?: return false + val noisePublicKey = peerInfo.noisePublicKey ?: return false + val nickname = mesh.getPeerNickname(peerID) + ?: peerInfo.nickname.takeIf(String::isNotBlank) + ?: peerID.take(8) + + FavoritesPersistenceService.shared.updateFavoriteStatus( + noisePublicKey = noisePublicKey, + nickname = nickname, + isFavorite = isFavorite + ) + mesh.sendFavoriteNotification(peerID, isFavorite) + return true + } + + fun setVerified( + peerID: String, + verified: Boolean, + mesh: WearMeshService? + ): Boolean { + check(initialized) { "WearPeerIdentityState must be initialized by the application" } + val fingerprint = snapshot(peerID, mesh).fingerprint ?: return false + identityManager.setVerifiedFingerprint(fingerprint.lowercase(), verified) + publishChange() + return true + } + + fun myFingerprint(mesh: WearMeshService?): String? = mesh?.getIdentityFingerprint() + + override fun onFavoriteChanged(noiseKeyHex: String) { + publishChange() + } + + override fun onAllCleared() { + publishChange() + } + + private fun relationship( + peerID: String, + mesh: WearMeshService? + ): FavoriteRelationship? { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.let { return it } + val noisePublicKey = mesh?.getPeerInfo(peerID)?.noisePublicKey ?: return null + return FavoritesPersistenceService.shared.getFavoriteStatus(noisePublicKey) + } + + private fun publishChange() { + _revision.update { it + 1L } + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt new file mode 100644 index 00000000..707132de --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -0,0 +1,328 @@ +package com.bitchat.watch.ui.media + +import android.graphics.BitmapFactory +import android.media.MediaPlayer +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.bitchat.android.features.voice.AudioWaveformExtractor +import com.bitchat.android.features.voice.VoiceWaveformCache +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.roundContentSide +import com.bitchat.watch.ui.theme.LocalBitchatPalette +import kotlinx.coroutines.delay +import java.io.File + +/** + * Compact inline image thumbnail; tap opens the full-screen viewer. + */ +@Composable +fun ImageMessageItem(path: String, onOpen: (String) -> Unit) { + val bitmap = remember(path) { BitmapFactory.decodeFile(path) } + if (bitmap == null) { + FileMessageChip(name = File(path).name, sizeBytes = File(path).length()) + return + } + Image( + painter = BitmapPainter(bitmap.asImageBitmap()), + contentDescription = "image", + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(top = 2.dp) + .widthIn(max = 120.dp) + .aspectRatio( + (bitmap.width.toFloat() / bitmap.height.toFloat()).coerceIn(0.6f, 1.8f) + ) + .clip(RoundedCornerShape(10.dp)) + .clickable { onOpen(path) } + ) +} + +/** + * Full-screen image viewer (mirrors the phone's FullScreenImageViewer): black surface, + * fit-to-screen, tap or swipe-back to dismiss. + */ +@Composable +fun FullScreenImageViewer(path: String, onClose: () -> Unit) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { onClose() }, + contentAlignment = Alignment.Center + ) { + val bitmap = remember(path) { BitmapFactory.decodeFile(path) } + if (bitmap != null) { + val imageModifier = if (LocalConfiguration.current.isScreenRound) { + Modifier.size(roundContentSide(maxWidth.value, maxHeight.value).dp) + } else { + Modifier.fillMaxSize() + } + Image( + painter = BitmapPainter(bitmap.asImageBitmap()), + contentDescription = "image fullscreen", + contentScale = ContentScale.Fit, + modifier = imageModifier + ) + } + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "close", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp) + .size(20.dp) + ) + } + } +} + +/** + * Voice-note bubble: play/pause + waveform (120 bins, extracted locally like the phone) + + * duration/progress. Playback via MediaPlayer. + */ +@Composable +fun VoiceNoteItem(path: String, messageID: String? = null) { + val palette = LocalBitchatPalette.current + val context = LocalContext.current + val liveIDs by com.bitchat.android.features.voice.LiveVoiceManager + .getInstance(context).liveMessageIDs.collectAsState() + val isLive = messageID != null && messageID in liveIDs + var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) } + var playing by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var durationMs by remember { mutableIntStateOf(0) } + val player = remember { MediaPlayer() } + + LaunchedEffect(path) { + if (samples == null) { + AudioWaveformExtractor.extractAsync(path) { extracted -> + if (extracted != null) { + VoiceWaveformCache.put(path, extracted) + samples = extracted + } + } + } + } + + DisposableEffect(path) { + runCatching { + player.reset() + player.setDataSource(path) + player.setOnCompletionListener { + playing = false + progress = 0f + } + player.prepare() + durationMs = player.duration + } + onDispose { + runCatching { if (player.isPlaying) player.stop() } + runCatching { player.release() } + } + } + + LaunchedEffect(playing) { + while (playing) { + progress = if (durationMs > 0) player.currentPosition.toFloat() / durationMs else 0f + delay(100) + } + } + + Row( + modifier = Modifier + .padding(top = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background(palette.inputSurface) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isLive) { + Text( + text = "LIVE", + color = Color(0xFFFFB300), + style = ChatVisualTokens.SystemActionStyle, + modifier = Modifier.padding(end = 4.dp) + ) + } + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .clickable { + if (playing) { + player.pause() + playing = false + } else { + runCatching { player.start() } + playing = true + } + }, + contentAlignment = Alignment.Center + ) { + if (playing) { + Canvas(modifier = Modifier.size(10.dp)) { + val w = size.width + val h = size.height + drawRoundRect( + color = Color.Black, + topLeft = Offset(0f, 0f), + size = Size(w * 0.35f, h), + cornerRadius = CornerRadius(1.dp.toPx()) + ) + drawRoundRect( + color = Color.Black, + topLeft = Offset(w * 0.65f, 0f), + size = Size(w * 0.35f, h), + cornerRadius = CornerRadius(1.dp.toPx()) + ) + } + } else { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "play", + tint = Color.Black, + modifier = Modifier.size(16.dp) + ) + } + } + WaveformBars( + samples = samples, + progress = progress, + modifier = Modifier + .padding(start = 6.dp) + .weight(1f) + .height(22.dp), + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = palette.textTertiary.copy(alpha = 0.5f) + ) + Text( + text = formatDuration(if (playing) (durationMs * progress).toInt() else durationMs), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + modifier = Modifier.padding(start = 6.dp) + ) + } +} + +@Composable +fun WaveformBars( + samples: FloatArray?, + progress: Float, + modifier: Modifier = Modifier, + activeColor: Color, + inactiveColor: Color +) { + Canvas(modifier = modifier) { + val bars = 32 + val values = samples?.let { com.bitchat.android.features.voice.resampleWave(it, bars) } + ?: FloatArray(bars) { 0.3f } + val barWidth = size.width / (bars * 2 - 1) + for (i in 0 until bars) { + val v = values.getOrElse(i) { 0f }.coerceIn(0.08f, 1f) + val barHeight = size.height * v + val x = i * barWidth * 2 + drawRoundRect( + color = if (i.toFloat() / bars <= progress) activeColor else inactiveColor, + topLeft = Offset(x, (size.height - barHeight) / 2f), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(barWidth / 2f) + ) + } + } +} + +/** + * Compact chip for non-media files. + */ +@Composable +fun FileMessageChip(name: String, sizeBytes: Long) { + val palette = LocalBitchatPalette.current + Row( + modifier = Modifier + .padding(top = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background(palette.inputSurface) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = name, + style = ChatVisualTokens.SystemActionStyle, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = formatSize(sizeBytes), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary + ) + } + } +} + +private fun formatDuration(ms: Int): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60) +} + +private fun formatSize(bytes: Long): String = when { + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576f) + bytes >= 1024 -> "%.1f KB".format(bytes / 1024f) + else -> "$bytes B" +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..5ae88981 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/BitchatPalette.kt @@ -0,0 +1,38 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class BitchatPalette( + val inputOutline: Color, + val inputOutlineFocused: Color, + val inputSurface: Color, + val inputSurfaceFocused: Color, + val inputButton: Color, + val textTertiary: Color, + val accentOrange: Color, + val accentPurple: Color, + val peerColors: PeerColorStyle, +) + +val DarkBitchatPalette = BitchatPalette( + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), + textTertiary = Color(0xFF6B776B), + accentOrange = Color(0xFFFF9F0A), + accentPurple = Color(0xFFBF5AF2), + peerColors = PeerColorStyle.Dark, +) + +val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } + +object BitchatMotion { + const val QUICK_MS = 120 + const val STANDARD_MS = 180 + const val EMPHASIZED_MS = 240 +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt new file mode 100644 index 00000000..4d8c390b --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/PeerColors.kt @@ -0,0 +1,35 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import kotlin.math.abs + +@Immutable +data class PeerColorStyle( + val saturation: Float, + val value: Float, +) { + companion object { + val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f) + } +} + +fun colorForPeer(stableKey: String, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in stableKey.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + val style = palette.peerColors + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = style.saturation, + value = style.value + ) +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt new file mode 100644 index 00000000..3f928aba --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Theme.kt @@ -0,0 +1,42 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.wear.compose.material3.ColorScheme +import androidx.wear.compose.material3.MaterialTheme + +val BitchatWearColorScheme = ColorScheme( + primary = Color(0xFF32D74B), + onPrimary = Color.Black, + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), + onSecondary = Color.Black, + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = Color(0xFFFF9F0A), + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surfaceContainer = Color(0xFF0E150E), + surfaceContainerLow = Color(0xFF0B0B0B), + surfaceContainerHigh = Color(0xFF182118), + onSurface = Color(0xFFF5F5F5), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), + onError = Color.Black, +) + +@Composable +fun BitchatWearTheme(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalBitchatPalette provides DarkBitchatPalette) { + MaterialTheme( + colorScheme = BitchatWearColorScheme, + typography = BitchatWearTypography, + content = content + ) + } +} diff --git a/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt new file mode 100644 index 00000000..aca6c631 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt @@ -0,0 +1,45 @@ +package com.bitchat.watch.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material3.Typography +import com.bitchat.watch.R + +val BitchatFontFamily = FontFamily( + Font(R.font.geist_mono_regular, FontWeight.Normal), + Font(R.font.geist_mono_medium, FontWeight.Medium), + Font(R.font.geist_mono_semibold, FontWeight.SemiBold), + Font(R.font.geist_mono_bold, FontWeight.Bold), +) + +val BitchatWearTypography = Typography( + defaultFontFamily = BitchatFontFamily, +) + +object ChatVisualTokens { + val MessageBodyStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 17.sp, + ) + + val SenderStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + lineHeight = 15.sp, + ) + + val SystemActionStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 15.sp, + ) + + val TimestampStyle = SystemActionStyle.copy(fontSize = 10.sp, lineHeight = 12.sp) +} diff --git a/wear/src/main/res/drawable/ic_launcher_background.xml b/wear/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..4eb5b929 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_foreground.xml b/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..f1f1b5b1 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/wear/src/main/res/drawable/ic_launcher_monochrome.xml b/wear/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..0b4064c9 --- /dev/null +++ b/wear/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/wear/src/main/res/drawable/ic_notification.xml b/wear/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..bc355f16 --- /dev/null +++ b/wear/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/wear/src/main/res/drawable/ic_spec_star.xml b/wear/src/main/res/drawable/ic_spec_star.xml new file mode 100644 index 00000000..d132b2ce --- /dev/null +++ b/wear/src/main/res/drawable/ic_spec_star.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/wear/src/main/res/drawable/ic_spec_star_filled.xml b/wear/src/main/res/drawable/ic_spec_star_filled.xml new file mode 100644 index 00000000..a3fae5e5 --- /dev/null +++ b/wear/src/main/res/drawable/ic_spec_star_filled.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/wear/src/main/res/font/geist_mono_bold.ttf b/wear/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 00000000..90eb8a86 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_bold.ttf differ diff --git a/wear/src/main/res/font/geist_mono_medium.ttf b/wear/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 00000000..ff49ece5 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_medium.ttf differ diff --git a/wear/src/main/res/font/geist_mono_regular.ttf b/wear/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 00000000..50c9d5a6 Binary files /dev/null and b/wear/src/main/res/font/geist_mono_regular.ttf differ diff --git a/wear/src/main/res/font/geist_mono_semibold.ttf b/wear/src/main/res/font/geist_mono_semibold.ttf new file mode 100644 index 00000000..1b21724d Binary files /dev/null and b/wear/src/main/res/font/geist_mono_semibold.ttf differ diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..a79cb4c6 --- /dev/null +++ b/wear/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/wear/src/main/res/values-v31/styles.xml b/wear/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..aa520d34 --- /dev/null +++ b/wear/src/main/res/values-v31/styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 00000000..2a2d1beb --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + bitchat + Mesh network + Keeps the Bluetooth mesh running in the background + Mesh running + + %1$d peer + %1$d peers + + Direct messages + Encrypted direct-message alerts + New encrypted message + Unlock to view the message + + %1$d new message + %1$d new messages + + diff --git a/wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt b/wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt new file mode 100644 index 00000000..e59d5153 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/WearNavigationStateTest.kt @@ -0,0 +1,109 @@ +package com.bitchat.watch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearNavigationStateTest { + + @Test + fun `notification dm goes back to main chat`() { + val navigation = WearNavigationState() + navigation.navigate(WearScreen.People) + navigation.navigate(WearScreen.Nickname) + + navigation.openDmFromNotification("peer-a") + + assertEquals(WearScreen.Dm("peer-a"), navigation.screen) + assertTrue(navigation.goBack()) + assertEquals(WearScreen.Chat, navigation.screen) + assertFalse(navigation.canGoBack) + } + + @Test + fun `dm opened from people also goes back to main chat`() { + val navigation = WearNavigationState() + navigation.navigate(WearScreen.People) + navigation.navigate(WearScreen.Dm("peer-a")) + + assertTrue(navigation.goBack()) + + assertEquals(WearScreen.Chat, navigation.screen) + assertFalse(navigation.canGoBack) + } + + @Test + fun `dm text input returns to dm before main chat`() { + val navigation = WearNavigationState() + navigation.navigate(WearScreen.People) + navigation.navigate(WearScreen.Dm("peer-a")) + navigation.navigate(WearScreen.TextInput("peer-a")) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.Dm("peer-a"), navigation.screen) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.Chat, navigation.screen) + assertFalse(navigation.canGoBack) + } + + @Test + fun `normal app launch resets an open dm to main chat`() { + val navigation = WearNavigationState() + navigation.openDmFromNotification("peer-a") + + navigation.openChat() + + assertEquals(WearScreen.Chat, navigation.screen) + assertFalse(navigation.canGoBack) + } + + @Test + fun `user detail and verification return through dm before main chat`() { + val navigation = WearNavigationState() + navigation.openDmFromNotification("peer-a") + navigation.navigate(WearScreen.UserDetail("peer-a")) + navigation.navigate(WearScreen.Verification("peer-a")) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.UserDetail("peer-a"), navigation.screen) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.Dm("peer-a"), navigation.screen) + + assertTrue(navigation.goBack()) + assertEquals(WearScreen.Chat, navigation.screen) + } + + @Test + fun `navigation state survives activity recreation`() { + val navigation = WearNavigationState() + navigation.openDmFromNotification("peer-a") + navigation.navigate(WearScreen.UserDetail("peer-a")) + navigation.navigate(WearScreen.Verification("peer-a")) + + val restored = WearNavigationState.restore(navigation.toSavedStateValues()) + + requireNotNull(restored) + assertEquals(WearScreen.Verification("peer-a"), restored.screen) + assertTrue(restored.goBack()) + assertEquals(WearScreen.UserDetail("peer-a"), restored.screen) + assertTrue(restored.goBack()) + assertEquals(WearScreen.Dm("peer-a"), restored.screen) + assertTrue(restored.goBack()) + assertEquals(WearScreen.Chat, restored.screen) + } + + @Test + fun `unhandled notification launch survives activity recreation`() { + val request = WearLaunchRequest( + id = 42L, + target = WearLaunchTarget.Dm("peer-a") + ) + + val restored = restoreWearLaunchRequest(request.toSavedStateValues()) + + assertEquals(request, restored) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt new file mode 100644 index 00000000..ab84d5c7 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/notification/WearNotificationPolicyTest.kt @@ -0,0 +1,62 @@ +package com.bitchat.watch.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearNotificationPolicyTest { + + @Test + fun `system private messages never notify`() { + assertFalse( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = true, + appInForeground = false, + openDmPeer = null + ) + ) + } + + @Test + fun `visible matching dm suppresses notification`() { + assertFalse( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = true, + openDmPeer = "peer-a" + ) + ) + } + + @Test + fun `backgrounded matching dm still notifies`() { + assertTrue( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = false, + openDmPeer = "peer-a" + ) + ) + } + + @Test + fun `different visible dm still notifies`() { + assertTrue( + WearNotificationPolicy.shouldNotifyPrivateMessage( + senderPeerID = "peer-a", + senderIsSystem = false, + appInForeground = true, + openDmPeer = "peer-b" + ) + ) + } + + @Test + fun `peer count is distinct`() { + assertEquals(2, WearNotificationPolicy.activePeerCount(listOf("peer-a", "peer-a", "peer-b"))) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt new file mode 100644 index 00000000..79242d85 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -0,0 +1,214 @@ +package com.bitchat.watch.ui + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class ChatAutoScrollTest { + + @Test + fun `append waits for active gesture and rechecks history intent`() = runTest { + val scrolling = MutableStateFlow(true) + var followsNewest = true + var scrollCount = 0 + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ } + } + assertFalse(job.isCompleted) + assertEquals(0, scrollCount) + followsNewest = false + scrolling.value = false + job.join() + assertEquals(0, scrollCount) + } + + @Test + fun `append follows after gesture settles when user remains at newest`() = runTest { + val scrolling = MutableStateFlow(true) + var scrollCount = 0 + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + followNewestWhenIdle(scrolling, { true }) { scrollCount++ } + } + assertEquals(0, scrollCount) + scrolling.value = false + job.join() + assertEquals(1, scrollCount) + } + + private fun move( + state: ChatScrollIntentState, + dp: Float, + user: Boolean = true, + newest: Boolean = false, + ) = updatedChatScrollIntent(state, dp, user, newest) + + @Test + fun `append and programmatic movement never change intent`() { + val docked = ChatScrollIntentState() + assertEquals(docked, move(docked, 1000f, user = false)) + val history = ChatScrollIntentState(false, false) + assertEquals(history, move(history, 1000f, user = false)) + } + + @Test + fun `first consumed movement away suspends following before controls hide`() { + val state = move(ChatScrollIntentState(), -1f) + assertFalse(state.followsNewest) + assertEquals(true, state.controlsVisible) + assertFalse(move(state, -11f).controlsVisible) + } + + @Test + fun `deliberate reversal reveals controls but does not resume following`() { + val history = move(ChatScrollIntentState(), -12f) + val almost = move(history, 23f) + assertFalse(almost.controlsVisible) + val revealed = move(almost, 1f) + assertEquals(true, revealed.controlsVisible) + assertFalse(revealed.followsNewest) + assertEquals(ChatScrollIntentState(), move(revealed, 1f, newest = true)) + } + + @Test + fun `jitter does not accumulate into repeated toggles`() { + var state = move(ChatScrollIntentState(), -12f) + repeat(100) { + state = move(state, 3f) + state = move(state, -3f) + } + assertFalse(state.controlsVisible) + assertEquals(0f, state.reversalDp) + } + + @Test + fun `pauses and discrete crown ticks retain net movement`() { + var state = ChatScrollIntentState() + repeat(4) { + state = move(state, -3f) + state = move(state, 0f, user = false) + } + assertFalse(state.controlsVisible) + repeat(8) { + state = move(state, 3f) + state = move(state, 0f, user = false) + } + assertEquals(true, state.controlsVisible) + assertFalse(state.followsNewest) + } + + @Test + fun `fling preserves controls until newest is reached`() { + val history = move(ChatScrollIntentState(), -12f) + assertEquals(history, move(history, 1000f, user = false)) + assertEquals(history, move(history, -1000f, user = false)) + assertEquals(ChatScrollIntentState(), move(history, 1f, user = false, newest = true)) + } + + @Test + fun `consumed distance is independent of item boundaries and event chunking`() { + val initial = ChatScrollIntentState() + val oneEvent = move(initial, -12f) + val acrossRows = + listOf(-2f, -3f, -1f, -6f).fold(initial) { state, delta -> + move(state, delta) + } + assertEquals(oneEvent, acrossRows) + } + + @Test + fun `pixel distances normalize to the same dp thresholds`() { + for (density in listOf(1f, 1.6875f, 2f, 3f)) { + val history = move(ChatScrollIntentState(), (-12f * density) / density) + assertFalse(history.controlsVisible) + assertEquals(true, move(history, (24f * density) / density).controlsVisible) + } + } + + @Test + fun `invalid and unconsumed input is ignored`() { + val initial = ChatScrollIntentState() + assertEquals(initial, move(initial, Float.NaN)) + assertEquals(initial, move(initial, Float.POSITIVE_INFINITY)) + assertEquals(initial, move(initial, 0f)) + } + + @Test + fun `newest scroll waits until appended message is measured`() = runTest { + val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null)) + var scrollCount = 0 + + val scrollJob = + launch(start = CoroutineStart.UNDISPATCHED) { + scrollToNewestAfterItemsMeasured( + expectedItemCount = 4, + expectedSingleMessageKey = null, + measuredLayouts = measuredLayouts, + ) { + scrollCount += 1 + } + } + + assertFalse(scrollJob.isCompleted) + assertEquals(0, scrollCount) + + measuredLayouts.value = MeasuredChatLayout(4, null) + scrollJob.join() + + assertEquals(1, scrollCount) + } + + @Test + fun `newest scroll runs immediately when messages are already measured`() = runTest { + val measuredLayouts = MutableStateFlow(MeasuredChatLayout(4, null)) + var scrollCount = 0 + + scrollToNewestAfterItemsMeasured( + expectedItemCount = 4, + expectedSingleMessageKey = null, + measuredLayouts = measuredLayouts, + ) { + scrollCount += 1 + } + + assertEquals(1, scrollCount) + } + + @Test + fun `first message waits past stale empty placeholder layout`() = runTest { + val messageKey = "first-message" + val measuredLayouts = + MutableStateFlow( + MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder") + ) + var scrollCount = 0 + + val scrollJob = + launch(start = CoroutineStart.UNDISPATCHED) { + scrollToNewestAfterItemsMeasured( + expectedItemCount = 1, + expectedSingleMessageKey = messageKey, + measuredLayouts = measuredLayouts, + ) { + scrollCount += 1 + } + } + + assertFalse(scrollJob.isCompleted) + assertEquals(0, scrollCount) + + measuredLayouts.value = + MeasuredChatLayout( + itemCount = 1, + singleVisibleItemKey = messageKey, + ) + scrollJob.join() + + assertEquals(1, scrollCount) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt new file mode 100644 index 00000000..60191d67 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearContentPaddingTest.kt @@ -0,0 +1,70 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearContentPaddingTest { + + @Test + fun `additional padding preserves scaffold insets`() { + val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) + .withAdditionalPadding( + layoutDirection = LayoutDirection.Ltr, + horizontal = 10.dp, + vertical = 8.dp + ) + + assertEquals(20.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) + assertEquals(22.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) + assertEquals(26.dp, resolved.calculateTopPadding()) + assertEquals(28.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `vertical clearance preserves responsive width and replaces scaffold height inset`() { + val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp) + .withVerticalClearance( + layoutDirection = LayoutDirection.Ltr, + top = 30.dp, + bottom = 64.dp + ) + + assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr)) + assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr)) + assertEquals(30.dp, resolved.calculateTopPadding()) + assertEquals(64.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `additional padding preserves logical start and end in rtl`() { + val resolved = PaddingValues(start = 10.dp, top = 18.dp, end = 12.dp, bottom = 20.dp) + .withAdditionalPadding( + layoutDirection = LayoutDirection.Rtl, + horizontal = 10.dp, + vertical = 8.dp + ) + + assertEquals(22.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl)) + assertEquals(20.dp, resolved.calculateRightPadding(LayoutDirection.Rtl)) + assertEquals(26.dp, resolved.calculateTopPadding()) + assertEquals(28.dp, resolved.calculateBottomPadding()) + } + + @Test + fun `vertical clearance preserves logical start and end in rtl`() { + val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp) + .withVerticalClearance( + layoutDirection = LayoutDirection.Rtl, + top = 30.dp, + bottom = 64.dp + ) + + assertEquals(12.dp, resolved.calculateLeftPadding(LayoutDirection.Rtl)) + assertEquals(10.dp, resolved.calculateRightPadding(LayoutDirection.Rtl)) + assertEquals(30.dp, resolved.calculateTopPadding()) + assertEquals(64.dp, resolved.calculateBottomPadding()) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt new file mode 100644 index 00000000..c274657c --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearDisplayGeometryTest.kt @@ -0,0 +1,51 @@ +package com.bitchat.watch.ui + +import com.bitchat.watch.ui.theme.ChatVisualTokens +import kotlin.math.pow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WearDisplayGeometryTest { + @Test + fun `header band corners stay inside the circle at supported text sizes`() { + for (diameter in listOf(192f, 228f, 240f)) { + for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) { + for (titleSize in listOf(12f, 13f, 14f)) { + val lineHeight = titleSize * 1.3f * scale + val top = (maxOf(48f, lineHeight) - lineHeight) / 2f + val width = roundBandWidth(diameter, diameter, top, top + lineHeight) + val radius = diameter / 2f + for (y in listOf(top, top + lineHeight)) { + assertTrue( + (width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f + ) + } + } + } + } + } + + @Test + fun `out of display bands have no usable width`() { + assertEquals(0f, roundBandWidth(192f, 192f, -1f, 20f)) + assertEquals(0f, roundBandWidth(192f, 192f, 180f, 193f)) + } + + @Test + fun `image and recording safe square fits all four corners`() { + for (diameter in listOf(192f, 228f, 240f)) { + val side = roundContentSide(diameter, diameter) + assertTrue(side > 0) + assertTrue(2 * (side / 2).pow(2) < (diameter / 2).pow(2)) + } + } + + @Test + fun `essential shared text is at least twelve sp`() { + assertTrue(ChatVisualTokens.SystemActionStyle.fontSize.value >= 12f) + assertTrue(ChatVisualTokens.MessageBodyStyle.fontSize.value >= 12f) + assertTrue(ChatVisualTokens.SenderStyle.fontSize.value >= 12f) + assertTrue(ChatVisualTokens.TimestampStyle.fontSize.value >= 10f) + } +} diff --git a/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt new file mode 100644 index 00000000..9e26b1ef --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt @@ -0,0 +1,38 @@ +package com.bitchat.watch.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WearPeerIdentityStateTest { + + @Test + fun `favorite indicator matches Android three-state star`() { + assertEquals( + FavoriteIndicator.None, + favoriteIndicator(isFavorite = false, theyFavoritedUs = false) + ) + assertEquals( + FavoriteIndicator.FavoritedUs, + favoriteIndicator(isFavorite = false, theyFavoritedUs = true) + ) + assertEquals( + FavoriteIndicator.Favorite, + favoriteIndicator(isFavorite = true, theyFavoritedUs = false) + ) + assertEquals( + FavoriteIndicator.Favorite, + favoriteIndicator(isFavorite = true, theyFavoritedUs = true) + ) + } + + @Test + fun `verification code keeps every fingerprint character`() { + val fingerprint = (0 until 64).joinToString("") { (it % 16).toString(16) } + + val formatted = formatVerificationCode(fingerprint) + + assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace)) + assertEquals(8, formatted.lines().size) + assertEquals(List(8) { 2 }, formatted.lines().map { it.split(" ").size }) + } +}