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 9911c273..211e851b 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -3,102 +3,140 @@ name: Android CI on: workflow_dispatch: push: - branches: [ "main", "develop" ] + branches: [main, develop] pull_request: - branches: [ "main", "develop" ] + branches: [main, develop] + +permissions: + contents: read + +env: + SETUP_JAVA_VERSION: 21.0.11+10.0.LTS jobs: verify: - name: Test & Lint - runs-on: ubuntu-latest + name: Test and lint + runs-on: ubuntu-24.04 steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' + - name: Set up pinned JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: ${{ env.SETUP_JAVA_VERSION }} - - name: Setup Gradle - uses: gradle/gradle-build-action@v3 + - name: Set up and validate Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - name: Verify native library inputs + run: tools/arti-build/verify-checksums.sh - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + - name: Run unit tests + run: ./gradlew testDebugUnitTest - - name: Run unit tests - run: ./gradlew testDebugUnitTest + - name: Run lint + run: ./gradlew lintDebug - - name: Run lint - run: ./gradlew lintDebug + - name: Upload test reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: test-reports + path: | + **/build/test-results/ + **/build/reports/tests/ - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports - path: | - **/build/test-results/ - **/build/reports/tests/ + - name: Upload lint results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: lint-results + path: "**/build/reports/lint-results-*.html" - - name: Upload lint results - uses: actions/upload-artifact@v4 - if: always() - with: - name: lint-results - path: '**/build/reports/lint-results-*.html' + build-debug: + name: Build debug APK + runs-on: ubuntu-24.04 + needs: verify - build: - name: Build ${{ matrix.variant }} - runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up pinned JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: ${{ env.SETUP_JAVA_VERSION }} + + - name: Set up and validate Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Build debug APK + run: ./gradlew assembleDebug + + - name: Upload debug APK + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: Debug-apk + path: app/build/outputs/apk/debug/*.apk + + reproducible-build: + name: Reproducible release build ${{ matrix.replica }} + runs-on: ubuntu-24.04 needs: verify strategy: + fail-fast: false matrix: - variant: [Debug, Release] + replica: [a, b] steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' + - name: Set up pinned JDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: ${{ env.SETUP_JAVA_VERSION }} - - name: Setup Gradle - uses: gradle/gradle-build-action@v3 + - name: Set up and validate Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-disabled: true - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - name: Build canonical unsigned release + run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}" - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + - name: Upload build replica + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: reproducible-release-${{ matrix.replica }} + path: ${{ runner.temp }}/release-${{ matrix.replica }}/* + if-no-files-found: error - - name: Build ${{ matrix.variant }} APK - run: ./gradlew assemble${{ matrix.variant }} + compare-reproducible-builds: + name: Compare release bytes + runs-on: ubuntu-24.04 + needs: reproducible-build - - name: Upload ${{ matrix.variant }} APK - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.variant }}-apk - path: app/build/outputs/apk/**/*.apk + steps: + - name: Checkout verification script + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Download replica A + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: reproducible-release-a + path: ${{ runner.temp }}/release-a + + - name: Download replica B + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: reproducible-release-b + path: ${{ runner.temp }}/release-b + + - name: Compare every canonical byte + run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b" diff --git a/.github/workflows/fetch-georelays.yml b/.github/workflows/fetch-georelays.yml index f7d629bc..8f91c53d 100644 --- a/.github/workflows/fetch-georelays.yml +++ b/.github/workflows/fetch-georelays.yml @@ -10,11 +10,11 @@ permissions: jobs: update-relay-data: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -37,4 +37,4 @@ jobs: git commit -m "Automated update of relay data - $(date -u)" git push env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f1e1841a..75c7ef31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,106 +2,118 @@ name: Release on: workflow_dispatch: + inputs: + tag: + description: Existing vX.Y.Z tag to build and attest + required: true + type: string push: tags: - - 'v*.*.*' # Triggers for tags like v1.0.0 + - "v*.*.*" + +permissions: + contents: read + +env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} jobs: build: - runs-on: ubuntu-latest + name: Build unsigned release ${{ matrix.replica }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + replica: [a, b] steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 17 - - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - with: - gradle-version: wrapper - - - name: Cache Gradle files - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Grant execute permission for Gradlew - run: chmod +x ./gradlew - - - name: Build Release APKs (with architecture splits) - run: ./gradlew assembleRelease --no-daemon --stacktrace - - - name: List APK files + - name: Validate release tag run: | - echo "APK files built:" - find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \; + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "error: release tag must look like vX.Y.Z" >&2 + exit 1 + fi - - name: Rename APKs for GitHub Release - run: | - cd app/build/outputs/apk/release - [ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk - [ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk - [ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk - - - name: DEBUG - run: | - set -x - pwd - ls -all - cd app/build/outputs/ - ls -all - tree || ls -R - - # Optional: Sign APKs (uncomment and configure secrets when ready) - # - name: Sign APKs - # uses: r0adkll/sign-android-release@v1 - # with: - # releaseDirectory: app/build/outputs/apk/release - # signingKeyBase64: ${{ secrets.SIGNING_KEY }} - # alias: ${{ secrets.ALIAS }} - # keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} - # keyPassword: ${{ secrets.KEY_PASSWORD }} - - - name: Upload APKs as artifacts - uses: actions/upload-artifact@v4 + - name: Checkout tagged source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - name: bitchat-android-release-${{ github.ref_name }} - path: app/build/outputs/apk/release/*.apk + ref: ${{ env.RELEASE_TAG }} + + - name: Verify workflow provenance ref + run: | + checked_out_commit="$(git rev-parse HEAD)" + if [ "$checked_out_commit" != "$GITHUB_SHA" ]; then + echo "error: run the workflow from the $RELEASE_TAG tag ref so provenance identifies the built commit" >&2 + exit 1 + fi + + - name: Validate the Gradle wrapper + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-disabled: true + + - name: Build canonical unsigned APKs and AAB + run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}" + + - name: Upload unsigned replica + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: unsigned-release-${{ matrix.replica }} + path: ${{ runner.temp }}/release-${{ matrix.replica }}/* retention-days: 30 if-no-files-found: error - release: + compare: + name: Verify reproducibility + runs-on: ubuntu-24.04 needs: build - runs-on: ubuntu-latest + permissions: + attestations: write + contents: read + id-token: write steps: - - name: Download release artifacts - uses: actions/download-artifact@v4 + - name: Checkout verification script + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - name: bitchat-android-release-${{ github.ref_name }} - path: release + ref: ${{ env.RELEASE_TAG }} - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - name: Download replica A + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - files: | - release/bitchat-android-arm64.apk - release/bitchat-android-x86_64.apk - release/bitchat-android-universal.apk - name: Release ${{ github.ref_name }} - body: | - **bitchat-android-arm64.apk** - ARM64 (most phones) - **bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets) - **bitchat-android-universal.apk** - All architectures (fallback) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + name: unsigned-release-a + path: ${{ runner.temp }}/release-a + + - name: Download replica B + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: unsigned-release-b + path: ${{ runner.temp }}/release-b + + - name: Compare every canonical byte + run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b" + + - name: Prepare public attestation subjects + run: | + mkdir "$RUNNER_TEMP/attestation-subjects" + cp "$RUNNER_TEMP/release-a/BUILDINFO.json" \ + "$RUNNER_TEMP/attestation-subjects/BITCHAT_BUILDINFO.json" + cp "$RUNNER_TEMP/release-a/SHA256SUMS.unsigned" \ + "$RUNNER_TEMP/attestation-subjects/BITCHAT_SHA256SUMS.unsigned" + cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.apk \ + "$RUNNER_TEMP/attestation-subjects/" + cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.aab \ + "$RUNNER_TEMP/attestation-subjects/" + + - name: Attest verified unsigned release + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: ${{ runner.temp }}/attestation-subjects/* + + - name: Upload verified unsigned release + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verified-unsigned-release + path: ${{ runner.temp }}/release-a/* + retention-days: 30 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 0986ac74..71a4f9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ gen/ *~ *.swp *.lock +!tools/arti-build/Cargo.lock .goosehints # Google services @@ -69,6 +70,7 @@ google-services.json # Arti build artifacts (cloned repo and Rust build cache) tools/arti-build/.arti-source/ tools/arti-build/target/ +.reproducible-build/ # JVM heap dumps (a Gradle daemon OOM drops these in the repo root) *.hprof 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 index edc167b0..8d9a2e44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,79 +1,127 @@ -# Bitchat Android - Agent Guide +# Repository Guidelines -This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase. +## Privacy & External Disclosure (Mandatory) -## 1. Project Overview -**Bitchat** is a decentralized, off-grid communication application focused on privacy and censorship resistance. It utilizes mesh networking (primarily Bluetooth LE and Tor/Arti) to enable peer-to-peer messaging without centralized servers. +Protect the developer's privacy without exception. Treat all data from the +developer's computer, accounts, devices, workspace, and communications as +private unless the developer explicitly authorizes sharing a specific item +with a specific destination. -**Key Technologies:** -- **Language:** Kotlin (JVM Target 1.8) -- **UI Framework:** Jetpack Compose (Material 3) -- **Asynchronous:** Kotlin Coroutines & Flow -- **Networking:** Bluetooth Low Energy (BLE), Tor (Arti Rust bridge), OkHttp -- **Architecture:** MVVM with Clean Architecture principles -- **Build System:** Gradle (Kotlin DSL) +- Never expose, publish, transmit, upload, paste, commit, or otherwise disclose + personally identifying or potentially identifying information. This includes + names, usernames, email addresses, account handles, home or workplace details, + local or absolute paths, drive or volume names, hostnames, device names and + serials, IP or MAC addresses, network names, peer IDs, messages, contacts, + tokens, keys, credentials, logs, metadata, and unique environment details. +- Never post any repository, workspace, device, account, conversation, log, or + developer data to GitHub, issue trackers, pull requests, comments, gists, + paste sites, analytics services, AI services, remote APIs, chat systems, or + any other external destination without the developer's explicit permission + for that exact data and destination. A general request to work on the + repository is not permission to disclose data. +- Keep local filesystem layout and computer information private. Do not include + absolute paths, usernames, home-directory names, mounted drives, shell + prompts, environment variables, installed-software inventories, hardware + details, or similar machine fingerprints in commits, patches, documentation, + screenshots, test fixtures, examples, issue text, PR text, or comments. +- Before any authorized external action, inspect and sanitize the exact content + being sent. Use repository-relative paths and neutral placeholders; remove + hidden metadata and redact unrelated or identifying content. When safe + sanitization cannot be guaranteed, do not send the data and ask the developer + how to proceed. +- Use synthetic, non-identifying test data only. Never use real messages, + contacts, account data, device identifiers, network information, keys, or + production logs in tests, examples, screenshots, fixtures, or bug reports. +- For geohash, GPS, map, location, proximity, or geofencing work, use only + clearly synthetic coordinates and geohashes that cannot reveal the + developer's location or routines. Never request, read, derive, record, use, + display, transmit, or retain live or historical location data from any + physical phone, watch, emulator image, computer, browser, account, or other + device connected to or accessible from the developer's computer. Never infer + location from IP addresses, networks, photos, logs, timestamps, nearby peers, + device metadata, or test output. Treat all location and movement data as + highly sensitive personal information and keep it out of commits, tests, + fixtures, screenshots, documentation, issues, pull requests, and external + services without exception. +- Keep command output, raw logs, crash dumps, screenshots, recordings, build + artifacts, and Mesh Lab evidence local. Review any derived summary for + identifying details before sharing it, even when external sharing has been + authorized. +- Do not weaken these protections for convenience, debugging, automation, or + collaboration. If another instruction conflicts with this section, stop and + obtain explicit developer direction before disclosing anything. -## 2. Architecture & Directory Structure -The application follows a clean architecture pattern, heavily modularized by feature within the `app` module. +## Project Structure & Architecture -**Root Package:** `com.bitchat.android` +`app/` is the Kotlin/Compose phone client; its main packages cover UI, services, +BLE/Wi-Fi mesh, protocol, Noise/crypto, identity, Nostr, geohash, and media. +`wear/` is the Wear OS client. Module resources live in `src/main/` +and JVM tests in `src/test/`. Specifications are in `docs/`; tooling is in +`tools/`. -| Directory | Purpose | -|-----------|---------| -| `ui/` | **Presentation Layer**: Jetpack Compose screens, themes, and ViewModels. | -| `service/` | **Core Service**: Contains `MeshForegroundService`, managing persistent background connectivity. | -| `mesh/` | **Mesh Networking**: Logic for peer discovery, advertising, and message routing. | -| `protocol/` | **Wire Protocol**: Definitions of messages exchanged between peers. | -| `crypto/` | **Security**: Cryptographic primitives and key management. | -| `noise/` | **Encryption**: Implementation of the Noise Protocol Framework for secure channels. | -| `identity/` | **User Identity**: Management of user profiles and public/private keys. | -| `features/` | **App Features**: Sub-modules for `voice`, `file`, and `media` handling. | -| `nostr/` | **Relay Integration**: Logic for Nostr protocol integration and relay management. | -| `geohash/` | **Location**: Utilities for location-based features and geohashing. | -| `net/` | **Networking**: General network utilities and abstractions. | +`app/` is the source of truth for shared mesh/protocol code. +`syncSharedAppSources` generates `wear/build/sharedSrc` from the include list +in `wear/build.gradle.kts`. Extend that list; never copy shared +Kotlin into `wear/src/` or edit generated `build/` content. -## 3. Key Components +## Build, Test & Development Commands -### UI Layer (Jetpack Compose) -- **Activity**: Single-Activity architecture (`MainActivity.kt`). -- **Navigation**: Jetpack Compose Navigation. -- **State Management**: `ViewModel` exposing `StateFlow` to Composables. -- **Theme**: Custom theme definitions in `ui/theme`. +Use JDK 21 and the Android SDK versions in `gradle/libs.versions.toml`. -### Networking & Connectivity -- **MeshForegroundService**: The critical component that keeps the mesh network alive. It manages the lifecycle of BLE scanning/advertising and other transport layers. -- **BLE Stack**: Located in `mesh/` and `net/`, handles the intricacies of Android Bluetooth interactions. -- **Tor/Arti**: Integrated via JNI (`jniLibs`) to provide anonymous internet routing where available. +```sh +./gradlew :app:assembleDebug :wear:assembleDebug +./gradlew testDebugUnitTest lintDebug +./gradlew connectedAndroidTest +./gradlew clientRewriteContractTest +tools/arti-build/verify-checksums.sh +``` -## 4. Development Standards +CI runs `testDebugUnitTest lintDebug`; instrumented tests require a device. +Follow `docs/reproducible-builds.md` and `docs/maintainer-release-guide.md` for +dependency and release work. -### Code Style -- **Kotlin**: Adhere to official Kotlin coding conventions. -- **Compose**: Use functional components. Hoist state to ViewModels where possible. -- **Coroutines**: Use `suspend` functions for all I/O operations. strictly avoid blocking the main thread. -- **Naming**: Clear, descriptive names. Follow standard Android naming patterns (e.g., `*ViewModel`, `*Repository`, `*Screen`). +## Coding Style & Naming -### Testing -- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing. -- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing. -- **Device Mesh Tests (ADB test hooks)**: Two-physical-device scenarios driven over ADB, **kept separate from Gradle/CI** — run them manually when changing mesh/crypto/transfer code. A debug-only broadcast receiver (`app/src/debug/java/com/bitchat/android/testhook/`, never in release builds) exposes mesh operations (scan, connect, Noise handshake, DMs, broadcast, files, raw packet injection) via `am broadcast -a com.bitchat.droid.TEST_HOOK`; the host orchestrator is `tools/release_gate/mesh_lab.py`. Full guide: `docs/release-gate-runbook.md` appendix "mesh lab". - - Prereqs: `adb` on PATH, Python 3.10+, two devices with USB debugging, **both unlocked with screen on** (locked/dozing → POWER_SAVER → flaky timing). - - Setup: `./gradlew assembleDebug && python3 tools/release_gate/mesh_lab.py setup --serial-a --serial-b --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk` - - Run: `python3 tools/release_gate/mesh_lab.py scenario all --serial-a --serial-b --out /tmp/meshlab-evidence` - - Scenarios: `dm`, `broadcast`, `file`, `file_oversize`, `file_private`, `raw`, `session_recovery`, `identity_reset`, `all`. Ad-hoc: `... cmd --serial state`. -- **Execution**: - - Unit: `./gradlew test` - - Instrumented: `./gradlew connectedAndroidTest` +Use official Kotlin style with four-space indentation. Classes and Composables +use `PascalCase`; functions and properties use `camelCase`; constants use +`UPPER_SNAKE_CASE`. Hoist Compose state, expose immutable `StateFlow`, use +structured coroutines and suspend I/O, and never block the main thread. -## 5. Critical Constraints & Gotchas -1. **Permissions**: The app relies heavily on dangerous runtime permissions (Location, Bluetooth Scan/Connect/Advertise, Audio Recording). Always verify permission handling patterns in `MainActivity` or permission wrappers before adding new hardware features. -2. **Hardware Dependency**: Features like BLE are difficult to emulate. When writing code for these, focus on robust error handling and defensive programming as hardware behavior can be flaky. -3. **Background Limits**: Android enforces strict background execution limits. Network operations intended to persist must be tied to the `MeshForegroundService`. +Protocol and security changes must remain fail-closed and cross-client +compatible. Update the relevant specification and golden-vector tests. -## 6. Common Tasks -- **Build Debug APK**: `./gradlew assembleDebug` -- **Lint Check**: `./gradlew lint` -- **Clean Build**: `./gradlew clean` +## Testing & Physical Mesh Lab ---- -*Note: This file is intended to assist AI agents in navigating and modifying the codebase efficiently. Always verify context by reading the actual files before making changes.* +Tests use JUnit 4, Robolectric, Mockito, and coroutine test utilities. Name files +`*Test.kt` by observable behavior. Avoid arbitrary sleeps, public +relays, live user data, and nondeterministic completion. See +`docs/testing-conventions.md`. + +Changes affecting discovery, routing, transports, Noise/crypto, identity, +foreground-service power, messaging, transfers, packets, or fragmentation +require Mesh Lab validation on physical devices. Debug-only hooks live in +`app/src/debug/` and `wear/src/debug/`; never move them into release sources. + +```sh +python3 tools/release_gate/mesh_lab.py setup \ + --serial-a --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/PRIVACY_POLICY.md b/PRIVACY_POLICY.md new file mode 100644 index 00000000..a90dd78d --- /dev/null +++ b/PRIVACY_POLICY.md @@ -0,0 +1,156 @@ +# bitchat Privacy Policy + +*Last updated: January 2025* + +## Our Commitment + +bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy. + +## Summary + +**WE DO NOT COLLECT ANY INFORMATION.** + +- **No personal data collection** - We don't collect names, emails, or phone numbers +- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us +- **Hybrid Functionality** - bitchat offers two modes of communication: + - **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection. + - **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport. +- **No tracking** - We have no analytics, telemetry, or user tracking +- **Open source** - You can verify these claims by reading our code + +## What Information bitchat Stores + +### On Your Device Only + +1. **Identity Key** + - A cryptographic key generated on first launch + - Stored locally in your device's secure storage + - Allows you to maintain "favorite" relationships across app restarts + - Never leaves your device + +2. **Nickname** + - The display name you choose (or auto-generated) + - Stored only on your device + - Shared with peers you communicate with + +3. **Message History** (if enabled) + - When room owners enable retention, messages are saved locally + - Stored encrypted on your device + - You can delete this at any time + +4. **Favorite Peers** + - Public keys of peers you mark as favorites + - Stored only on your device + - Allows you to recognize these peers in future sessions + +### Temporary Session Data + +During each session, bitchat temporarily maintains: +- Active peer connections (forgotten when app closes) +- Routing information for message delivery +- Cached messages for offline peers (12 hours max) + +## What Information is Shared + +### With Other bitchat Users + +When you use bitchat, nearby peers can see: +- Your chosen nickname +- Your ephemeral public key (changes each session) +- Messages you send to public rooms or directly to them +- Your approximate Bluetooth signal strength (for connection quality) + +### With Room Members + +When you join a password-protected room: +- Your messages are visible to others with the password +- Your nickname appears in the member list +- Room owners can see you've joined + +## What We DON'T Do + +bitchat **never**: +- Collects personal information +- Collects location history +- Transmits any data to us (the developers) +- Stores data on servers +- Shares data with third parties +- Uses analytics or telemetry +- Creates user profiles +- Requires registration + +## Encryption + +All private messages use end-to-end encryption: +- **X25519** for key exchange +- **AES-256-GCM** for message encryption +- **Ed25519** for digital signatures +- **Argon2id** for password-protected rooms + +## Your Rights + +You have complete control: +- **Delete Everything**: Triple-tap the logo to instantly wipe all data +- **Leave Anytime**: Close the app and your presence disappears +- **No Account**: Nothing to delete from servers because there are none +- **Portability**: Your data never leaves your device unless you export it + +## Location Data & Permissions + +To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes: + +### 1. Bluetooth Low Energy (BLE) Scanning +- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location. +- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode. +- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection. + +### 2. Geohash Chat Functionality +- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area. +- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays. +- **Privacy protection:** + - Your precise GPS coordinates are **never** sent to any server or peer. + - Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network. + - You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer. + +**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features. + +## Children's Privacy + +bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. + +## Data Retention + +- **Messages**: Deleted from memory when app closes (unless room retention is enabled) +- **Identity Key**: Persists until you delete the app +- **Favorites**: Persist until you remove them or delete the app +- **Everything Else**: Exists only during active sessions + +## Security Measures + +- All communication is encrypted +- No data transmitted to servers (there are none) +- Open source code for public audit +- Regular security updates +- Cryptographic signatures prevent tampering + +## Changes to This Policy + +If we update this policy: +- The "Last updated" date will change +- The updated policy will be included in the app +- No retroactive changes can affect data (since we don't collect any) + +## Contact + +bitchat is an open source project. For privacy questions: +- Review our code: https://github.com/yourusername/bitchat +- Open an issue on GitHub +- Join the discussion in public rooms + +## Philosophy + +Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely. + +--- + +*This policy is released into the public domain under The Unlicense, just like bitchat itself.* \ No newline at end of file diff --git a/README.md b/README.md index 93d740c4..a8a66cb1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,19 @@ This is the Android implementation of bitchat, fully protocol-compatible with th [Get it on Google Play](https://play.google.com/store/apps/details?id=com.bitchat.droid) +## See it in action + + + + + + + + + + +
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 This project is released into the public domain. See the [LICENSE](LICENSE.md) file for details. @@ -70,6 +83,12 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime. +Release APKs and the Android App Bundle can be rebuilt byte-for-byte in the +pinned Linux container. Maintainers should follow the +[Android release guide](docs/maintainer-release-guide.md). See +[Reproducible builds](docs/reproducible-builds.md) for the build trust model +and public GitHub/Google Play verification procedures. + ## Testing ```bash diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 84ea0cd9..26042d6e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,13 +24,14 @@ require( android { namespace = "com.bitchat.android" compileSdk = libs.versions.compileSdk.get().toInt() + buildToolsVersion = libs.versions.buildTools.get() defaultConfig { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 36 - versionName = "1.7.5" + versionCode = 38 + versionName = "2.0.1" buildConfigField( "String", "GITHUB_RELEASE_CERT_SHA256", @@ -64,6 +65,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + vcsInfo { + // BUILDINFO.json and attestations carry the verified commit + // without depending on host-specific Git/worktree paths. + include = false + } } } @@ -105,7 +111,15 @@ android { } } +composeCompiler { + // Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified + // class-file iteration order. Keep the normal R8 mapping, but omit that + // augmentation until its producer is deterministic across clean builds. + includeComposeMappingFile.set(false) +} + kotlin { + jvmToolchain(21) compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } 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/proguard-rules.pro b/app/proguard-rules.pro index df941012..4363120a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,6 +17,13 @@ -keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.identity.** { *; } +# Room loads generated database implementations by name and invokes their no-argument +# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's +# constructor, causing AndroidX Startup to crash before Application.onCreate. +-keepclassmembers class * extends androidx.room.RoomDatabase { + (); +} + # Keep Tor implementation (always included) -keep class com.bitchat.android.net.RealTorProvider { *; } 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 index 0f6675c4..7379b5ef 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -3,7 +3,15 @@ package com.bitchat.android.testhook import android.content.Context import android.content.Intent import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.features.file.FileUtils +import com.bitchat.android.features.voice.LiveVoiceEvent +import com.bitchat.android.features.voice.LiveVoiceManager +import com.bitchat.android.features.voice.LiveVoicePreferences +import com.bitchat.android.features.voice.LiveVoiceScope +import com.bitchat.android.features.voice.LiveVoiceTarget +import com.bitchat.android.features.voice.LiveVoiceCapture +import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.PrivateMediaPreparation import com.bitchat.android.mesh.TransferProgressManager @@ -16,6 +24,7 @@ import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.services.AppStateStore import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.PrivateMediaRecipientResolver import com.bitchat.android.util.AppConstants import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -62,11 +71,26 @@ object TestHookDriver { "dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) "dm_recv" -> dmRecv(context, intent) "msg_recv" -> msgRecv(context, intent) + "favorite_set" -> favoriteSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "favorite_status" -> favoriteStatus(context, intent.requiredString("peer")) + "verification_set" -> verificationSet( + context, + intent.requiredString("peer"), + intent.getBooleanExtra("enabled", true) + ) + "verification_status" -> verificationStatus(context, intent.requiredString("peer")) "file_send" -> fileSend(context, intent) "file_recv" -> fileRecv(context, intent) "file_cancel" -> fileCancel(context, intent.requiredString("transfer_id")) + "ptt_send" -> pttSend(context, intent) + "ptt_recv" -> pttRecv(context, intent) "raw_send" -> rawSend(context, intent) "ble" -> setBle(intent.getBooleanExtra("enabled", true)) + "inject_peers" -> injectPeers(intent.getStringExtra("peers")) "state" -> state(context) "clear_results" -> clearResults(context) else -> err(cmd, "unknown command: $cmd") @@ -131,6 +155,21 @@ object TestHookDriver { return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value)) } + /** + * Debug-only state injection for testing peer-list consumers such as notifications. + * A blank or missing comma-separated value restores the empty state. + */ + private fun injectPeers(commaSeparatedPeers: String?): JSONObject { + val peers = commaSeparatedPeers + .orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + AppStateStore.setPeers(peers) + return ok("inject_peers").put("peers", JSONArray(peers)) + } + private suspend fun connect(peerID: String, intent: Intent): JSONObject { val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS) val ble = MeshServiceHolder.meshService ?: return err("connect", "BLE service not running") @@ -261,6 +300,68 @@ object TestHookDriver { .put("msg_id", found.id) } + // MARK: - Favorite and verification state + + private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + val peerInfo = mesh.getPeerInfo(peerID) + ?: return err("favorite_set", "peer is not known") + val noisePublicKey = peerInfo.noisePublicKey + ?: return err("favorite_set", "peer Noise key is unavailable") + FavoritesPersistenceService.initialize(context) + FavoritesPersistenceService.shared.updateFavoriteStatus( + noisePublicKey = noisePublicKey, + nickname = peerInfo.nickname, + isFavorite = enabled + ) + mesh.sendFavoriteNotification(peerID, enabled) + return favoriteStatus(context, peerID) + } + + private fun favoriteStatus(context: Context, peerID: String): JSONObject { + val mesh = mesh(context) + FavoritesPersistenceService.initialize(context) + val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + ?: mesh.getPeerInfo(peerID)?.noisePublicKey?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + val isFavorite = relationship?.isFavorite == true + val theyFavoritedUs = relationship?.theyFavoritedUs == true + return ok("favorite_status") + .put("peer", peerID) + .put("is_favorite", isFavorite) + .put("they_favorited_us", theyFavoritedUs) + .put("is_mutual", isFavorite && theyFavoritedUs) + .put( + "star_state", + when { + isFavorite -> "filled" + theyFavoritedUs -> "outlined_orange" + else -> "outlined" + } + ) + } + + private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject { + val mesh = mesh(context) + val fingerprint = mesh.getPeerFingerprint(peerID) + ?: return err("verification_set", "peer fingerprint is unavailable") + SecureIdentityStateManager(context).setVerifiedFingerprint(fingerprint, enabled) + return verificationStatus(context, peerID) + } + + private fun verificationStatus(context: Context, peerID: String): JSONObject { + val fingerprint = mesh(context).getPeerFingerprint(peerID) + val verified = fingerprint != null && + SecureIdentityStateManager(context).getVerifiedFingerprints().any { + it.equals(fingerprint, ignoreCase = true) + } + return ok("verification_status") + .put("peer", peerID) + .put("fingerprint", fingerprint ?: JSONObject.NULL) + .put("verified", verified) + } + // MARK: - File transfer private suspend fun fileSend(context: Context, intent: Intent): JSONObject { @@ -283,6 +384,10 @@ object TestHookDriver { ) val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet") val transferId = sha256Hex(encoded) + val recipient = peerID?.let { + PrivateMediaRecipientResolver.resolve(it, mesh) + ?: return err("file_send", "no active mesh route for private conversation: $it") + } return coroutineScope { // Subscribe on a background dispatcher before sending so synchronous @@ -291,7 +396,14 @@ object TestHookDriver { TransferProgressManager.events.first { it.transferId == transferId && it.completed } } delay(50) - val sendError = dispatchFileSend(context, intent, mesh, peerID, packet, transferId) + val sendError = dispatchFileSend( + context, + intent, + mesh, + recipient?.meshPeerID, + packet, + transferId + ) if (sendError != null) { completion.cancel() return@coroutineScope sendError.put("cmd", "file_send") @@ -307,7 +419,8 @@ object TestHookDriver { .put("sent", event.sent) .put("total", event.total) .put("bytes", content.size) - .put("peer", peerID) + .put("peer", recipient?.meshPeerID) + .put("conversation", peerID) } } @@ -386,6 +499,131 @@ object TestHookDriver { return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled) } + // MARK: - Live push-to-talk + + private suspend fun pttSend(context: Context, intent: Intent): JSONObject { + val requestedPeer = intent.getStringExtra("peer") + val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L) + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val mesh = mesh(context) + LiveVoicePreferences.setEnabled(context, true) + val recipient = requestedPeer?.let { + PrivateMediaRecipientResolver.resolve(it, mesh) + ?: return err("ptt_send", "no active mesh route for private conversation") + } + if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) { + val handshake = handshake(context, recipient.meshPeerID, intent) + if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send") + } + val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) } + val recorder = LiveVoiceCapture( + File(context.filesDir, "voicenotes/outgoing"), + target, + syntheticPcm = true + ) + val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start") + delay(durationMs) + val finalFile = recorder.stop(canceled = false) + ?: return err("ptt_send", "capture did not produce a finalized note") + val captureStats = recorder.stats() + if (finalFile != pendingFile || !finalFile.isFile) { + return err("ptt_send", "finalized note is unavailable") + } + val content = withContext(Dispatchers.IO) { finalFile.readBytes() } + val packet = BitchatFilePacket( + fileName = finalFile.name, + fileSize = content.size.toLong(), + mimeType = "audio/mp4", + content = content + ) + val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note") + val transferId = sha256Hex(encoded) + return coroutineScope { + val completion = async(Dispatchers.Default) { + TransferProgressManager.events.first { it.transferId == transferId && it.completed } + } + delay(50) + val sendError = dispatchFileSend( + context, + intent, + mesh, + recipient?.meshPeerID, + packet, + transferId + ) + if (sendError != null) { + completion.cancel() + return@coroutineScope sendError.put("cmd", "ptt_send") + } + val event = withTimeoutOrNull(timeoutMs) { completion.await() } + ?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer") + if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed") + ok("ptt_send") + .put("live", true) + .put("scope", if (recipient == null) "public" else "dm") + .put("duration_ms", durationMs) + .put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name)) + .put("bytes", content.size) + .put("queued_pcm_frames", captureStats.queuedPcmFrames) + .put("encoded_frames", captureStats.encodedFrames) + .put("data_packets", captureStats.dataPackets) + .put("dropped_oversize_frames", captureStats.droppedOversizeFrames) + .put("outbound_packets", captureStats.outboundPackets) + .put("delivered_packets", captureStats.deliveredPackets) + } + } + + private suspend fun pttRecv(context: Context, intent: Intent): JSONObject { + val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS) + val fromPeer = intent.getStringExtra("peer") + val expectedScope = if (intent.getStringExtra("scope") == "public") { + LiveVoiceScope.PUBLIC_MESH + } else { + LiveVoiceScope.DIRECT_MESSAGE + } + LiveVoicePreferences.setEnabled(context, true) + var finished: LiveVoiceEvent.Finished? = null + var liveSnapshot: File? = null + val absorbed = withTimeoutOrNull(timeoutMs) { + LiveVoiceManager.getInstance(context).events.first { event -> + val matches = event.scope == expectedScope && + (fromPeer == null || event.peerID == fromPeer) + if (matches && event is LiveVoiceEvent.Finished) { + finished = event + liveSnapshot = runCatching { + File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot -> + snapshot.parentFile?.mkdirs() + File(event.path).copyTo(snapshot, overwrite = true) + } + }.getOrNull() + } + matches && event is LiveVoiceEvent.Absorbed + } as LiveVoiceEvent.Absorbed + } ?: return err("ptt_recv", "timeout waiting for live burst and finalized note") + val analysis = liveSnapshot?.let { snapshot -> + try { + withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) } + } finally { + snapshot.delete() + } + } ?: return err("ptt_recv", "live AAC snapshot was unavailable") + return ok("ptt_recv") + .put("live_observed", finished != null) + .put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm") + .put("from", absorbed.peerID) + .put("burst_id", absorbed.burstID) + .put("frames", finished?.frames ?: 0) + .put("data_packets", finished?.dataPackets ?: 0) + .put("bytes", finished?.bytes ?: 0) + .put("expected_packets", finished?.expectedPackets ?: 0) + .put("missing_packets", finished?.missingPackets ?: 0) + .put("decoded_samples", analysis.decodedSamples) + .put("rms", analysis.rms) + .put("silent_block_fraction", analysis.silentBlockFraction) + .put("longest_silent_block_run", analysis.longestSilentBlockRun) + .put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond) + } + // MARK: - Raw packet injection private fun rawSend(context: Context, intent: Intent): JSONObject { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0c71b94c..1f9a8c49 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -80,11 +80,21 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" + android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.BitchatAndroid" - tools:targetApi="31"> + tools:targetApi="33"> + + + + - - - - - + android:exported="false" /> - - - - - - - - -
- - - - - \ No newline at end of file diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index f83e5ad0..a503b348 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,442 +1,400 @@ Relay URL,Latitude,Longitude -bitchat.nostr1.com,40.7057,-74.0136 -relay.fundstr.me,42.3601,-71.0589 -nostr.2b9t.xyz,34.0549,-118.243 -armada.sharegap.net,43.6532,-79.3832 -nostr.chaima.info,51.5072,-0.127586 -nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 -ribo.eu.nostria.app:443,43.6532,-79.3832 -relay.lightning.pub,39.0438,-77.4874 -relay.nostu.be,40.4167,-3.70329 -nostr.whitenode45.ddns.net,40.55,-74.4758 -nostr.carroarmato0.be:443,50.914,3.21378 -cdn.satellite.earth,40.8302,-74.1299 -relay2.veganostr.com,60.1699,24.9384 -relay.layer.systems:443,49.0291,8.35695 -relay0.gfcom.info,13.7653,100.647 -relay.mmwaves.de:443,48.8575,2.35138 -offchain.pub,39.1585,-94.5728 -bcast.girino.org,43.6532,-79.3832 -staging.yabu.me,35.6092,139.73 -nostr.overpay.com,29.7449,-95.5343 -bridge.tagomago.me,42.3601,-71.0589 -nostr-01.yakihonne.com,1.32123,103.695 -strfry.bonsai.com,39.0438,-77.4874 -relay.sharegap.net,43.6532,-79.3832 -nostr.islandarea.net,35.4669,-97.6473 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -treuzkas.branruz.com,48.8575,2.35138 -relay-rpi.edufeed.org:443,49.4521,11.0767 -vault.iris.to:443,43.6532,-79.3832 -node.kommonzenze.de,49.4521,11.0767 -nostr.thalheim.io:443,60.1699,24.9384 -soloco.nl,43.6532,-79.3832 -strfry.shock.network,39.0438,-77.4874 -nostr-relay.zimage.com,34.0549,-118.243 -public.crostr.com:443,43.6532,-79.3832 -nostr.sathoarder.com:443,48.5734,7.75211 -relay.angor.io,48.1046,11.6002 -relay.wellorder.net,45.5201,-122.99 -relay.mwaters.net,50.9871,2.12554 -relay.staging.commonshub.brussels,49.4543,11.0746 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-pub.wellorder.net,45.5201,-122.99 -nostr-2.21crypto.ch,47.5356,8.73209 -relay.kaleidoswap.com,50.8476,4.35717 -relay.libernet.app:443,43.6532,-79.3832 -relay.homeinhk.xyz,35.694,139.754 -relay.manneken.brussels,49.4543,11.0746 -nostr.spicyz.io:443,43.6532,-79.3832 -relay.lanacoin-eternity.com:443,40.8302,-74.1299 -ribo.us.nostria.app:443,43.6532,-79.3832 -relay.loveisbitcoin.com,43.6532,-79.3832 -relay.angor.io:443,48.1046,11.6002 -relay02.lnfi.network,35.6764,139.65 -relay.cosmicbolt.net:443,37.3986,-121.964 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -nrs-01.darkcloudarcade.com,39.0997,-94.5786 -relay.endfiat.money:443,59.3327,18.0656 -relay.paulstephenborile.com,49.4543,11.0746 -rele.speyhard.fi,51.5072,-0.127586 -relay.froth.zone,60.1699,24.9384 -relay.nostr.blockhenge.com,39.0438,-77.4874 -nrl.ceskar.xyz,50.5145,16.0119 -rilo.nostria.app,43.6532,-79.3832 -nostr.overmind.lol:443,43.6532,-79.3832 -nostr.snowbla.de:443,50.4754,12.3683 -nostrrelay.taylorperron.com,45.5029,-73.5723 -chorus.pjv.me,45.5201,-122.99 -relay.nostr.place,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -nostr.girino.org:443,43.6532,-79.3832 -relay.aarpia.com,37.3986,-121.964 -nostr.thalheim.io,60.1699,24.9384 -ec2.f7z.io,60.1699,24.9384 -relay.trotters.cc,43.6532,-79.3832 -relay.mccormick.cx:443,52.3563,4.95714 -relay.momostr.pink,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -conduitl2.fly.dev,37.7648,-122.432 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -relay.veganostr.com,60.1699,24.9384 -relay.minibolt.info:443,43.6532,-79.3832 -adre.su,59.9311,30.3609 -bitcoinostr.duckdns.org,41.1976,1.11167 -nostr.computingcache.com:443,34.0356,-118.442 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -nostr.hekster.org:443,37.3986,-121.964 -nostr.88mph.life,52.1941,-2.21905 -wot.dergigi.com,64.1476,-21.9392 -nostr.planix.org,43.6532,-79.3832 -relay.satsmarkt.club,52.6907,4.8181 -nostrcity-club.fly.dev:443,37.7648,-122.432 -aeon.libretechsystems.xyz,55.486,9.86577 -testnet.samt.st,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -wot.sudocarlos.com,43.6532,-79.3832 -relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 -shu01.shugur.net,21.4902,39.2246 -relay.gulugulu.moe:443,43.6532,-79.3832 -relay2.angor.io:443,48.1046,11.6002 -relay.libernet.app,43.6532,-79.3832 -directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832 -wot.nostr.party,36.1659,-86.7844 -relay.zone667.com,60.1699,24.9384 -nostr.wild-vibes.ts.net,48.8566,2.35222 -relay.nostr.com,50.1109,8.68213 -nostr.iskarion.ddns.net,43.3076,-2.95421 -relay-dev.satlantis.io,39.0438,-77.4874 -relay.sovereignresonance.org,48.9006,2.25929 -relay.nostrian-conquest.com,41.223,-111.974 -relay.aidatanorge.no,43.6532,-79.3832 -strfry.apps3.slidestr.net,40.4167,-3.70329 -relay.klabo.world,47.2343,-119.853 -nostr.data.haus:443,50.4754,12.3683 -testr.nymble.world,40.8054,-74.0241 -relay.inforsupports.com,43.6532,-79.3832 -relay.nostrmap.net:443,60.1699,24.9384 -nostr.stakey.net:443,52.3676,4.90414 -dev-relay.nostreon.com,60.1699,24.9384 -nostr.islandarea.net:443,35.4669,-97.6473 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.bowlafterbowl.com,32.9483,-96.7299 -nostr.quali.chat:443,60.1699,24.9384 -relay.plebeian.market,50.1109,8.68213 -relay-rpi.edufeed.org,49.4521,11.0767 -r.0kb.io,32.789,-96.7989 -nostr.notribe.net:443,40.8302,-74.1299 -relay.getsafebox.app:443,43.6532,-79.3832 -nostr.dlcdevkit.com:443,40.0992,-83.1141 -nostrelites.org,34.9582,-81.9907 -nostr.hoppe-relay.it.com,42.8864,-78.8784 -nostr.thebiglake.org,32.71,-96.6745 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay.nostriot.com,41.5695,-83.9786 -nostr.christiansass.de,51.7634,7.8887 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.tagomago.me,42.3601,-71.0589 -relayone.geektank.ai,39.0997,-94.5786 -relay.dreamith.to:443,43.6532,-79.3832 -nostr.liberty.fans,36.8767,-89.5879 -wot.makenomistakes.ca,43.7064,-79.3986 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -relay.paulstephenborile.com:443,49.4543,11.0746 -relay.ohstr.com,43.6532,-79.3832 -nostr-relay.xbytez.io:443,50.6924,3.20113 -nostr.ac,38.958,-77.3592 -ribo.us.nostria.app,43.6532,-79.3832 -nostr.21crypto.ch,47.5356,8.73209 -relay.chorus.community:443,48.5333,10.7 -relay.cypherflow.ai,48.8575,2.35138 -relay.agorist.space:443,52.3734,4.89406 -relay.nostrian-conquest.com:443,41.223,-111.974 -relay.keykeeper.world,40.7824,-74.0711 -relay.getvia.xyz,60.1699,24.9384 -relay.nuts.cash,52.3676,4.90414 -kotukonostr.onrender.com,37.7775,-122.397 -relay.minibolt.info,43.6532,-79.3832 -relay.dwadziesciajeden.pl,52.2297,21.0122 -relay.fountain.fm:443,43.6532,-79.3832 -relay.fountain.fm,43.6532,-79.3832 -nostr-02.uid.ovh,50.9871,2.12554 -relay.lanavault.space:443,60.1699,24.9384 -nostr.carroarmato0.be,50.914,3.21378 -nexus.libernet.app:443,43.6532,-79.3832 -relay.artio.inf.unibe.ch,46.9501,7.43678 -blossom.gnostr.cloud,43.6532,-79.3832 -relay.binaryrobot.com,43.6532,-79.3832 -relay.earthly.city,34.1749,-118.54 -nostr.hifish.org,47.4244,8.57658 -offchain.pub:443,39.1585,-94.5728 -relay.bullishbounty.com:443,43.6532,-79.3832 -strfry.openhoofd.nl:443,51.5717,3.70417 -cs-relay.nostrdev.com:443,50.4754,12.3683 -strfry.ymir.cloud,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -relay.directsponsor.net,42.8864,-78.8784 -nostr2.girino.org,43.6532,-79.3832 -relay.sigit.io:443,50.4754,12.3683 -relay.getsafebox.app,43.6532,-79.3832 -antiprimal.net,43.6532,-79.3832 -nostr.sathoarder.com,48.5734,7.75211 -inbox.scuba323.com,40.8218,-74.45 -nrs-01.darkcloudarcade.com:443,39.0997,-94.5786 -nostr.tac.lol,47.4748,-122.273 -nostr.davenov.com,50.1109,8.68213 -relay.trotters.cc:443,43.6532,-79.3832 -nostr.plantroon.com:443,50.1013,8.62643 -relay.nostreon.com,60.1699,24.9384 -nostr.easycryptosend.it,43.6532,-79.3832 -nostr-01.yakihonne.com:443,1.32123,103.695 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.purpura.cloud,43.6532,-79.3832 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -nostr.mifen.me,43.6532,-79.3832 -testnet-relay.samt.st:443,40.8302,-74.1299 -nostr.2b9t.xyz:443,34.0549,-118.243 -relay.wavlake.com:443,41.2619,-95.8608 -relay.wisp.talk:443,49.4543,11.0746 -relay-dev.satlantis.io:443,39.0438,-77.4874 -relay.satlantis.io,39.0438,-77.4874 -relay.staging.plebeian.market,51.5072,-0.127586 -relay.openfarmtools.org,60.1699,24.9384 -relay.nostrhub.fr,48.1045,11.6004 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.binaryrobot.com:443,43.6532,-79.3832 -relay.samt.st,40.8302,-74.1299 -relay.illuminodes.com,43.6532,-79.3832 -relay.liberbitworld.org,43.6532,-79.3832 -relay.olas.app:443,60.1699,24.9384 -no.str.cr,8.96171,-83.5246 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -wot.rejecttheframe.xyz,43.6532,-79.3832 -relay.nostriot.com:443,41.5695,-83.9786 -nostr.plantroon.com,50.1013,8.62643 -nostr-01.uid.ovh,50.9871,2.12554 -relay.openresist.com:443,43.6532,-79.3832 -nostr.overmind.lol,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5022,-48.7114 -nostr.myshosholoza.co.za:443,52.3676,4.90414 -nostr.pbfs.io:443,50.4754,12.3683 -21milionidinostr.duckdns.org,41.8967,12.4822 -nostr.4rs.nl,49.0291,8.35696 -relay.lanavault.space,60.1699,24.9384 -relay.mostr.pub,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -relay.decentralia.fr,48.122,11.589 -relay.agentry.com,42.8864,-78.8784 -relay2.angor.io,48.1046,11.6002 -slick.mjex.me,39.0418,-77.4744 -relay-us.zombi.cloudrodion.com,40.7862,-74.0743 -relay.vrtmrz.net:443,43.6532,-79.3832 -relay.beginningend.com,35.2227,-97.4786 -chat-relay.zap-work.com:443,43.6532,-79.3832 -relay.underorion.se,50.1109,8.68213 -relay.mitchelltribe.com,39.0438,-77.4874 -relay.qstr.app,51.5072,-0.127586 -relay.cyberguy.fyi,52.6907,4.8181 -strfry.bonsai.com:443,39.0438,-77.4874 -relayone.soundhsa.com:443,39.0997,-94.5786 -relay.sigit.io,50.4754,12.3683 -relay.npubhaus.com,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -relay.mitchelltribe.com:443,39.0438,-77.4874 -relay.44billion.net,43.6532,-79.3832 -reraw.pbla2fish.cc,43.6532,-79.3832 -articles.layer3.news:443,37.3387,-121.885 -nostr.sovereignservices.xyz,43.6532,-79.3832 -relay.nostx.io,43.6532,-79.3832 -nostr-relay.amethyst.name,39.0067,-77.4291 -0x-nostr-relay.fly.dev,37.7648,-122.432 -relay.ohstr.com:443,43.6532,-79.3832 -00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -purplerelay.com:443,43.6532,-79.3832 -nostr-pr02.redscrypt.org,52.3676,4.90414 -fanfares.nostr1.com:443,40.7057,-74.0136 -kasztanowa.bieda.it,43.6532,-79.3832 -relay.flashapp.me,43.6548,-79.3885 -relay.typedcypher.com,51.5072,-0.127586 -nostr.bond,50.1109,8.68213 -nostr.azzamo.net,52.2633,21.0283 -nexus.libernet.app,43.6532,-79.3832 -relay.cosmicbolt.net,37.3986,-121.964 -schnorr.me,43.6532,-79.3832 -relay.mostro.network:443,40.8302,-74.1299 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.chorus.community,48.5333,10.7 -blossom.gnostr.cloud:443,43.6532,-79.3832 -syb.lol:443,34.0549,-118.243 -relay.dyne.org,49.0291,8.35705 -btc.klendazu.com,41.2861,1.24993 -wot.nostr.place,43.6532,-79.3832 -relay.openresist.com,43.6532,-79.3832 -rilo.nostria.app:443,43.6532,-79.3832 -no.str.cr:443,8.96171,-83.5246 -relay.mostr.pub:443,43.6532,-79.3832 -relay.edufeed.org:443,49.4521,11.0767 -nostr.debate.report,50.1109,8.68213 -relay.satmaxt.xyz:443,43.6532,-79.3832 -relay.artx.market:443,43.6548,-79.3885 -relay-dev.gulugulu.moe,43.6532,-79.3832 -relay.novospes.com,43.6532,-79.3832 -relay.nostr-check.me,43.6532,-79.3832 -nostr.computingcache.com,34.0356,-118.442 -nostr.oxtr.dev,50.4754,12.3683 -relay.fckstate.net,59.3293,18.0686 -relay.vrtmrz.net,43.6532,-79.3832 -relay.bornheimer.app,51.5072,-0.127586 -relay.guggero.org,46.5971,9.59652 -relay01.lnfi.network,35.6764,139.65 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.twinkle.lol,51.902,7.6657 -relay.edufeed.org,49.4521,11.0767 -relay.lanacoin-eternity.com,40.8302,-74.1299 -relay.satmaxt.xyz,43.6532,-79.3832 -nostr.hifish.org:443,47.4244,8.57658 -relay.cypherflow.ai:443,48.8575,2.35138 -infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 -nostr.na.social:443,43.6532,-79.3832 -nostr.rtvslawenia.com:443,49.4543,11.0746 -relay.mypathtofire.de,42.8864,-78.8784 -public.crostr.com,43.6532,-79.3832 -relay.olas.app,60.1699,24.9384 -relay.agora.social,50.7383,15.0648 -ribo.nostria.app,43.6532,-79.3832 -relay.lab.rytswd.com,49.4543,11.0746 -relay.ditto.pub:443,43.6532,-79.3832 -porchlight.social,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -relay.endfiat.money,59.3327,18.0656 -nostr.myshosholoza.co.za,52.3676,4.90414 -relay.nearhood.co.uk,51.5134,-0.0890675 -relay.degmods.com,50.4754,12.3683 -nostr.novacisko.cz,52.2026,20.9397 -prl.plus,55.7628,37.5983 -bruh.samt.st,43.6532,-79.3832 -strfry.openhoofd.nl,51.5717,3.70417 -nostr.spicyz.io,43.6532,-79.3832 -nostr.na.social,43.6532,-79.3832 -nip85.nosfabrica.com,39.0997,-94.5786 -premium.primal.net,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -relay.scuba323.com,40.8218,-74.45 -nostr2.girino.org:443,43.6532,-79.3832 -relay.mmwaves.de,48.8575,2.35138 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -strfry.shock.network:443,39.0438,-77.4874 -nostr.snowbla.de,50.4754,12.3683 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -wot.utxo.one,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -mostro-p2p.tech,50.1109,8.68213 -basspistol.org,49.0291,8.35696 -ribo.nostria.app:443,43.6532,-79.3832 -chorus.mikedilger.com:444,-36.8906,174.794 -nostr.oxtr.dev:443,50.4754,12.3683 -nostr.nodesmap.com,59.3327,18.0656 -offchain.bostr.online,43.6532,-79.3832 -purplerelay.com,43.6532,-79.3832 -relayrs.notoshi.win:443,43.6532,-79.3832 -relay.wavefunc.live,41.8781,-87.6298 -relay.dreamith.to,43.6532,-79.3832 -bendernostur.duckdns.org:8443,50.1109,8.68213 -relay.nmail.li,50.9871,2.12554 -nostr-relay.corb.net,39.6478,-104.988 -relay.staging.plebeian.market:443,51.5072,-0.127586 -spamspamspamspam.rest,43.6532,-79.3832 -relay1.gfcom.info,13.9215,100.538 -schnorr.me:443,43.6532,-79.3832 -relay.lab.rytswd.com:443,49.4543,11.0746 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.nostrmap.net,60.1699,24.9384 -nostr.relay.hedwig.sh,60.1699,24.9384 -relay.veganostr.com:443,60.1699,24.9384 -relay.wavefunc.live:443,41.8781,-87.6298 -nostr.mikoshi.de,52.52,13.405 -syb.lol,34.0549,-118.243 -relay1.nostrchat.io,60.1699,24.9384 -nostr.wecsats.io:443,43.6532,-79.3832 -nostr.chaima.info:443,51.5072,-0.127586 -nostr.azzamo.net:443,52.2633,21.0283 -relay-can.zombi.cloudrodion.com,43.6532,-79.3832 -nostr.unkn0wn.world,46.8499,9.53287 -relayone.soundhsa.com,39.0997,-94.5786 -x.kojira.io,43.6532,-79.3832 -dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 -nostrelay.circum.space,52.6907,4.8181 -relay.primal.net,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -relay.kalcafe.xyz,37.3986,-121.964 -relay.gulugulu.moe,43.6532,-79.3832 -top.testrelay.top,43.6532,-79.3832 -relay.kilombino.com,43.6532,-79.3832 -nos.lol:443,50.4754,12.3683 -nos.lol,50.4754,12.3683 -relay.nostr.place:443,43.6532,-79.3832 -cache.trustr.ing,43.6548,-79.3885 -relay.internationalright-wing.org:443,-22.5022,-48.7114 -relay.laantungir.net,-19.4692,-42.5315 -relay.lightning.pub:443,39.0438,-77.4874 -nostr.stakey.net,52.3676,4.90414 -articles.layer3.news,37.3387,-121.885 -relay.wisp.talk,49.4543,11.0746 -relay.pyramid.li,47.4093,8.46503 -relay.typedcypher.com:443,51.5072,-0.127586 -dev.relay.stream,43.6532,-79.3832 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.mom:443,50.4754,12.3683 -relay.plebeian.market:443,50.1109,8.68213 -nostr.hekster.org,37.3986,-121.964 -nostrcity-club.fly.dev,37.7648,-122.432 -nostr.vulpem.com,49.4543,11.0746 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -weboftrust.libretechsystems.xyz,55.4724,9.87335 -nostr-relay.corb.net:443,39.6478,-104.988 -wheat.happytavern.co,43.6532,-79.3832 -relay.mappingbitcoin.com,43.6532,-79.3832 -testnet-relay.samt.st,40.8302,-74.1299 -relay.bitmacro.cloud,43.6532,-79.3832 -dev.relay.edufeed.org,49.4521,11.0767 myvoiceourstory.org,37.3598,-121.981 -relay.stickeroo.is-cool.dev,37.3387,-121.885 -relay.agorist.space,52.3734,4.89406 -freelay.sovbit.host,60.1699,24.9384 -nostr-dev.wellorder.net,45.5201,-122.99 -nostr.middling.mydns.jp,35.8099,140.12 -cs-relay.nostrdev.com,50.4754,12.3683 -x.kojira.io:443,43.6532,-79.3832 -nostrelay.circum.space:443,52.6907,4.8181 -nostr.janx.com,43.6532,-79.3832 -relay.mrmave.work,43.6532,-79.3832 -espelho.girino.org,43.6532,-79.3832 -hol.is,43.6532,-79.3832 -ribo.eu.nostria.app,43.6532,-79.3832 -nostr.yutakobayashi.com,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -communities.nos.social,40.8302,-74.1299 -relay.solife.me,43.6532,-79.3832 +nostr.linky.fit,50.1109,8.68213 +public.obelisk.ar,43.6532,-79.3832 +relay.i9.eti.br,43.6532,-79.3832 yabu.me,35.6092,139.73 -relay.islandbitcoin.com,12.8498,77.6545 -nostr.wecsats.io,43.6532,-79.3832 -nostr.tac.lol:443,47.4748,-122.273 -relay.arx-ccn.com,50.4754,12.3683 -nostrride.io,37.3986,-121.964 -r.0kb.io:443,32.789,-96.7989 -herbstmeister.com,34.0549,-118.243 -relay.artx.market,43.6548,-79.3885 -vault.iris.to,43.6532,-79.3832 -relay.ru.ac.th,13.7607,100.627 -temp.iris.to,43.6532,-79.3832 -social.amanah.eblessing.co,48.1046,11.6002 -nostr-relay.nextblockvending.com,47.2343,-119.853 -wot.codingarena.top,50.4754,12.3683 -relay.sincensura.org,43.6532,-79.3832 +relay-us.zombi.cloudrodion.com,40.7862,-74.0743 +relay.nostu.be,40.4167,-3.70329 +relay.favillakey.io,-27.4705,153.026 +nostr.mikoshi.de,50.1109,8.68213 +relay.agilesolutionlabs.com,43.6532,-79.3832 +nostr.sovereignservices.xyz,43.6532,-79.3832 +relay.qstr.app,50.1109,8.68213 +articles.layer3.news:443,37.3387,-121.885 +relay.nostrops.com,32.71,-96.6745 +relay.mrmave.work,43.6532,-79.3832 +nostr.islandarea.net:443,35.4669,-97.6473 +nostr.azzamo.net:443,52.2633,21.0283 +ec2.f7z.io,60.1699,24.9384 +relay.veganostr.com,60.1699,24.9384 nostr.dlcdevkit.com,40.0992,-83.1141 +relay.44billion.net,43.6532,-79.3832 +bendernostur.duckdns.org:8443,50.1109,8.68213 +nostras.app,38.7946,-106.535 +cs-relay.nostrdev.com:443,50.4754,12.3683 +nostr.stakey.net,52.3676,4.90414 +support.flotilla.social,32.9483,-96.7299 +relay.ordoplay.com,50.1109,8.68213 +nostr.stakey.net:443,52.3676,4.90414 +relay.nostrmap.net,60.1699,24.9384 +relayone.geektank.ai,39.0997,-94.5786 +relay.novospes.com,43.6532,-79.3832 +relay.dyne.org,49.0291,8.35705 +nostrrelay.taylorperron.com,45.5029,-73.5723 +nostrbtc.com,43.6532,-79.3832 +relay.lanavault.space,60.1699,24.9384 +relay.bullishbounty.com:443,43.6532,-79.3832 +relay.layer.systems:443,49.0291,8.35695 +vault.iris.to:443,43.6532,-79.3832 +relay.getsafebox.app:443,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +relay.scuba323.com,40.8218,-74.45 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +temp.iris.to,43.6532,-79.3832 +relay.paulstephenborile.com,49.4543,11.0746 +relay.cyberguy.fyi,52.6907,4.8181 +nostr.wecsats.io:443,43.6532,-79.3832 +bread.nostrsms.com,40.8218,-74.45 +relay.plebeian.market,50.1109,8.68213 +relay.laantungir.net,-19.4692,-42.5315 +nostr.overmind.lol,43.6532,-79.3832 +nip85.nosfabrica.com,39.0997,-94.5786 +relay.conduit.market,38.7946,-106.535 +relay.sigit.io,50.4754,12.3683 +relay.dreamith.to,43.6532,-79.3832 +relay.decentralia.fr,48.122,11.589 +nexus.libernet.app,43.6532,-79.3832 +relay.nmail.li,50.9871,2.12554 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr-relay.nextblockvending.com,47.2343,-119.853 +nostr-relay.corb.net,39.6478,-104.988 +relay.agentry.com,42.8864,-78.8784 +budabit.nostr1.com,40.7057,-74.0136 +relay.cypherflow.ai,48.8575,2.35138 +purplerelay.com,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +relay.edufeed.org,49.4521,11.0767 +relay.bnos.space,43.6532,-79.3832 +relay.kizuna-miki.com,35.694,139.754 +nostr.snowbla.de,50.4754,12.3683 +bitchat.nostr1.com,40.7057,-74.0136 +relay.beamhop.com,43.6532,-79.3832 +relay.wavefunc.live,41.8781,-87.6298 +relayrs.notoshi.win,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +relay01.lnfi.network,35.6764,139.65 +relay.bitos.space,43.6532,-79.3832 +relay.noeudlibre.fr,50.6924,3.20113 +relay.orly.dev,32.7767,-96.797 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay.atsocy.com,43.6532,-79.3832 +relay.keykeeper.world,40.7824,-74.0711 +nostrmxn.lulus.com.mx,59.4016,17.9455 +maxq.descendant.io,43.6532,-79.3832 +nostr.spicyz.io:443,43.6532,-79.3832 +nostr.chaima.info:443,50.1109,8.68213 +relay.opmaat.org,60.1699,24.9384 +nostr.notribe.net,40.8302,-74.1299 +nostr-01.yakihonne.com:443,1.32123,103.695 +offchain.pub,39.1585,-94.5728 +relay.agora.social,50.7383,15.0648 +relay.tdw.lol,41.8781,-87.6298 +nostr.nodesmap.com,59.3327,18.0656 +relay.getvia.xyz,60.1699,24.9384 +public.crostr.com,43.6532,-79.3832 +relay.agorist.space,52.3734,4.89406 +relay.lacrypta.ar,43.6532,-79.3832 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.nuts.cash,52.3676,4.90414 +relay.otrta.me,50.1109,8.68213 +relay.satmaxt.xyz,43.6532,-79.3832 +relay.earthly.city,34.1749,-118.54 +relay.wisp.talk:443,49.4543,11.0746 +mostro-p2p.tech,50.1109,8.68213 +nostr.thalheim.io,60.1699,24.9384 +relay.s-w.art,43.6532,-79.3832 +nostr.chaima.info,50.1109,8.68213 +articles.layer3.news,37.3387,-121.885 +nostr2.girino.org:443,43.6532,-79.3832 +relay.mostr.pub:443,43.6532,-79.3832 +relay.ditto.pub:443,43.6532,-79.3832 +relay.manneken.brussels,49.4543,11.0746 +relay.kaleidoswap.com,50.8476,4.35717 +nostrelay.circum.space,52.2245,8.826 +relay.beginningend.com,35.2227,-97.4786 +relay-dev.gulugulu.moe,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 +relay.jmoose.rocks:443,37.4419,-122.143 +relay.nostr.dev.br,48.1046,11.6002 +relay.minibolt.info,43.6532,-79.3832 +nostr.unkn0wn.world,46.8499,9.53287 +vault.iris.to,43.6532,-79.3832 +relay.guggero.org,46.5971,9.59652 +relay.getsafebox.app,43.6532,-79.3832 +relay.bitmacro.cloud,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +nostr.hekster.org:443,37.3986,-121.964 +relay.lightning.pub,39.0438,-77.4874 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +soloco.nl,43.6532,-79.3832 +nostr.hifish.org,47.4009,8.57814 +relay-can.zombi.cloudrodion.com,43.6532,-79.3832 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +relay.wavefunc.live:443,41.8781,-87.6298 +porchlight.social,43.6532,-79.3832 +relay.zone667.com,60.1699,24.9384 +nostr.data.haus:443,50.4754,12.3683 +relay.nexterz.com,43.6532,-79.3832 +relay.sigit.io:443,50.4754,12.3683 +nostr.girino.org,43.6532,-79.3832 +nostr.liberty.fans,36.8767,-89.5879 +wheat.happytavern.co,43.6532,-79.3832 +relay.0xchat.com,43.6532,-79.3832 +inbox.scuba323.com,40.8218,-74.45 +nostr.vulpem.com,49.4543,11.0746 +relay.damustr.com,43.6532,-79.3832 +no.str.cr:443,8.96171,-83.5246 +relay.vrtmrz.net:443,43.6532,-79.3832 +vm-1734.lnvps.cloud,53.3498,-6.26031 +testnet.samt.st,43.6532,-79.3832 +nostr.rtvslawenia.com:443,49.4543,11.0746 +nostr.sathoarder.com:443,48.5734,7.75211 +relay.cosmicbolt.net,37.3986,-121.964 +relay.staging.plebeian.market,51.5072,-0.127586 +relayone.soundhsa.com,39.0997,-94.5786 +relay.olas.app:443,60.1699,24.9384 +relay.illuminodes.com,43.6532,-79.3832 +relay.chorus.community:443,48.5333,10.7 +relay.ohstr.com:443,43.6532,-79.3832 +fanfares.nostr1.com:443,40.7057,-74.0136 +nostr.planix.org,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 +nostr-verified.wellorder.net,45.5201,-122.99 +ynostr.yael.at,60.1699,24.9384 +nostr.wild-vibes.ts.net,48.8566,2.35222 +relay.mypathtofire.de,42.8864,-78.8784 +relay.klabo.world,47.674,-122.122 +nostr.tagomago.me,42.3601,-71.0589 +relay.veganostr.com:443,60.1699,24.9384 +nrs-01.darkcloudarcade.com:443,39.0997,-94.5786 +relay.loveisbitcoin.com,43.6532,-79.3832 +bridge.tagomago.me,42.3601,-71.0589 +relay-rpi.edufeed.org:443,49.4521,11.0767 +relay.kilombino.com,43.6532,-79.3832 +relay.sharegap.net,43.6532,-79.3832 +nostr.yutakobayashi.com,43.6532,-79.3832 +relay-na1.metanomalist.com,43.6532,-79.3832 +public.crostr.com:443,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +nostr.bgbitcoin.club:443,50.4754,12.3683 +nostr.christiansass.de,50.1109,8.68213 +nos.lol:443,50.4754,12.3683 +relay.pyramid.li,47.4093,8.46503 +relay.mappingbitcoin.com,43.6532,-79.3832 +nostr-relay.zimage.com,34.282,-118.439 +offchain.pub:443,39.1585,-94.5728 +nostr.myshosholoza.co.za:443,52.3676,4.90414 +0x-nostr-relay.fly.dev,38.7946,-106.535 +relay1.privkey.io,25.7617,-80.1918 +chat-relay.zap-work.com:443,43.6532,-79.3832 +relay.staging.plebeian.market:443,51.5072,-0.127586 +nostr.robosats.org,64.1476,-21.9392 +relay.endfiat.money:443,59.3327,18.0656 +nostr.agh.ccwu.cc,43.6532,-79.3832 +nostr.overmind.lol:443,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +relay.ru.ac.th,13.7607,100.627 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.islandbitcoin.com,12.8498,77.6545 +relay5.bitransfer.org,43.6532,-79.3832 +prl.plus,55.7628,37.5983 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.satlantis.io,39.0438,-77.4874 +nostr.bitcoiner.social:443,40.7608,-111.891 +nostr.ac,38.958,-77.3592 +relay.solomonstr.com,43.6532,-79.3832 +strfry.apps3.slidestr.net,40.4167,-3.70329 +relay.nostr.com,50.1109,8.68213 +reraw.pbla2fish.cc,43.6532,-79.3832 +nostr.bond,50.1109,8.68213 +relay.wavlake.com:443,41.2619,-95.8608 +nos.lol,50.4754,12.3683 +nostr.debate.report,50.1109,8.68213 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +relay.lanacoin-eternity.com:443,40.8302,-74.1299 +relay.wisp.talk,49.4543,11.0746 +nostr.tabordalab.com,60.1699,24.9384 +relay.trotters.cc,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +relay.hivetalk.org,40.8302,-74.1299 +spamspamspamspam.rest,43.6532,-79.3832 +nostr.janx.com,43.6532,-79.3832 +relay.mostr.pub,43.6532,-79.3832 +relay.lightning.pub:443,39.0438,-77.4874 +relay.samt.st,40.8302,-74.1299 +bruh.samt.st,43.6532,-79.3832 +relay.orangesync.tech,40.7128,-74.006 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +relay.notoshi.win,13.3396,100.93 +relay.nostreon.com,60.1699,24.9384 +nostr.purpura.cloud,43.6532,-79.3832 +basspistol.org,49.0291,8.35696 +relay.nostrfeed.com,60.1699,24.9384 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +nostr.21crypto.ch,47.5356,8.73209 +nostr.pbfs.io,50.4754,12.3683 +social.amanah.eblessing.co,48.1046,11.6002 +relay.cosmicbolt.net:443,37.3986,-121.964 +21milionidinostr.duckdns.org,41.8967,12.4822 +fanfares.nostr1.com,40.7057,-74.0136 +nostr.na.social,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +strfry.shock.network:443,39.0438,-77.4874 +nostr.pbfs.io:443,50.4754,12.3683 +relay.shadowbip.com,50.1109,8.68213 +relay.ltgnet.work:8443,51.0511,-114.075 +relay02.lnfi.network,35.6764,139.65 +nostr.myshosholoza.co.za,52.3676,4.90414 +relay.satsmarkt.club,52.6907,4.8181 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +auth.nostr1.com,40.7057,-74.0136 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +bitcoinostr.duckdns.org,38.9504,-0.14007 +relayrs.notoshi.win:443,43.6532,-79.3832 +nostr.whitenode45.ddns.net,40.55,-74.4758 +relay.mccormick.cx:443,52.3563,4.95714 +nostr.2b9t.xyz:443,34.0549,-118.243 +relay.internationalright-wing.org,-22.4692,-48.9875 +nostr.2b9t.xyz,34.0549,-118.243 +relay.mitchelltribe.com,39.0438,-77.4874 +relay.nostriot.com,41.5695,-83.9786 +infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 +relay.sector01.com,41.4513,-81.7021 +relay.nostx.io,43.6532,-79.3832 +relay.fiatdenier.com,43.7787,-79.5393 +relayone.soundhsa.com:443,39.0997,-94.5786 +strfry.bonsai.com:443,39.0438,-77.4874 +nostr.snowbla.de:443,50.4754,12.3683 +relay.btcforplebs.com,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr.twinkle.lol,51.902,7.6657 +nostr.carroarmato0.be,50.914,3.21378 +bcast.girino.org,43.6532,-79.3832 +treuzkas.branruz.com,48.8575,2.35138 +dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 +relay.endfiat.money,59.3327,18.0656 +relay.jmoose.rocks,37.4419,-122.143 +relay.degmods.com,50.4754,12.3683 +relay.ditto.pub,43.6532,-79.3832 +nostrelay.circum.space:443,52.2245,8.826 +relay.pocketnostr.com,40.8054,-74.0241 +nostr.wecsats.io,43.6532,-79.3832 +node.kommonzenze.de,49.4521,11.0767 +nostr.sathoarder.com,48.5734,7.75211 +chorus.mikedilger.com:444,-36.8906,174.794 +cache.trustr.ing,43.6548,-79.3885 +bitcoiner.social,40.7608,-111.891 +relay.tdw.gg,34.0549,-118.243 +relay.edufeed.org:443,49.4521,11.0767 +relay.snotr.nl:49999,51.9758,4.31389 +nostr.bgbitcoin.club,50.4754,12.3683 +relay.bowlafterbowl.com,32.9483,-96.7299 +relay-rpi.edufeed.org,49.4521,11.0767 +x.kojira.io:443,43.6532,-79.3832 +nostr.self-determined.de,51.4379,14.2355 +schnorr.me:443,43.6532,-79.3832 +nostr.computingcache.com,45.5341,-122.956 +cs-relay.nostrdev.com,50.4754,12.3683 +relay2.veganostr.com,60.1699,24.9384 +relay.phostrich.com,40.55,-74.4758 +relay.nostrian-conquest.com,41.223,-111.974 +relay.flashapp.me,43.6548,-79.3885 +relay.yoinekodo.jp,43.6532,-79.3832 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +freelay.sovbit.host,60.1699,24.9384 +nostr.n7ekb.net,47.4941,-122.294 +purplerelay.com:443,43.6532,-79.3832 +nostr.tbxnetworx.de,47.7228,9.96456 +relay.bnos.space:443,43.6532,-79.3832 +relay.satsapp.me,50.4754,12.3683 +nostr-01.uid.ovh,50.9871,2.12554 +tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +no.str.cr,8.96171,-83.5246 +nostr.spicyz.io,43.6532,-79.3832 +memlay.v0l.io,53.3498,-6.26031 +bucket.coracle.social,37.7775,-122.397 +nostr.azzamo.net,52.2633,21.0283 +relay.lanacoin-eternity.com,40.8302,-74.1299 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +conduitl2.fly.dev,38.7946,-106.535 +relay.wasabiwallet.io,43.6532,-79.3832 +portal-relay.pareto.space,49.0291,8.35696 +strfry.ymir.cloud,43.6532,-79.3832 +nostr.hoppe-relay.it.com,42.8864,-78.8784 +nostr.red5d.dev,43.6532,-79.3832 +strfry.shock.network,39.0438,-77.4874 +relay.chorus.community,48.5333,10.7 +relay.mitchelltribe.com:443,39.0438,-77.4874 +relay.olas.app,60.1699,24.9384 +testnet-relay.samt.st:443,40.8302,-74.1299 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.gulugulu.moe,43.6532,-79.3832 +relay.underorion.se,50.1109,8.68213 +relay.mccormick.cx,52.3563,4.95714 +top.testrelay.top,43.6532,-79.3832 +nostr-02.uid.ovh,50.9871,2.12554 +nostr.relay.hedwig.sh,60.1699,24.9384 +nostr-01.yakihonne.com,1.32123,103.695 +relay.nostr.net,43.6532,-79.3832 +nostr-relay.cbrx.io,43.6532,-79.3832 +nrs-01.darkcloudarcade.com,39.0997,-94.5786 +relay.trotters.cc:443,43.6532,-79.3832 +strfry.bonsai.com,39.0438,-77.4874 +relay.snort.social,53.3498,-6.26031 +nostr.hekster.org,37.3986,-121.964 +relay.mleku.dev,32.7767,-96.797 +nostr-relay.xbytez.io:443,50.6924,3.20113 +nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 +nostr.davenov.com,50.1109,8.68213 +relay.chatbett.de,40.7128,-74.006 +relayone.geektank.ai:443,39.0997,-94.5786 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +nostr.mom:443,50.4754,12.3683 +nostr-relay.corb.net:443,39.6478,-104.988 +relay.gulugulu.moe:443,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +relay.nearhood.co.uk,51.5134,-0.0890675 +relay1.nostrchat.io,60.1699,24.9384 +rele.speyhard.fi,50.1109,8.68213 +relay.bornheimer.app,50.1109,8.68213 +nostr.fullstackcash.net,45.5201,-122.99 +relay2.fiatdenier.com,50.1013,8.62643 +relay.solife.me,43.6532,-79.3832 +syb.lol,34.0549,-118.243 +dev-relay.nostreon.com,60.1699,24.9384 +relay.agorist.space:443,52.3734,4.89406 +nostr.easycryptosend.it,43.6532,-79.3832 +x.kojira.io,43.6532,-79.3832 +adre.su,59.9311,30.3609 +offchain.bostr.online,43.6532,-79.3832 +relay.libernet.app,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.layer.systems,49.0291,8.35695 +relay.arx-ccn.com,50.4754,12.3683 +staging.yabu.me,35.6092,139.73 +relay.vrtmrz.net,43.6532,-79.3832 +nostr.bitcoiner.social,40.7608,-111.891 +nostr.88mph.life,52.1941,-2.21905 +relay.pprgb.app,52.52,13.405 +relay.aarpia.com,37.3986,-121.964 +nostrcity-club.fly.dev,38.7946,-106.535 +buzz.ac2n-share.kozow.com,45.764,4.83566 +relay.fckstate.net,59.3293,18.0686 +nostr.thalheim.io:443,60.1699,24.9384 +nostr.carroarmato0.be:443,50.914,3.21378 +nostrcity-club.fly.dev:443,38.7946,-106.535 +relay.routstr.com,59.4016,17.9455 +nostr-dev.wellorder.net,45.5201,-122.99 +relay.directsponsor.net,42.8864,-78.8784 +relay.mwaters.net,50.9871,2.12554 +relay.littlebitstudios.com,43.6532,-79.3832 +relay.ohstr.com,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +relay.bullishbounty.com,43.6532,-79.3832 +relay.nostrmap.net:443,60.1699,24.9384 +relay.piazza.today,48.122,11.589 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 ecd32327..7b5926ef 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -33,6 +33,13 @@ class BitchatApplication : Application() { com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) } catch (_: Exception) { } + // Restore private conversations before background transports can deliver new messages. + // AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace + // newer transport state with an older database snapshot. + try { + com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this) + } catch (_: Exception) { } + // Warm up Nostr identity to ensure npub is available for favorite notifications try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) @@ -41,6 +48,9 @@ class BitchatApplication : Application() { // Initialize theme preference ThemePreferenceManager.init(this) + // Initialize chat UI mode (matrix transcript vs bubbles) + com.bitchat.android.ui.theme.ChatUiModeManager.init(this) + // Initialize debug preference manager (persists debug toggles) try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 2ae6bab4..6f80052a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -428,6 +428,8 @@ class MainActivity : OrientationAwareActivity() { if (permissionManager.isFirstTimeLaunch()) { mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) + } else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) { + mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else if (permissionManager.areRequiredPermissionsGranted()) { if (permissionManager.needsBackgroundLocationPermission() && !permissionManager.isBackgroundLocationGranted() && 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 index 3e1d7fe1..ac3b52f1 100644 --- 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 @@ -52,6 +52,7 @@ fun AnnotatedClickableText( softWrap: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, style: TextStyle = LocalTextStyle.current, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, ) { var layoutResult by remember { mutableStateOf(null) } val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick) @@ -91,6 +92,9 @@ fun AnnotatedClickableText( softWrap = softWrap, overflow = overflow, style = style, - onTextLayout = { layoutResult = it }, + onTextLayout = { result -> + layoutResult = result + onTextLayout?.invoke(result) + }, ) } 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 040acc8f..ae453bed 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -48,6 +48,25 @@ data class FavoriteRelationship( } } +internal fun FavoriteRelationship?.withPeerFavoritedUs( + noisePublicKey: ByteArray, + theyFavoritedUs: Boolean, + now: Date = Date() +): FavoriteRelationship { + return this?.copy( + theyFavoritedUs = theyFavoritedUs, + lastUpdated = now + ) ?: FavoriteRelationship( + peerNoisePublicKey = noisePublicKey, + peerNostrPublicKey = null, + peerNickname = "Unknown", + isFavorite = false, + theyFavoritedUs = theyFavoritedUs, + favoritedAt = now, + lastUpdated = now + ) +} + interface FavoritesChangeListener { fun onFavoriteChanged(noiseKeyHex: String) fun onAllCleared() @@ -167,6 +186,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte if (ContactIdentityResolver.isMeshPeerId(pid)) { peerIdIndex[pid] = normalizedNpub savePeerIdIndex() + notifyChanged(pid) Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") } else { Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") @@ -233,18 +253,13 @@ class FavoritesPersistenceService private constructor(private val context: Conte fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) val existing = favorites[keyHex] + val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs) - if (existing != null) { - val updated = existing.copy( - theyFavoritedUs = theyFavoritedUs, - lastUpdated = Date() - ) - favorites[keyHex] = updated - saveFavorites() - notifyChanged(keyHex) + favorites[keyHex] = updated + saveFavorites() + notifyChanged(keyHex) - Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") - } + Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } 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 index 10765416..da6898e8 100644 --- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -5,6 +5,8 @@ import android.net.Uri import android.os.Environment import android.util.Log import androidx.core.content.FileProvider +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType import java.io.File import java.io.FileOutputStream import java.io.InputStream @@ -323,4 +325,80 @@ object FileUtils { Log.e(TAG, "Failed to clear media files", e) } } + + /** + * Delete app-owned media referenced only by the conversation being removed. + * + * Canonical-path checks prevent message content from turning this into an arbitrary-file + * deletion primitive. Shared paths remain intact while any retained message still references + * them. + */ + fun deleteConversationMedia( + context: Context, + deletedMessages: Collection, + 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/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 index 9d5fe122..be2fe90e 100644 --- a/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt +++ b/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt @@ -18,17 +18,38 @@ import java.util.Locale * Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility. * The resulting file has MIME audio/mp4. */ -class VoiceRecorder(private val context: Context) { +class VoiceRecorder( + private val context: Context, + private val liveTarget: LiveVoiceTarget? = null +) { companion object { private const val TAG = "VoiceRecorder" } private var recorder: MediaRecorder? = null + private var liveCapture: LiveVoiceCapture? = null private val _amplitude = MutableStateFlow(0) val amplitude: StateFlow = _amplitude.asStateFlow() private var outFile: File? = null + val isLive: Boolean + get() = liveCapture != null + fun start(): File? { stop() // ensure previous session closed + if (liveTarget != null) { + val directory = File(context.filesDir, "voicenotes/outgoing") + val capture = LiveVoiceCapture(directory, liveTarget) + val liveFile = capture.start() + if (liveFile != null) { + liveCapture = capture + outFile = liveFile + return liveFile + } + } + return startClassic() + } + + private fun startClassic(): File? { return try { val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() } val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a" @@ -56,13 +77,19 @@ class VoiceRecorder(private val context: Context) { fun pollAmplitude(): Int { return try { - val amp = recorder?.maxAmplitude ?: 0 + val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0 _amplitude.value = amp amp } catch (_: Exception) { 0 } } - fun stop(): File? { + fun stop(canceled: Boolean = false): File? { + liveCapture?.let { capture -> + val file = capture.stop(canceled) + liveCapture = null + outFile = null + return file + } try { recorder?.apply { try { stop() } catch (_: Exception) {} @@ -73,6 +100,10 @@ class VoiceRecorder(private val context: Context) { val f = outFile recorder = null outFile = null + if (canceled) { + f?.delete() + return null + } return f } } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index bf9745d9..35d0f4ec 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -28,12 +28,14 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.util.UniversalApkManager @@ -137,6 +139,12 @@ fun HotspotScreen( is HotspotViewModel.HotspotState.Starting -> { LoadingScreen() } + is HotspotViewModel.HotspotState.ConfirmDisconnect -> { + ExistingGroupConfirmation( + onConfirm = viewModel::confirmDisconnectAndStart, + onCancel = viewModel::cancelDisconnect + ) + } is HotspotViewModel.HotspotState.Active -> { ActiveHotspotScreen(state = currentState) } @@ -152,6 +160,28 @@ fun HotspotScreen( } } +@Composable +private fun ExistingGroupConfirmation( + onConfirm: () -> Unit, + onCancel: () -> Unit +) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.hotspot_disconnect_title)) }, + text = { Text(stringResource(R.string.hotspot_disconnect_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.hotspot_disconnect_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + @OptIn(ExperimentalPermissionsApi::class) @Composable fun IntroScreen(onStartHotspot: () -> Unit) { diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt index 3a22b6b1..a56182bc 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -28,10 +28,6 @@ class HotspotManager(private val context: Context) { companion object { private const val TAG = "HotspotMgr" - // Retry configuration - private const val MAX_FRAMEWORK_ATTEMPTS = 5 - private const val RETRY_DELAY_MILLIS = 1000L - // Group info polling interval private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L @@ -39,10 +35,14 @@ class HotspotManager(private val context: Context) { private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L // SSID and password configuration - private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat private const val SSID_SUFFIX_LENGTH = 8 private const val PASSWORD_LENGTH = 16 + // Records the group we created so a later run can tell our own orphan apart + // from a group belonging to Cast, Android Auto or Quick Share. + private const val PREFS_NAME = "hotspot" + private const val KEY_OWNED_GROUP = "owned_group_name" + // Characters to use for random generation (excluding confusing ones) private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I } @@ -63,17 +63,56 @@ class HotspotManager(private val context: Context) { private var hasNotifiedStarted = false // Track if we've notified the callback private var isReceiverRegistered = false // Track receiver registration to prevent leaks + // Set once our own createGroup command is accepted, and re-checked against every + // group snapshot afterwards. stopHotspot() only calls removeGroup() when this is + // true: removal is device-scoped, so issuing it when the group on the framework + // is not ours could only tear down another app's session (Cast, Android Auto, + // Quick Share). + private var createdGroup = false + + // Framework-reported name of the group this session hosts, once known. Null while + // the group is still forming, when a null snapshot carries no information. + private var hostedGroupName: String? = null + + // Name of the foreign group the user explicitly agreed to disconnect, or null. + // Consent is per-group: a group with a different name asks again. + private var confirmedReplacementName: String? = null + + // stopHotspot() can be reached again while its group query/removal is still in + // flight. Later callers wait for that same teardown instead of releasing the + // Wi-Fi Aware lease early. + private var teardownInProgress = false + private val teardownCallbacks = mutableListOf<() -> Unit>() + // Saved credentials for reconnection private var savedSsid: String? = null private var savedPassword: String? = null + // Last Wi-Fi P2P state seen on the broadcast, or null before the first one arrives + private var lastP2pState: Int? = null + + private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + + /** Network name of the last group this app created, surviving process death. */ + private var ownedGroupName: String? + get() = prefs.getString(KEY_OWNED_GROUP, null) + set(value) = prefs.edit().putString(KEY_OWNED_GROUP, value).apply() + // Broadcast receiver for Wi-Fi P2P events private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) + lastP2pState = state Log.d(TAG, "Wi-Fi P2P state changed: $state") + + // Wi-Fi Direct going away is terminal for this session: without it + // the group cannot form, and any group already up is now dead. + if (state == WIFI_P2P_STATE_DISABLED && (isStarting || hasNotifiedStarted)) { + Log.w(TAG, "Wi-Fi P2P was disabled; aborting hotspot") + failStartup(HotspotStartupPolicy.P2P_DISABLED_MESSAGE) + } } WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { Log.d(TAG, "Wi-Fi P2P connection changed") @@ -85,8 +124,13 @@ class HotspotManager(private val context: Context) { /** * Start the Wi-Fi P2P hotspot. + * + * @param confirmedReplacementName name of the foreign Wi-Fi Direct group the + * user has confirmed may be disconnected, as previously reported through + * [HotspotCallback.onExistingGroupConflict]. When null (or when the group + * present no longer matches), a foreign group is reported instead of touched. */ - fun startHotspot(callback: HotspotCallback) { + fun startHotspot(callback: HotspotCallback, confirmedReplacementName: String? = null) { if (isStarting) { Log.w(TAG, "Hotspot already starting") return @@ -111,6 +155,7 @@ class HotspotManager(private val context: Context) { } this.callback = callback + this.confirmedReplacementName = confirmedReplacementName isStarting = true Log.d(TAG, "Starting Wi-Fi P2P hotspot") @@ -138,32 +183,59 @@ class HotspotManager(private val context: Context) { Log.d(TAG, "Using saved credentials: SSID=$savedSsid") } - // Start P2P framework with retries - startWifiP2pFramework(1) + // Start P2P framework (retries reuse this one channel) + startWifiP2pFramework() } /** * Stop the hotspot. + * + * @param onTeardownComplete invoked once the framework has acknowledged the + * removal of our group (or immediately when this session created none). Lets + * the caller hold the Wi-Fi Aware radio back until the P2P group is gone. */ - fun stopHotspot() { + fun stopHotspot(onTeardownComplete: (() -> Unit)? = null) { Log.d(TAG, "Stopping hotspot") + onTeardownComplete?.let(teardownCallbacks::add) + if (teardownInProgress) { + Log.d(TAG, "Teardown already in progress; chaining completion") + return + } + isStarting = false hasNotifiedStarted = false // Stop group info polling handler.removeCallbacksAndMessages(null) - // Remove group - channel?.let { ch -> - wifiP2pManager?.removeGroup(ch, object : ActionListener { - override fun onSuccess() { - Log.d(TAG, "Group removed successfully") - } - override fun onFailure(reason: Int) { - Log.w(TAG, "Failed to remove group: $reason") - } - }) + // Detach the channel first so any in-flight listener sees the hotspot as stopped, + // then remove the group and close the channel once the framework has replied. + val staleChannel = channel + channel = null + + val hadOwnGroup = createdGroup + val expectedGroupName = hostedGroupName ?: if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ) { + savedSsid + } else { + null + } + createdGroup = false + hostedGroupName = null + + var teardownAction: (() -> Unit)? = null + if (staleChannel != null && hadOwnGroup) { + teardownInProgress = true + teardownAction = { + removeOwnGroupIfStillPresent(staleChannel, expectedGroupName) + } + } else if (staleChannel != null) { + // This session created nothing, so there is nothing of ours to remove. + // removeGroup() here is exactly the bug this change fixes: device-scoped + // removal would disconnect whatever group another app has running. + closeChannel(staleChannel) } // Release locks @@ -182,8 +254,101 @@ class HotspotManager(private val context: Context) { } currentGroup = null - channel = null callback = null + + if (teardownAction != null) { + teardownAction.invoke() + } else { + finishTeardown() + } + } + + /** + * Re-check the device-scoped group immediately before removing it. The last poll + * is only a snapshot: our group may have disappeared and another app may have + * claimed Wi-Fi Direct before stop was requested. + */ + @SuppressLint("MissingPermission") + private fun removeOwnGroupIfStillPresent(ch: Channel, expectedGroupName: String?) { + val manager = wifiP2pManager ?: run { + closeChannel(ch) + finishTeardown() + return + } + + try { + manager.requestGroupInfo(ch) { group -> + val stillOurs = HotspotStartupPolicy.isExpectedHostedGroup( + existingGroupName = group?.networkName, + isGroupOwner = group?.isGroupOwner == true, + expectedGroupName = expectedGroupName + ) + if (!stillOurs) { + Log.i( + TAG, + "Current group '${group?.networkName}' is not ours " + + "('$expectedGroupName'); leaving it alone" + ) + closeChannel(ch) + finishTeardown() + return@requestGroupInfo + } + + try { + manager.removeGroup(ch, object : ActionListener { + override fun onSuccess() { + Log.d(TAG, "Group removed successfully") + clearOwnedGroupNameIfMatches(expectedGroupName) + closeChannel(ch) + finishTeardown() + } + + override fun onFailure(reason: Int) { + Log.w(TAG, "Failed to remove group: $reason") + closeChannel(ch) + finishTeardown() + } + }) + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while removing the group", e) + closeChannel(ch) + finishTeardown() + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while confirming group ownership", e) + closeChannel(ch) + finishTeardown() + } + } + + private fun clearOwnedGroupNameIfMatches(removedGroupName: String?) { + if (HotspotStartupPolicy.shouldClearOwnedGroupName(ownedGroupName, removedGroupName)) { + ownedGroupName = null + } + } + + private fun finishTeardown() { + teardownInProgress = false + val callbacks = teardownCallbacks.toList() + teardownCallbacks.clear() + callbacks.forEach { it.invoke() } + } + + /** + * Release the channel's binder registration with WifiP2pService. Without this the + * registration survives until the process dies, and every start/stop cycle adds + * another stale client to the framework's list. + */ + private fun closeChannel(channelToClose: Channel) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) return + + try { + channelToClose.close() + Log.d(TAG, "P2P channel closed") + } catch (e: Exception) { + Log.w(TAG, "Error closing P2P channel", e) + } } /** @@ -202,39 +367,144 @@ class HotspotManager(private val context: Context) { } /** - * Start Wi-Fi P2P framework with retry logic. + * Initialise the P2P framework once. Every retry reuses this channel — calling + * initialize() per attempt registers a fresh binder with WifiP2pService that is + * never reclaimed until the process dies. */ - private fun startWifiP2pFramework(attempt: Int) { - if (attempt > MAX_FRAMEWORK_ATTEMPTS) { - Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts") - failStartup("Failed to start hotspot. Please try again.") - return - } + private fun startWifiP2pFramework() { + Log.d(TAG, "Initialising P2P channel") - Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)") + val newChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null) - channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null) - - if (channel == null) { + if (newChannel == null) { + // The service is unobtainable; retrying will not change that. Log.e(TAG, "Failed to initialize P2P channel") - handler.postDelayed({ - startWifiP2pFramework(attempt + 1) - }, RETRY_DELAY_MILLIS) + failStartup(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE) return } - createGroup(attempt) + channel = newChannel + createGroupWhenP2pAvailable() + } + + /** + * Ask the framework for the current P2P state before the first attempt. + * + * When P2P is disabled the state machine answers every createGroup with BUSY — + * the same code a genuinely transient collision returns — so without this check + * a permanent failure is indistinguishable from a retryable one. + */ + @SuppressLint("MissingPermission") + private fun createGroupWhenP2pAvailable() { + val ch = channel ?: return + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + clearStaleGroupThenCreate(ch, attempt = 1) + return + } + + try { + wifiP2pManager?.requestP2pState(ch) { state -> + if (channel !== ch) return@requestP2pState + lastP2pState = state + clearStaleGroupThenCreate(ch, attempt = 1) + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while reading P2P state", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } + } + + /** + * A P2P group survives the process that created it, so a previous session killed + * while hosting leaves an orphan behind. The framework then rejects createGroup + * with BUSY for as long as that group exists, which no retry can clear. + */ + @SuppressLint("MissingPermission") + private fun clearStaleGroupThenCreate(ch: Channel, attempt: Int) { + try { + wifiP2pManager?.requestGroupInfo(ch) { existingGroup -> + if (channel !== ch) return@requestGroupInfo + + val action = HotspotStartupPolicy.startAction( + p2pState = lastP2pState, + existingGroupName = existingGroup?.networkName, + ownedGroupName = ownedGroupName, + confirmedGroupName = confirmedReplacementName + ) + + when (action) { + is HotspotStartupPolicy.StartAction.Fail -> { + Log.w(TAG, "Not attempting group creation: ${action.message}") + failStartup(action.message) + } + HotspotStartupPolicy.StartAction.ConfirmReplaceExisting -> { + val name = existingGroup?.networkName + if (name == null) { + // Unreachable while the policy requires a name, but there + // is nothing safe to bind consent to without one. + failStartup(HotspotStartupPolicy.P2P_BUSY_MESSAGE) + } else { + Log.i(TAG, "Existing group '$name' is not ours; asking the user") + reportExistingGroupConflict(name) + } + } + HotspotStartupPolicy.StartAction.Create -> + createGroup(attempt, oldGroupCleared = true) + HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> { + Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating") + removeStaleGroup(ch, attempt) + } + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } + } + + private fun removeStaleGroup(ch: Channel, attempt: Int) { + try { + wifiP2pManager?.removeGroup(ch, object : ActionListener { + override fun onSuccess() { + if (channel !== ch) return + Log.d(TAG, "Stale group removed") + createGroup(attempt, oldGroupCleared = true) + } + override fun onFailure(reason: Int) { + if (channel !== ch) return + // Creation may still succeed, and a BUSY reply here backs off as usual. + Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway") + createGroup(attempt, oldGroupCleared = false) + } + }) + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while removing the existing group", e) + failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.") + } } /** * Create Wi-Fi P2P group. + * + * @param oldGroupCleared false when a previous group may still exist (its removal + * just failed). The ownership marker keeps the OLD group's name in that case: + * overwriting it early would make a BUSY retry classify our own stale group as + * foreign and raise a spurious consent dialog. On success the group-info poll + * records the authoritative name anyway. */ @SuppressLint("MissingPermission") - private fun createGroup(attempt: Int) { + private fun createGroup(attempt: Int, oldGroupCleared: Boolean) { val ch = channel ?: return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Record before the call: if the process dies between creation and the + // first group info, the next run still knows this orphan is ours. + if (oldGroupCleared) { + ownedGroupName = savedSsid + } + // Android 10+: Custom SSID and password val config = WifiP2pConfig.Builder() .setNetworkName(savedSsid!!) @@ -256,11 +526,18 @@ class HotspotManager(private val context: Context) { private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener { override fun onSuccess() { if (channel !== requestChannel) { + // Ours by construction: this listener only observes our own createGroup. Log.w(TAG, "Removing group created after hotspot was stopped") - wifiP2pManager?.removeGroup(requestChannel, null) + try { + wifiP2pManager?.removeGroup(requestChannel, null) + } catch (e: SecurityException) { + // The orphan stays; the next start recognises it via ownedGroupName. + Log.e(TAG, "Could not remove the late group; permission was revoked", e) + } return } Log.d(TAG, "P2P group created successfully") + createdGroup = true isStarting = false // Don't call onHotspotStarted() yet - wait for group info startGroupInfoPolling() @@ -274,7 +551,7 @@ class HotspotManager(private val context: Context) { } /** - * Handle group creation failure with retry logic. + * Handle group creation failure, backing off only for genuinely transient causes. */ private fun handleGroupCreationFailure(reason: Int, attempt: Int) { val reasonStr = when (reason) { @@ -284,16 +561,22 @@ class HotspotManager(private val context: Context) { else -> "UNKNOWN($reason)" } - Log.w(TAG, "Failed to create group: $reasonStr") + Log.w( + TAG, + "Failed to create group: $reasonStr " + + "(attempt $attempt/${HotspotStartupPolicy.MAX_ATTEMPTS}, p2pState=$lastP2pState)" + ) - if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) { - // Framework is busy, retry - Log.d(TAG, "P2P framework busy, retrying...") - handler.postDelayed({ - startWifiP2pFramework(attempt + 1) - }, RETRY_DELAY_MILLIS) - } else { - failStartup("Failed to create hotspot: $reasonStr") + when (val decision = HotspotStartupPolicy.decide(reason, attempt, lastP2pState)) { + is HotspotStartupPolicy.Decision.Retry -> { + Log.d(TAG, "Retrying group creation in ${decision.delayMillis}ms") + handler.postDelayed({ + // Re-check for a stale group each round: BUSY is also how the + // framework reports "a group already exists". + channel?.let { clearStaleGroupThenCreate(it, attempt + 1) } + }, decision.delayMillis) + } + is HotspotStartupPolicy.Decision.Fail -> failStartup(decision.message) } } @@ -308,6 +591,17 @@ class HotspotManager(private val context: Context) { cb?.onError(message) } + /** + * A group belonging to another app is up. Stop cleanly — with [createdGroup] + * false the stop path leaves that group untouched — and let the UI ask whether + * starting the hotspot may disconnect it. + */ + private fun reportExistingGroupConflict(groupName: String) { + val cb = callback + stopHotspot() + cb?.onExistingGroupConflict(groupName) + } + /** * Start polling for group info to track connected clients. */ @@ -346,26 +640,52 @@ class HotspotManager(private val context: Context) { try { wifiP2pManager?.requestGroupInfo(ch) { group -> - if (group != null) { - currentGroup = group + // A reply arriving after the hotspot stopped must not revive any + // state the stop just cleared. + if (channel !== ch) return@requestGroupInfo - // Update saved credentials if using system-generated ones - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - savedSsid = group.networkName - savedPassword = group.passphrase - } + reconcileGroupOwnership(group) - // Notify callback on FIRST successful group info retrieval - if (!hasNotifiedStarted) { - hasNotifiedStarted = true - Log.d(TAG, "Group info received, notifying callback") - callback?.onHotspotStarted() - } else { - // Subsequent updates - callback?.onConnectionInfoUpdated(getConnectionInfo()) - } - } else { + if (group == null) { Log.w(TAG, "requestGroupInfo returned null group") + return@requestGroupInfo + } + + if (!isOurHostedGroup(group)) { + // Someone else's group is on the radio. Reading anything from it + // — its name, its credentials, its client count — would report + // another app's session as our hotspot, and recording its name + // would let the next start remove it without asking. + Log.w( + TAG, + "Observed group '${group.networkName}' is not the one we created" + ) + return@requestGroupInfo + } + + currentGroup = group + + // Update saved credentials if using system-generated ones + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + savedSsid = group.networkName + savedPassword = group.passphrase + } + + // Authoritative name straight from the framework, for the group we + // just confirmed is ours. + group.networkName?.let { + hostedGroupName = it + ownedGroupName = it + } + + // Notify callback on FIRST successful group info retrieval + if (!hasNotifiedStarted) { + hasNotifiedStarted = true + Log.d(TAG, "Group info received, notifying callback") + callback?.onHotspotStarted() + } else { + // Subsequent updates + callback?.onConnectionInfoUpdated(getConnectionInfo()) } } } catch (e: SecurityException) { @@ -374,6 +694,63 @@ class HotspotManager(private val context: Context) { } } + /** + * Is this snapshot the group this session created? + * + * `isGroupOwner` cannot answer that on its own: it reports that *this device* + * hosts the group, which is equally true of an autonomous group another app + * created here. Above Q we chose the network name, so it identifies our group + * exactly. Below Q the framework names it, and the first snapshot after our own + * createGroup succeeded is the only evidence available — after that the name is + * fixed, and a group answering to a different one is not ours. + */ + private fun isOurHostedGroup(group: WifiP2pGroup): Boolean { + if (!group.isGroupOwner) return false + val name = group.networkName ?: return false + + hostedGroupName?.let { return name == it } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + name == savedSsid + } else { + true + } + } + + /** + * Keep [createdGroup] honest about what is actually on the framework. + * + * Our group can disappear without us — Wi-Fi toggled, another app issuing its own + * device-scoped removeGroup(), a driver reset — and another app can then create + * one in its place. Believing the group present is still ours would make stop + * remove that replacement, the exact disruption consent exists to prevent. + * + * Reconciled only once the framework has named our group: before that a null + * snapshot means the group is still forming, not that it is gone. Losing the flag + * to a transient null is safe in a way that keeping it is not — the group we + * created is then left behind, and the next start recognises it by name and + * removes it silently. + */ + private fun reconcileGroupOwnership(group: WifiP2pGroup?) { + val expectedName = hostedGroupName ?: if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ) { + savedSsid + } else { + null + } ?: return + + // A null snapshot is normal before the configured group appears. A non-null + // group with a different name is positive evidence that ours was replaced. + if (hostedGroupName == null && group == null) return + + val stillOurs = group != null && isOurHostedGroup(group) + if (createdGroup && !stillOurs) { + Log.w(TAG, "Group '$expectedName' is no longer ours; leaving what is present alone") + } + createdGroup = stillOurs + } + /** * Acquire WakeLock and WifiLock to keep hotspot active. */ @@ -460,7 +837,7 @@ class HotspotManager(private val context: Context) { val suffix = (1..SSID_SUFFIX_LENGTH) .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] } .joinToString("") - return "$SSID_PREFIX$suffix" + return "${HotspotStartupPolicy.SSID_PREFIX}$suffix" } /** @@ -489,6 +866,14 @@ class HotspotManager(private val context: Context) { interface HotspotCallback { fun onHotspotStarted() fun onConnectionInfoUpdated(info: ConnectionInfo?) + + /** + * A Wi-Fi Direct group belonging to another app is active and the caller has + * not confirmed replacing it. Ask the user, then retry with this name as + * `confirmedReplacementName` if they accept. Nothing was disturbed. + */ + fun onExistingGroupConflict(groupName: String) + fun onError(message: String) } } 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 index 50b04855..73f9faa4 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt @@ -1,9 +1,12 @@ package com.bitchat.android.hotspot import android.app.Application +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,6 +20,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) companion object { private const val TAG = "HotspotViewModel" + + // Upper bound on waiting for the framework to acknowledge group removal + // before the Wi-Fi Aware lease is released anyway. + private const val TEARDOWN_FALLBACK_MILLIS = 10_000L } private val _state = MutableStateFlow(HotspotState.Intro) @@ -24,12 +31,25 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) private var hotspotManager: HotspotManager? = null private var webServer: ApkWebServer? = null + + /** APK waiting on the user's answer to the disconnect confirmation. */ + private var pendingApk: File? = null + + /** Group the pending confirmation is about; consent binds to this name only. */ + private var pendingGroupName: String? = null + + /** Once-releasable radio claim owned by the current hotspot session. */ + private var awareLease: WifiAwareController.HotspotLease? = null private val context = application.applicationContext /** * Start the hotspot with the provided APK file. */ fun startHotspot(apkFile: File) { + startHotspot(apkFile, confirmedGroupName = null) + } + + private fun startHotspot(apkFile: File, confirmedGroupName: String?) { if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) { Log.w(TAG, "Hotspot already starting or active") return @@ -40,6 +60,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { try { + // Wi-Fi Aware holds a NAN interface that blocks the P2P one; release it + // first or every createGroup comes back BUSY. Restored when we stop. + awareLease = WifiAwareController.acquireHotspotLease() + // Start hotspot val manager = HotspotManager(context) hotspotManager = manager @@ -52,8 +76,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) // Get connection info val info = manager.getConnectionInfo() if (info == null) { - manager.stopHotspot() - _state.value = HotspotState.Error("Failed to get hotspot connection info") + failWith("Failed to get hotspot connection info") return@launch } @@ -75,8 +98,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) ) } catch (e: Exception) { Log.e(TAG, "Failed to start web server", e) - manager.stopHotspot() - _state.value = HotspotState.Error("Failed to start web server: ${e.message}") + failWith("Failed to start web server: ${e.message}") } } } @@ -91,35 +113,110 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) } } - override fun onError(message: String) { + override fun onExistingGroupConflict(groupName: String) { viewModelScope.launch { - Log.e(TAG, "Hotspot error: $message") - _state.value = HotspotState.Error(message) + // Nothing was disturbed; the manager already stopped + // itself. Release our resources and ask the user. + teardown() + pendingApk = apkFile + pendingGroupName = groupName + _state.value = HotspotState.ConfirmDisconnect } } - }) + + override fun onError(message: String) { + viewModelScope.launch { failWith(message) } + } + }, confirmedGroupName) } catch (e: Exception) { Log.e(TAG, "Error starting hotspot", e) - hotspotManager?.stopHotspot() - _state.value = HotspotState.Error(e.message ?: "Unknown error") + failWith(e.message ?: "Unknown error") } } } + /** The user agreed that starting may disconnect the existing Wi-Fi Direct group. */ + fun confirmDisconnectAndStart() { + if (_state.value !is HotspotState.ConfirmDisconnect) return + val apk = pendingApk ?: return + val groupName = pendingGroupName + pendingApk = null + pendingGroupName = null + startHotspot(apk, confirmedGroupName = groupName) + } + + fun cancelDisconnect() { + // A tap landing late (e.g. through an exit animation) must not tear down + // the session a just-processed confirmation is starting. + if (_state.value !is HotspotState.ConfirmDisconnect) return + pendingApk = null + pendingGroupName = null + // The conflict path already tore down; this only covers a stray state. + teardown() + _state.value = HotspotState.Intro + } + /** * Stop the hotspot and web server. */ fun stopHotspot() { Log.d(TAG, "Stopping hotspot") + pendingApk = null + pendingGroupName = null + teardown() + _state.value = HotspotState.Intro + } + /** + * Every failure after the hotspot has been requested must land here. + * + * Skipping any part of this leaves something running that shouldn't be: the web + * server keeps serving the APK on whatever network the device joins next, and the + * Wi-Fi Aware hold blocks the mesh until the user happens to retry or close the + * screen. + */ + private fun failWith(message: String) { + Log.e(TAG, "Hotspot failed: $message") + pendingApk = null + pendingGroupName = null + teardown() + _state.value = HotspotState.Error(message) + } + + /** Releases every resource startHotspot may have acquired. Safe to call twice. */ + private fun teardown() { webServer?.stopServer() webServer = null - hotspotManager?.stopHotspot() + val manager = hotspotManager hotspotManager = null - _state.value = HotspotState.Intro + // Nothing of ours to hand back. An earlier teardown may already have passed + // its once-releasable lease to the manager's completion callback. + val lease = awareLease ?: return + awareLease = null + + if (manager == null) { + lease.close() + return + } + + // Close the lease only when the manager has finished removing our group. + // Restoring Wi-Fi Aware earlier recreates the NAN/P2P radio contention the + // lease exists to prevent. The lease is idempotent, so a duplicate or late + // completion cannot release a newer session's claim. + manager.stopHotspot { lease.close() } + + // A framework that never acknowledges the removal must not pin Wi-Fi Aware + // down for the life of the process. close() is idempotent and this lease + // belongs to this session alone, so whichever path runs second is a no-op. + // A plain handler rather than viewModelScope: onCleared() cancels the scope + // right when this teardown may be running. + Handler(Looper.getMainLooper()).postDelayed( + { lease.close() }, + TEARDOWN_FALLBACK_MILLIS + ) } /** @@ -142,6 +239,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) sealed class HotspotState { object Intro : HotspotState() object Starting : HotspotState() + + /** A foreign Wi-Fi Direct group is up; waiting for the user's go-ahead. */ + object ConfirmDisconnect : HotspotState() + data class Active( val ssid: String, val password: String, 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 ab00b137..1efa87a2 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -528,4 +528,11 @@ class SecureIdentityStateManager { } editor.apply() } + + /** Use for panic paths that must finish the disk mutation before identity reset continues. */ + fun clearSecureValuesSynchronously(vararg keys: String): Boolean { + val editor = prefs.edit() + keys.forEach(editor::remove) + return editor.commit() + } } 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 f2bb54c6..0e4f29af 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -68,8 +68,17 @@ class BluetoothConnectionManager( } override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) { + packetBroadcaster.onLinkDisconnected(device.address, linkID) delegate?.onDeviceDisconnected(device, linkID, peerID) } + + override fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) { + packetBroadcaster.onGattClientWriteComplete(deviceAddress, linkID, status) + } + + override fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) { + packetBroadcaster.onGattServerNotificationComplete(deviceAddress, linkID, status) + } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { delegate?.onRSSIUpdated(deviceAddress, rssi) @@ -485,4 +494,6 @@ interface BluetoothConnectionManagerDelegate { fun onDeviceConnected(device: BluetoothDevice) fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) fun onRSSIUpdated(deviceAddress: String, rssi: Int) + fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) = Unit + fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) = Unit } 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 498339ce..a0f7c207 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -95,9 +95,6 @@ class BluetoothGattClientManager( private var scanWatchdogJob: Job? = null private var scanDutyCycleJob: Job? = null - // RSSI monitoring state - private var rssiMonitoringJob: Job? = null - // State management private var isActive = false @@ -133,8 +130,6 @@ class BluetoothGattClientManager( connectionScope.launch { applyPowerProfile(powerManager.profile.value) - // Start RSSI monitoring - startRSSIMonitoring() } return true @@ -151,7 +146,6 @@ class BluetoothGattClientManager( if (!isActive) { // Idempotent stop stopScanning() - stopRSSIMonitoring() return } @@ -167,7 +161,6 @@ class BluetoothGattClientManager( } catch (_: Exception) { } stopScanning() - stopRSSIMonitoring() Log.i(TAG, "GATT client manager stopped") } } @@ -185,40 +178,6 @@ class BluetoothGattClientManager( } } - /** - * Start periodic RSSI monitoring for all client connections - */ - private fun startRSSIMonitoring() { - rssiMonitoringJob?.cancel() - rssiMonitoringJob = connectionScope.launch { - while (isActive) { - try { - // Request RSSI from all client connections - val connectedDevices = connectionTracker.getConnectedDevices() - connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn -> - try { - deviceConn.gatt?.readRemoteRssi() - } catch (e: Exception) { - Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") - } - } - delay(powerManager.profile.value.ble.rssiPollIntervalMs) - } catch (e: Exception) { - Log.w(TAG, "Error in RSSI monitoring: ${e.message}") - delay(powerManager.profile.value.ble.rssiPollIntervalMs) - } - } - } - } - - /** - * Stop RSSI monitoring - */ - private fun stopRSSIMonitoring() { - rssiMonitoringJob?.cancel() - rssiMonitoringJob = null - } - /** * Start scanning with rate limiting */ @@ -616,18 +575,17 @@ class BluetoothGattClientManager( Log.d(TAG, "Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes") } } - - override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { - val deviceAddress = gatt.device.address - if (status == BluetoothGatt.GATT_SUCCESS) { - // Update the connection tracker with new RSSI value - connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) { - it.copy(rssi = rssi) - } - } else { - Log.d(TAG, "Failed to read RSSI for $deviceAddress, status: $status") + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) { + delegate?.onGattClientWriteComplete(gatt.device.address, linkID, status) } } + } try { 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 7a15b963..038cd960 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -297,6 +297,14 @@ class BluetoothGattServerManager( gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) } } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + delegate?.onGattServerNotificationComplete( + device.address, + serverLinkIDs[device.address], + status + ) + } } // Proper cleanup sequencing to prevent race conditions 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 f828af9e..22f17867 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -20,6 +20,7 @@ import com.bitchat.android.util.toHexString import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import java.util.* import kotlin.math.sign import kotlin.random.Random @@ -111,12 +112,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private val messageHandler = MessageHandler(myPeerID, context.applicationContext) internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) + private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray) + private val voiceFrameQueue = Channel(capacity = 128) private lateinit var gossipSyncManager: GossipSyncManager // Service-level notification manager for background (no-UI) DMs private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( context.applicationContext, - androidx.core.app.NotificationManagerCompat.from(context.applicationContext), - com.bitchat.android.util.NotificationIntervalManager() + androidx.core.app.NotificationManagerCompat.from(context.applicationContext) ) // Service state management @@ -130,6 +132,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private var terminated = false init { + serviceScope.launch { + for (request in voiceFrameQueue) dispatchVoiceFrame(request) + } Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") VerificationService.configure(encryptionService) setupDelegates() @@ -480,21 +485,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Callbacks override fun onMessageReceived(message: BitchatMessage) { - // Always reflect into process-wide store so UI can hydrate after recreation - try { - when { - message.isPrivate -> { - val peer = message.senderPeerID ?: "" - if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) - } - message.channel != null -> { - com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) - } - else -> { - com.bitchat.android.services.AppStateStore.addPublicMessage(message) - } - } - } catch (_: Exception) { } + // Private-message admission is authoritative. In particular, do not forward a + // callback or notify after panic mode rejected the message while wiping state. + if ( + !com.bitchat.android.services.IncomingMessageAdmission + .admitToAppState(message) + ) return + // And forward to UI delegate if attached delegate?.didReceiveMessage(message) @@ -616,6 +613,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } catch (_: Exception) { } } + + override fun handleVoiceFrame(routed: RoutedPacket): Boolean = + messageHandler.handlePublicVoiceFrame(routed) override fun handleLeave(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleLeave(routed) } @@ -958,6 +958,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } + fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + if (payload.isEmpty()) return + voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf())) + } + + private fun dispatchVoiceFrame(request: VoiceFrameRequest) { + try { + val recipientPeerID = request.recipientPeerID + val packet = if (recipientPeerID == null) { + BitchatPacket( + version = 1u, + type = MessageType.VOICE_FRAME.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = request.payload, + ttl = MAX_TTL + ) + } else { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) return + val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode() + val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID) + BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = MAX_TTL + ) + } + broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + } catch (e: Exception) { + Log.w(TAG, "Live voice frame send failed: ${e.message}") + } + } + fun prepareFilePrivate( recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket, @@ -1592,6 +1630,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic securityManager.clearAllData() peerManager.clearAllPeers() peerManager.clearAllFingerprints() + try { gossipSyncManager.clear() } catch (_: Exception) { } } catch (e: Exception) { Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") } 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 1ec1baea..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,10 +1,13 @@ package com.bitchat.android.mesh +import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothStatusCodes +import android.os.Build import android.util.Log import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RoutedPacket @@ -19,8 +22,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.actor +import java.util.ArrayDeque /** * Handles packet broadcasting to connected devices using actor pattern for serialization @@ -48,7 +51,10 @@ class BluetoothPacketBroadcaster( companion object { private const val TAG = "BluetoothPacketBroadcaster" - private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS + private const val MAX_PENDING_SENDS_PER_LINK = 256 + private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576 + private const val SEND_RETRY_DELAY_MS = 15L + private const val MAX_CALLBACK_RETRIES = 3 } // Optional nickname resolver injected by higher layer (peerID -> nickname?) @@ -119,11 +125,38 @@ class BluetoothPacketBroadcaster( // Actor scope for the broadcaster private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG) + + private enum class SendDirection { CLIENT_WRITE, SERVER_NOTIFICATION } + + private data class SendKey( + val deviceAddress: String, + val linkID: String, + val direction: SendDirection + ) + + private data class PendingSend( + val data: ByteArray, + val device: BluetoothDevice, + val gatt: BluetoothGatt? = null, + val gattServer: BluetoothGattServer? = null, + val characteristic: BluetoothGattCharacteristic, + var callbackFailures: Int = 0 + ) + + private class LinkSendState { + val pending = ArrayDeque() + 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 ) { for (request in channel) { val accepted = try { @@ -443,21 +476,16 @@ class BluetoothPacketBroadcaster( gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? ): Boolean { - return try { - characteristic?.let { char -> - char.value = data - val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false - result - } ?: false - } catch (e: Exception) { - Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}") - connectionScope.launch { - delay(CLEANUP_DELAY) - connectionTracker.removeSubscribedDevice(device) - connectionTracker.addressPeerMap.remove(device.address) - } - false - } + val server = gattServer ?: return false + val char = characteristic ?: return false + val linkID = connectionTracker.getDeviceConnection(device.address) + ?.takeIf { !it.isClient } + ?.linkID + ?: return false + return enqueueSend( + SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION), + PendingSend(data.copyOf(), device, gattServer = server, characteristic = char) + ) } /** @@ -467,20 +495,160 @@ class BluetoothPacketBroadcaster( deviceConn: BluetoothConnectionTracker.DeviceConnection, data: ByteArray ): Boolean { - return try { - deviceConn.characteristic?.let { char -> - char.value = data - val result = deviceConn.gatt?.writeCharacteristic(char) ?: false - result - } ?: false - } catch (e: Exception) { - Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}") - connectionScope.launch { - delay(CLEANUP_DELAY) - connectionTracker.cleanupDeviceConnection(deviceConn.device.address) + val gatt = deviceConn.gatt ?: return false + val char = deviceConn.characteristic ?: return false + return enqueueSend( + SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE), + PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char) + ) + } + + /** + * Android permits only one outstanding GATT operation per link. Queueing here mirrors the + * readiness-driven iOS transport and prevents later voice frames from overwriting an operation + * that the controller has not completed yet. + */ + private fun enqueueSend(key: SendKey, request: PendingSend): Boolean { + val startNow = synchronized(sendLock) { + val state = sendStates.getOrPut(key, ::LinkSendState) + if ( + state.pending.size >= MAX_PENDING_SENDS_PER_LINK || + state.pendingBytes + request.data.size > MAX_PENDING_BYTES_PER_LINK + ) { + Log.w(TAG, "BLE send queue full for ${key.direction}; rejecting ${request.data.size} bytes") + return false } + state.pending.addLast(request) + state.pendingBytes += request.data.size + if (!state.inFlight && !state.retryScheduled) { + state.inFlight = true + true + } else { + false + } + } + if (startNow) startHead(key) + return true + } + + @Suppress("DEPRECATION") + @SuppressLint("MissingPermission", "ObsoleteSdkInt") + private fun startHead(key: SendKey) { + val request = synchronized(sendLock) { sendStates[key]?.pending?.peekFirst() } ?: return + val accepted = try { + when (key.direction) { + SendDirection.CLIENT_WRITE -> { + val gatt = request.gatt + if (gatt == null) { + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + request.characteristic, + request.data, + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ) == BluetoothStatusCodes.SUCCESS + } else { + request.characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + request.characteristic.value = request.data + gatt.writeCharacteristic(request.characteristic) + } + } + SendDirection.SERVER_NOTIFICATION -> { + val server = request.gattServer + if (server == null) { + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + server.notifyCharacteristicChanged( + request.device, + request.characteristic, + false, + request.data + ) == BluetoothStatusCodes.SUCCESS + } else { + request.characteristic.value = request.data + server.notifyCharacteristicChanged(request.device, request.characteristic, false) + } + } + } + } catch (error: Exception) { + Log.w(TAG, "BLE ${key.direction} failed to start: ${error.message}") false } + if (!accepted) rejectStart(key) + } + + private fun rejectStart(key: SendKey): Boolean { + val schedule = synchronized(sendLock) { + val state = sendStates[key] ?: return false + state.inFlight = false + if (state.retryScheduled || state.pending.isEmpty()) false else { + state.retryScheduled = true + true + } + } + if (schedule) { + connectionScope.launch { + delay(SEND_RETRY_DELAY_MS) + val retry = synchronized(sendLock) { + val state = sendStates[key] ?: return@synchronized false + state.retryScheduled = false + if (!state.inFlight && state.pending.isNotEmpty()) { + state.inFlight = true + true + } else false + } + if (retry) startHead(key) + } + } + return false + } + + fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) { + completeSend(SendKey(deviceAddress, linkID, SendDirection.CLIENT_WRITE), status) + } + + fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) { + if (linkID == null) return + completeSend(SendKey(deviceAddress, linkID, SendDirection.SERVER_NOTIFICATION), status) + } + + private fun completeSend(key: SendKey, status: Int) { + var retry = false + val startNext = synchronized(sendLock) { + val state = sendStates[key] ?: return + val head = state.pending.peekFirst() ?: run { + sendStates.remove(key) + return + } + state.inFlight = false + if (status != BluetoothGatt.GATT_SUCCESS && head.callbackFailures < MAX_CALLBACK_RETRIES) { + head.callbackFailures++ + retry = true + false + } else { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "BLE ${key.direction} failed with status $status after retries") + } + state.pending.removeFirst() + state.pendingBytes -= head.data.size + if (state.pending.isEmpty()) { + sendStates.remove(key) + false + } else { + state.inFlight = true + true + } + } + } + if (retry) rejectStart(key) else if (startNext) startHead(key) + } + + fun onLinkDisconnected(deviceAddress: String, linkID: String?) { + synchronized(sendLock) { + sendStates.keys.removeAll { key -> + key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID) + } + } } /** @@ -499,6 +667,7 @@ class BluetoothPacketBroadcaster( * Shutdown the broadcaster actor gracefully */ fun shutdown() { + synchronized(sendLock) { sendStates.clear() } // Close the actor gracefully broadcasterActor.close() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index ae1f535a..28cd3d02 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -20,6 +20,7 @@ import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -41,7 +42,11 @@ class MeshCore( private val hooks: Hooks = Hooks() ) { data class Hooks( - val onMessageReceived: ((BitchatMessage) -> Unit)? = null, + /** + * Reflects a decoded message into transport-owned state before delegate dispatch. + * Return false to suppress all downstream effects for a rejected message. + */ + val onMessageReceived: ((BitchatMessage) -> Boolean)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val onReadReceiptSent: ((String) -> Unit)? = null, @@ -107,6 +112,8 @@ class MeshCore( private val storeForwardManager = StoreForwardManager() private val messageHandler = MessageHandler(myPeerID, context.applicationContext) private val packetProcessor = PacketProcessor(myPeerID) + private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray) + private val voiceFrameQueue = Channel(capacity = 128) private val directPeers = ConcurrentHashMap.newKeySet() val gossipSyncManager: GossipSyncManager = @@ -118,6 +125,9 @@ class MeshCore( private var isActive = false init { + scope.launch { + for (request in voiceFrameQueue) dispatchVoiceFrame(request) + } messageHandler.packetProcessor = packetProcessor peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) } setupDelegates() @@ -392,7 +402,7 @@ class MeshCore( } override fun onMessageReceived(message: BitchatMessage) { - hooks.onMessageReceived?.invoke(message) + if (hooks.onMessageReceived?.invoke(message) == false) return delegate?.didReceiveMessage(message) } @@ -477,6 +487,9 @@ class MeshCore( } catch (_: Exception) { } } + override fun handleVoiceFrame(routed: RoutedPacket): Boolean = + messageHandler.handlePublicVoiceFrame(routed) + override fun handleLeave(routed: RoutedPacket) { scope.launch { messageHandler.handleLeave(routed) } } @@ -607,6 +620,44 @@ class MeshCore( } } + fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + if (payload.isEmpty()) return + voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf())) + } + + private fun dispatchVoiceFrame(request: VoiceFrameRequest) { + try { + val recipientPeerID = request.recipientPeerID + val packet = if (recipientPeerID == null) { + BitchatPacket( + version = 1u, + type = MessageType.VOICE_FRAME.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = request.payload, + ttl = maxTtl + ) + } else { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) return + val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode() + val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID) + BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = maxTtl + ) + } + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } catch (e: Exception) { + Log.w("MeshCore", "Live voice frame send failed: ${e.message}") + } + } + fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, @@ -983,6 +1034,7 @@ class MeshCore( securityManager.clearAllData() peerManager.clearAllPeers() peerManager.clearAllFingerprints() + try { gossipSyncManager.clear() } catch (_: Exception) { } } fun clearAllEncryptionData() { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 063fe4f8..7b357149 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -21,6 +21,7 @@ interface MeshService { fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) fun sendFileBroadcast(file: BitchatFilePacket) fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) + fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, 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 05a5bf00..9434fc6e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -10,6 +10,8 @@ import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.sync.PacketIdUtil import com.bitchat.android.util.toHexString +import com.bitchat.android.features.voice.LiveVoiceManager +import com.bitchat.android.features.voice.LiveVoiceScope import kotlinx.coroutines.* import java.util.* @@ -140,7 +142,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro senderPeerID = peerID ) - delegate?.onMessageReceived(message) + if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) { + delegate?.onMessageReceived(message) + } // Send delivery ACK with generated message ID sendDeliveryAck(uniqueMsgId, peerID) @@ -149,6 +153,16 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } } + com.bitchat.android.model.NoisePayloadType.VOICE_FRAME -> { + return LiveVoiceManager.getInstance(appContext).handleFrame( + peerID = peerID, + nickname = delegate?.getPeerNickname(peerID) ?: peerID, + scope = LiveVoiceScope.DIRECT_MESSAGE, + payload = noisePayload.data, + timestampMs = packet.timestamp.toLong() + ) + } + com.bitchat.android.model.NoisePayloadType.PEER_STATE -> { val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data) if (authenticatedState == null) { @@ -411,6 +425,27 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } // Message relay is now handled by centralized PacketRelayManager } + + /** Validate and ingest an ephemeral public push-to-talk frame. */ + fun handlePublicVoiceFrame(routed: RoutedPacket): Boolean { + val packet = routed.packet + val peerID = routed.peerID ?: return false + if (peerID == myPeerID) return true + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(delegate?.getBroadcastRecipient())) return false + if (packet.timestamp > Long.MAX_VALUE.toULong()) return false + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + if (ageMs !in -30_000L..30_000L) return false + val peerInfo = delegate?.getPeerInfo(peerID) + if (peerInfo == null || !peerInfo.isVerifiedNickname) return false + return LiveVoiceManager.getInstance(appContext).handleFrame( + peerID = peerID, + nickname = delegate?.getPeerNickname(peerID) ?: peerID, + scope = LiveVoiceScope.PUBLIC_MESH, + payload = packet.payload, + timestampMs = packet.timestamp.toLong() + ) + } /** * Handle broadcast message with verification enforcement @@ -441,7 +476,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()) ) - delegate?.onMessageReceived(message) + if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) { + delegate?.onMessageReceived(message) + } return } else if (isFileTransfer) { Log.w(TAG, "FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)}") @@ -501,7 +538,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro recipientNickname = delegate?.getMyNickname() ) Log.d(TAG, "📄 Saved incoming file to $savedPath") - delegate?.onMessageReceived(message) + if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) { + delegate?.onMessageReceived(message) + } return } else if (isFileTransfer) { Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}") 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 b00c6a31..37a295e8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -136,6 +136,7 @@ class PacketProcessor(private val myPeerID: String) { MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed) MessageType.MESSAGE -> handleMessage(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler + MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false MessageType.LEAVE -> handleLeave(routed) MessageType.FRAGMENT -> handleFragment(routed) MessageType.REQUEST_SYNC -> handleRequestSync(routed) @@ -296,6 +297,7 @@ interface PacketProcessorDelegate { fun handleNoiseEncrypted(routed: RoutedPacket): Boolean suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) + fun handleVoiceFrame(routed: RoutedPacket): Boolean = false fun handleLeave(routed: RoutedPacket) fun handleFragment(packet: BitchatPacket): BitchatPacket? fun handleRequestSync(routed: RoutedPacket) 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 a82746c4..6b5c6d16 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -62,8 +62,16 @@ class PacketRelayManager(private val myPeerID: String) { } // Decrement TTL by 1 - val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte()) + val networkSize = delegate?.getNetworkSize() ?: 1 + val decrementedTtl = (packet.ttl - 1u).toUByte() + val voiceTtl = if ( + MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME && networkSize > 6 + ) minOf(decrementedTtl, 5u.toUByte()) else decrementedTtl + val relayPacket = packet.copy(ttl = voiceTtl) Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}") + if (MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME) { + delay(Random.nextLong(8L, 26L)) + } // Source-based routing: if route is set and includes us, try targeted next-hop forwarding val route = relayPacket.route 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 078e2f31..525487e5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -335,7 +335,7 @@ class PeerManager { // Remove stale peer IDs stalePeerIDs.forEach { stalePeerID -> - removePeer(stalePeerID, notifyDelegate = false) + removePeer(stalePeerID, notifyPeerList = false) } // Check if this is a new peer announcement @@ -371,7 +371,7 @@ class PeerManager { /** * Remove peer */ - fun removePeer(peerID: String, notifyDelegate: Boolean = true) { + fun removePeer(peerID: String, notifyPeerList: Boolean = true) { val removed = peers.remove(peerID) peerRSSI.remove(peerID) announcedPeers.remove(peerID) @@ -380,10 +380,13 @@ class PeerManager { // Also remove fingerprint mappings fingerprintManager.removePeer(peerID) - if (notifyDelegate && removed != null) { - // Notify specific removal event then list update + if (removed != null) { + // Lifecycle cleanup must always run. Callers may suppress only the + // intermediate peer-list update while atomically replacing a peer. try { delegate?.onPeerRemoved(peerID) } catch (_: Exception) {} - notifyPeerListUpdate() + if (notifyPeerList) { + notifyPeerListUpdate() + } } } 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 9714c63a..c5e98566 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -46,7 +46,6 @@ class PowerManager private constructor(context: Context) : LifecycleEventObserve val scanOnMs: Long, val scanOffMs: Long, val continuousScan: Boolean, - val rssiPollIntervalMs: Long, val maxConnections: Int = 8 ) @@ -318,24 +317,22 @@ internal object PowerProfileResolver { PowerManager.BleSchedule( scanOnMs = 1_000L, scanOffMs = 29_000L, - continuousScan = false, - rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + continuousScan = false ) isBackground -> PowerManager.BleSchedule( scanOnMs = 1_000L, scanOffMs = 59_000L, - continuousScan = false, - rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + continuousScan = false ) mode == PowerManager.PowerMode.PERFORMANCE -> - PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L) + PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true) mode == PowerManager.PowerMode.BALANCED -> - PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L) + PowerManager.BleSchedule(8_000L, 2_000L, false) mode == PowerManager.PowerMode.POWER_SAVER -> - PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L) + PowerManager.BleSchedule(2_000L, 28_000L, false) else -> - PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L) + PowerManager.BleSchedule(1_000L, 29_000L, false) } val announcementInterval = when { @@ -407,10 +404,4 @@ internal object PowerProfileResolver { discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L, discoveryStaleMs = staleMinutes * 60_000L ) - - private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) { - PowerManager.BatteryBand.NORMAL -> 60_000L - PowerManager.BatteryBand.LOW -> 120_000L - PowerManager.BatteryBand.CRITICAL -> 300_000L - } } 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 e3c0388a..3f684679 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private MessageType.ANNOUNCE, MessageType.MESSAGE, MessageType.FILE_TRANSFER, + MessageType.VOICE_FRAME, MessageType.LEAVE )) { return true diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 5a0cc697..3efe758d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -167,6 +167,23 @@ class UnifiedMeshService( } } + override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + if (recipientPeerID == null) { + when { + isBleEnabled() -> bluetooth.sendVoiceFrame(null, payload) + else -> wifiService()?.sendVoiceFrame(null, payload) + } + return + } + when { + isBleReady(recipientPeerID) -> bluetooth.sendVoiceFrame(recipientPeerID, payload) + isWifiReady(recipientPeerID) -> wifiService()?.sendVoiceFrame(recipientPeerID, payload) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendVoiceFrame(recipientPeerID, payload) + else -> wifiService()?.sendVoiceFrame(recipientPeerID, payload) + } + } + override fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, diff --git a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt index 5e47742f..710d72b1 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt @@ -14,6 +14,13 @@ import java.nio.ByteOrder * Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs. * For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each. * + * Unknown TLV types are SKIPPED, not rejected: the tag list above is a floor, + * not a ceiling, and a decoder that bails on the first tag it does not know + * makes the format unextendable — the whole file is lost over a field that, + * by construction, the sender considered optional. The iOS client has always + * done this (`case nil: continue` in its own decoder), so rejecting here also + * meant the two implementations disagreed about what a valid packet is. + * * Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this * TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes. * Transport-level fragmentation then splits the final packet for BLE MTU. @@ -26,7 +33,15 @@ data class BitchatFilePacket( ) { private enum class TLVType(val v: UByte) { FILE_NAME(0x01u), FILE_SIZE(0x02u), MIME_TYPE(0x03u), CONTENT(0x04u); - companion object { fun from(value: UByte) = values().find { it.v == value } } + companion object { + fun from(value: UByte): TLVType? = when (value) { + FILE_NAME.v -> FILE_NAME + FILE_SIZE.v -> FILE_SIZE + MIME_TYPE.v -> MIME_TYPE + CONTENT.v -> CONTENT + else -> null + } + } } fun encode(): ByteArray? { @@ -90,8 +105,15 @@ data class BitchatFilePacket( var size: Long? = null var mime: String? = null var contentBytes: ByteArray? = null - while (off + 3 <= data.size) { // minimum TLV header size (type + 2 bytes length) - val t = TLVType.from(data[off].toUByte()) ?: return null + var skippedUnknownTLVs = 0 + while (off < data.size) { + // Every TLV needs at least a type and a 2-byte length. + // Reject a truncated trailing header instead of silently + // accepting it, matching the iOS decoder. + if (data.size - off < 3) return null + // A null `t` is an unknown tag: read its length like any + // other 2-byte TLV and skip its value, matching iOS. + val t = TLVType.from(data[off].toUByte()) off += 1 // CONTENT uses 4-byte length; others use 2-byte length val len: Int @@ -105,6 +127,18 @@ data class BitchatFilePacket( off += 2 } if (len < 0 || off + len > data.size) return null + if (t == null) { + // Unknown tag: advance past the value without copying it + // and without logging. A peer can pad a packet with + // zero-length unknown TLVs — 3 bytes each — so anything + // per-TLV here is attacker-scaled: at the payload + // ceiling that is millions of copies and formatted log + // lines monopolising the mesh handler. Counted and + // reported once after the loop instead. + off += len + skippedUnknownTLVs += 1 + continue + } val value = data.copyOfRange(off, off + len) off += len when (t) { @@ -124,6 +158,9 @@ data class BitchatFilePacket( } } } + if (skippedUnknownTLVs > 0) { + android.util.Log.d("BitchatFilePacket", "⏭️ Skipped $skippedUnknownTLVs unknown TLV(s)") + } val n = name ?: return null val c = contentBytes ?: return null val s = size ?: c.size.toLong() @@ -138,4 +175,3 @@ data class BitchatFilePacket( } } } - 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 cba647a7..aacdafd4 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -21,6 +21,7 @@ enum class NoisePayloadType(val value: UByte) { PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding READ_RECEIPT(0x02u), // Message was read DELIVERED(0x03u), // Message was delivered + VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame VERIFY_CHALLENGE(0x10u), // Verification challenge VERIFY_RESPONSE(0x11u), // Verification response FILE_TRANSFER(0x20u), diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index d53f3d20..e64fc390 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -29,9 +29,23 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { companion object { val NONE = PeerCapabilities(0) + val PREKEYS = PeerCapabilities(1L shl 0) + val WIFI_BULK = PeerCapabilities(1L shl 1) + val GATEWAY = PeerCapabilities(1L shl 2) + val GROUPS = PeerCapabilities(1L shl 3) + val BOARD = PeerCapabilities(1L shl 4) + val VOUCH = PeerCapabilities(1L shl 5) + val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6) + val BRIDGE = PeerCapabilities(1L shl 7) + /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9) + + /** Reserved by iOS; decode it but do not advertise or act on it. */ + val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10) + /** Capabilities implemented by this Android build. */ val LOCAL_SUPPORTED = PRIVATE_MEDIA 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 aafc0d10..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,37 +10,63 @@ import java.util.concurrent.atomic.AtomicReference * Centralized OkHttp provider to ensure all network traffic honors Tor settings. */ object OkHttpProvider { - private val httpClientRef = AtomicReference(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 torProvider = ArtiTorManager.getInstance() val socks: InetSocketAddress? = torProvider.currentSocksAddress() @@ -50,6 +76,6 @@ object OkHttpProvider { val proxy = Proxy(Proxy.Type.SOCKS, socks) builder.proxy(proxy) } - return builder + return builder to if (socks == null) Route.DIRECT else Route.TOR } } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 88d41466..a43194cb 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -3,8 +3,11 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.Date /** @@ -18,7 +21,8 @@ class GeohashMessageHandler( private val repo: GeohashRepository, private val scope: CoroutineScope, private val dataManager: com.bitchat.android.ui.DataManager, - private val addChannelMessage: (String, BitchatMessage) -> Unit + private val addChannelMessage: (String, BitchatMessage) -> Unit, + private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default ) { companion object { private const val TAG = "GeohashMessageHandler" } @@ -44,6 +48,13 @@ class GeohashMessageHandler( if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch + val hasValidSignature = withContext(signatureVerificationDispatcher) { + event.isValidSignature() + } + if (!hasValidSignature) { + Log.w(TAG, "Rejecting geohash event ${event.id.take(8)}... with invalid signature") + return@launch + } if (dedupe(event.id)) return@launch // PoW validation (if enabled) - apply to chat messages primarily diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index a6927d71..0edb1fca 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -416,6 +416,11 @@ class LocationNotesManager private constructor() { Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}") return } + + if (!event.isValidSignature()) { + Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature") + return + } // Check for geohash tag val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" } 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 c1488037..0257c756 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -282,6 +282,11 @@ class NostrClient private constructor(private val context: Context) { handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit ) { try { + if (!event.isValidSignature()) { + Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... with invalid signature") + return + } + // Check Proof of Work validation for incoming geohash events val powSettings = PoWPreferenceManager.getCurrentSettings() if (powSettings.enabled && powSettings.difficulty > 0) { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 2fdc3180..4125d5ca 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -132,7 +132,14 @@ class NostrDirectMessageHandler( val favoriteControl = FavoriteControlMessage.parse(pm.content) if (favoriteControl != null) { - handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey) + val admitted = handleFavoriteControl( + favoriteControl, + conversationID, + senderNickname, + timestamp, + senderPubkey + ) + if (!admitted) return if (!seenStore.hasDelivered(pm.messageID)) { val nostrTransport = NostrTransport.getInstance(application) nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) @@ -157,13 +164,14 @@ class NostrDirectMessageHandler( val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID) - withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage( + val admitted = withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessageDurably( message = message, suppressUnread = suppressUnread, origin = PrivateMessageOrigin.NOSTR ) } + if (!admitted) return if (!seenStore.hasDelivered(pm.messageID)) { val nostrTransport = NostrTransport.getInstance(application) @@ -215,19 +223,26 @@ class NostrDirectMessageHandler( senderNostrPubkey = senderPubkey ) Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") - withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage( + val admitted = withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessageDurably( message = message, suppressUnread = false, origin = PrivateMessageOrigin.NOSTR ) } + if (!admitted) { + com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths( + application, + listOf(savedPath) + ) + } } else { Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID") } } NoisePayloadType.VERIFY_CHALLENGE, NoisePayloadType.VERIFY_RESPONSE, + NoisePayloadType.VOICE_FRAME, NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. } } @@ -238,15 +253,15 @@ class NostrDirectMessageHandler( senderNickname: String, timestamp: Date, senderPubkey: String - ) { - try { + ): Boolean { + return try { val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey) val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) } ?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey) if (noiseKey == null) { Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...") - return + return false } FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite) @@ -278,7 +293,7 @@ class NostrDirectMessageHandler( ) withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage( + privateChatManager.handleIncomingPrivateMessageDurably( message = systemMessage, suppressUnread = true, origin = PrivateMessageOrigin.NOSTR @@ -286,6 +301,7 @@ class NostrDirectMessageHandler( } } catch (e: Exception) { Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}") + false } } 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 3386a014..772e2a60 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -679,6 +679,24 @@ class NostrRelayManager private constructor() { Log.e(TAG, "Failed to clear subscriptions: ${e.message}") } } + + /** + * Clear all subscription tracking, deduplication cache, message queue, and connections for panic mode. + */ + fun clearAllOnPanic() { + try { + val wasConnected = desiredConnected.get() + clearAllSubscriptions() + clearDeduplicationCache() + disconnect() + if (wasConnected) { + desiredConnected.set(true) + } + Log.w(TAG, "🚨 Cleared NostrRelayManager subscriptions, cache, and connections for panic mode") + } catch (e: Exception) { + Log.e(TAG, "Failed to clear NostrRelayManager on panic: ${e.message}") + } + } /** * Get detailed status for all relays 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 674deb7d..641b3a5a 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -85,9 +85,7 @@ class OnboardingCoordinator( val missingRequired = permissionManager.getMissingPermissions() // Optional permissions (ask, but do not block if denied) - val optionalToRequest = permissionManager - .getOptionalPermissions() - .filter { !permissionManager.isPermissionGranted(it) } + val optionalToRequest = permissionManager.getUnrequestedOptionalPermissions() val missingPermissions = (missingRequired + optionalToRequest).distinct() @@ -101,6 +99,7 @@ class OnboardingCoordinator( } Log.d(TAG, "Requesting ${missingPermissions.size} permissions") + permissionManager.markOptionalPermissionsRequested(optionalToRequest) permissionLauncher?.launch(missingPermissions.toTypedArray()) } @@ -115,7 +114,10 @@ class OnboardingCoordinator( val allGranted = permissions.values.all { it } val criticalPermissions = getCriticalPermissions() - val criticalGranted = criticalPermissions.all { permissions[it] == true } + // The launcher result only contains permissions requested in this round. Returning + // users may be asked for POST_NOTIFICATIONS alone, so re-check required permissions + // against package state instead of treating absent result-map entries as denials. + val criticalGranted = criticalPermissions.all(permissionManager::isPermissionGranted) when { criticalGranted -> { 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 23235068..8d61d10e 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -19,6 +19,8 @@ class PermissionManager(private val context: Context) { private const val TAG = "PermissionManager" private const val PREFS_NAME = "bitchat_permissions" private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete" + private const val KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX = + "optional_permission_requested_" } private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -149,6 +151,32 @@ class PermissionManager(private val context: Context) { return optional } + /** + * Optional permissions are prompted once. A denial must not trap returning users in + * onboarding, while users upgrading to a notification-permission Android version + * should still receive one contextual request. + */ + fun getUnrequestedOptionalPermissions(): List { + 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 */ 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 47ee8d75..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 @@ -17,7 +18,8 @@ enum class MessageType(val value: UByte) { NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message FRAGMENT(0x20u), // Fragmentation for large packets REQUEST_SYNC(0x21u), // GCS-based sync request - FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.) + FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.) + VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync companion object { fun fromValue(value: UByte): MessageType? { @@ -33,6 +35,27 @@ object SpecialRecipients { val BROADCAST = ByteArray(8) { 0xFF.toByte() } // All 0xFF = broadcast } +/** + * Payload as it arrived on the wire, set by [BinaryProtocol.decode]. + * + * Signatures cover a re-encoding of the packet, and verification re-encodes too. + * DEFLATE output is not canonical and clients use different encoders + * (java.util.zip.Deflater here, Apple's compression_encode_buffer on iOS), so + * re-compressing can change the preimage and reject a valid packet. Reusing these + * bytes also stops a relay, which re-encodes on TTL decrement, from substituting + * its own encoding. + * + * [forPayload] ties the bytes to the payload they decode to: replace the payload + * and the encoder compresses instead. + * + * Not a data class: generated equals/hashCode over ByteArray compares references. + */ +class WirePayload( + val bytes: ByteArray, + val compressed: Boolean, + val forPayload: ByteArray +) + /** * Binary packet format - 100% backward compatible with iOS version * @@ -60,7 +83,10 @@ data class BitchatPacket( val payload: ByteArray, var signature: ByteArray? = null, // Changed from val to var for packet signing var ttl: UByte, - var route: List? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient + 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( @@ -99,7 +125,8 @@ data class BitchatPacket( payload = payload, signature = null, // Remove signature for signing route = route, - ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility + ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS, // Use fixed TTL=0 for signing to ensure relay compatibility + wirePayload = wirePayload // preimage must use the originator's bytes ) return BinaryProtocol.encode(unsignedPacket) } @@ -213,7 +240,17 @@ object BinaryProtocol { var originalPayloadSize: Int? = null var isCompressed = false - if (CompressionUtil.shouldCompress(payload)) { + // Re-encode of a decoded packet: reuse the originator's bytes (see WirePayload). + val wire = packet.wirePayload + if (wire != null && wire.forPayload.contentEquals(packet.payload)) { + if (wire.compressed) { + payload = wire.bytes + originalPayloadSize = packet.payload.size + isCompressed = true + } + // Uncompressed on the wire: keep it that way. shouldCompress agrees + // across clients, but the "only if smaller" check need not. + } else if (CompressionUtil.shouldCompress(payload)) { CompressionUtil.compress(payload)?.let { compressedPayload -> originalPayloadSize = payload.size payload = compressedPayload @@ -449,6 +486,8 @@ object BinaryProtocol { } else null // Payload + // Kept so the packet can be re-encoded byte-identically (see WirePayload). + var receivedCompressed: ByteArray? = null val payload = if (isCompressed) { val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2 if (payloadLength.toInt() < lengthFieldBytes) return null @@ -487,6 +526,8 @@ object BinaryProtocol { val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) { val compressedPayload = ByteArray(compressedSize) buffer.get(compressedPayload) + // Captured here so the allocation stays inside the reservation. + receivedCompressed = compressedPayload decompress(compressedPayload, originalSize) } ?: return null if (expandedPayload.size != originalSize) { @@ -519,7 +560,12 @@ object BinaryProtocol { payload = payload, signature = signature, ttl = ttl, - route = route + route = route, + wirePayload = WirePayload( + bytes = receivedCompressed ?: payload, + compressed = receivedCompressed != null, + forPayload = payload + ) ) } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index 30a641d0..cdef409f 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -64,6 +64,12 @@ object AppShutdownCoordinator { val torStop = async { try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } } + val conversationFlush = async { + try { + com.bitchat.android.services.AppStateStore + .awaitConversationPersistence() + } catch (_: Exception) { } + } // Clear AppState in-memory store try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } @@ -75,6 +81,7 @@ object AppShutdownCoordinator { // Wait up to 5 seconds for shutdown tasks withTimeoutOrNull(5000) { try { torStop.await() } catch (_: Exception) { } + try { conversationFlush.await() } catch (_: Exception) { } delay(100) } 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 index 2f70bf84..42feca39 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -13,6 +13,8 @@ import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import com.bitchat.android.MainActivity import com.bitchat.android.R import com.bitchat.android.mesh.BluetoothMeshService @@ -33,7 +35,6 @@ class MeshForegroundService : Service() { const val ACTION_STOP = "com.bitchat.android.service.STOP" const val ACTION_QUIT = "com.bitchat.android.service.QUIT" const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION" - const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" fun start(context: Context) { val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START } @@ -59,22 +60,6 @@ class MeshForegroundService : Service() { } } - /** - * Helper to be invoked right after POST_NOTIFICATIONS is granted to try - * promoting/starting the foreground service immediately without polling. - */ - fun onNotificationPermissionGranted(context: Context) { - // If background is enabled and permission now granted, start/promo service - if (!shouldStartAsForeground(context)) return - - val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } - } - fun stop(context: Context) { val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP } context.startService(intent) @@ -82,8 +67,7 @@ class MeshForegroundService : Service() { private fun shouldStartAsForeground(context: Context): Boolean { return MeshServicePreferences.isBackgroundEnabled(true) && - hasBluetoothPermissionsStatic(context) && - hasNotificationPermissionStatic(context) + hasBluetoothPermissionsStatic(context) } private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean { @@ -98,14 +82,10 @@ class MeshForegroundService : Service() { } } - private fun hasNotificationPermissionStatic(ctx: Context): Boolean { - return if (Build.VERSION.SDK_INT >= 33) { - androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED - } else true - } } private lateinit var notificationManager: NotificationManagerCompat + private lateinit var peerAvailabilityNotifier: PeerAvailabilityNotifier private var updateJob: Job? = null private val meshService: BluetoothMeshService? get() = MeshServiceHolder.meshService @@ -121,6 +101,16 @@ class MeshForegroundService : Service() { override fun onCreate() { super.onCreate() notificationManager = NotificationManagerCompat.from(this) + peerAvailabilityNotifier = PeerAvailabilityNotifier( + context = applicationContext, + scope = scope, + isAppCurrentlyInBackground = { + !ProcessLifecycleOwner.get() + .lifecycle + .currentState + .isAtLeast(Lifecycle.State.STARTED) + } + ) createChannel() // Ensure mesh service exists in holder (create if needed) @@ -139,7 +129,14 @@ class MeshForegroundService : Service() { com.bitchat.android.services.AppStateStore.peers .map { peers -> peers.distinct().size } .distinctUntilChanged() - .collect { + .collect { peerCount -> + peerAvailabilityNotifier.onPeerCountChanged( + peerCount = peerCount, + isAppInBackground = !ProcessLifecycleOwner.get() + .lifecycle + .currentState + .isAtLeast(Lifecycle.State.STARTED) + ) if (isInForeground) updateNotification(force = false) } } @@ -156,11 +153,13 @@ class MeshForegroundService : Service() { when (intent?.action) { ACTION_STOP -> { // Stop FGS and mesh cleanly + updateJob?.cancel() + updateJob = null try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { } try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { } try { MeshServiceHolder.clear() } catch (_: Exception) { } try { stopForeground(true) } catch (_: Exception) { } - notificationManager.cancel(NOTIFICATION_ID) + clearMeshNotifications() isInForeground = false stopSelf() return START_NOT_STICKY @@ -170,7 +169,7 @@ class MeshForegroundService : Service() { updateJob?.cancel() updateJob = null try { stopForeground(true) } catch (_: Exception) { } - notificationManager.cancel(NOTIFICATION_ID) + clearMeshNotifications() isInForeground = false // Fully stop all background activity, stop Tor (without changing setting), then kill the app AppShutdownCoordinator.requestFullShutdownAndKill( @@ -203,7 +202,9 @@ class MeshForegroundService : Service() { // Ensure mesh is running (only after permissions are granted) ensureMeshStarted() - // Promote exactly once when eligible, otherwise stay background (or stop) + // Promote exactly once when eligible, otherwise stay background (or stop). + // POST_NOTIFICATIONS is intentionally not an eligibility requirement: Android 13+ + // still allows foreground services and exposes them in the system task manager. if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { val count = getUnifiedActivePeerCount() val notification = buildNotification(count) @@ -234,30 +235,35 @@ class MeshForegroundService : Service() { private fun updateNotification(force: Boolean) { if (isShuttingDown) { - notificationManager.cancel(NOTIFICATION_ID) + clearMeshNotifications() return } val count = getUnifiedActivePeerCount() if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { if (lastNotifiedPeerCount != count) { - notificationManager.notify(NOTIFICATION_ID, buildNotification(count)) + startForegroundCompat(buildNotification(count)) lastNotifiedPeerCount = count } } else if (force) { // If disabled and forced, make sure to remove any prior foreground state try { stopForeground(false) } catch (_: Exception) { } - notificationManager.cancel(NOTIFICATION_ID) + clearMeshNotifications() isInForeground = false lastNotifiedPeerCount = null } } + private fun clearMeshNotifications() { + notificationManager.cancel(NOTIFICATION_ID) + peerAvailabilityNotifier.clear() + } + private fun hasAllRequiredPermissions(): Boolean { // For starting FGS with connectedDevice|dataSync, we need: // - Foreground service permissions (declared in manifest) // - One of the device-related permissions (we request BL perms at runtime) - // - On Android 13+, POST_NOTIFICATIONS to actually show notification - return hasBluetoothPermissions() && hasNotificationPermission() + // POST_NOTIFICATIONS controls notification-drawer visibility, not FGS eligibility. + return hasBluetoothPermissions() } private fun getUnifiedActivePeerCount(): Int { @@ -281,12 +287,6 @@ class MeshForegroundService : Service() { } } - private fun hasNotificationPermission(): Boolean { - return if (Build.VERSION.SDK_INT >= 33) { - androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED - } else true - } - private fun buildNotification(activePeers: Int): Notification { val openIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index 1ff4ec29..aea8fce6 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -136,6 +136,7 @@ object MeshServiceHolder { @Synchronized fun clear() { android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder") + try { sharedGossipSyncManager?.clear() } catch (_: Exception) { } try { sharedGossipSyncManager?.stop() } catch (_: Exception) { } sharedGossipSyncManager = null activeGossipOwners.clear() 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/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index e484d21a..c4f52675 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -1,5 +1,6 @@ package com.bitchat.android.services +import android.content.Context import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.flow.MutableStateFlow @@ -13,8 +14,10 @@ import kotlinx.coroutines.flow.asStateFlow object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() + 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()) @@ -30,6 +33,20 @@ object AppStateStore { // 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() @@ -55,6 +72,69 @@ object AppStateStore { _selectedPrivateChatPeer.value = peerID } + fun initializeConversationPersistence(context: Context) { + val repository = ConversationRepository.getInstance(context.applicationContext) + conversationRepository = repository + repository.initialize(::restorePrivateConversations) + } + + /** + * Restores database state again for a newly created UI, even if Android reused this process + * after a controlled shutdown cleared the process-wide state. + */ + fun reloadConversationPersistence(context: Context) { + val repository = ConversationRepository.getInstance(context.applicationContext) + conversationRepository = repository + repository.reload(::restorePrivateConversations) + } + + internal fun setConversationRepositoryForTest(repository: ConversationRepository?) { + conversationRepository = repository + } + + suspend fun awaitConversationPersistence() { + conversationRepository?.awaitPendingWrites() + } + + suspend fun loadPrivateConversationHistory(conversationID: String): Boolean { + val repository = conversationRepository ?: return false + val snapshot = repository.loadConversationAndWait( + ContactDirectory.canonicalConversationId(conversationID) + ) ?: return false + restorePrivateConversations(snapshot) + return true + } + + /** + * Drops an opened conversation's full payloads from memory while retaining its summary row. + * The complete bounded history remains encrypted in SQLite and is loaded again on demand. + */ + fun releasePrivateConversationHistory(conversationID: String) { + synchronized(this) { + val canonicalID = ContactDirectory.canonicalConversationId(conversationID) + val matching = _privateMessages.value.entries.filter { (id, _) -> + ContactDirectory.canonicalConversationId(id) + .equals(canonicalID, ignoreCase = true) + } + val latest = matching + .flatMap { it.value } + .distinctBy { it.id } + .maxWithOrNull( + compareBy { + 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() @@ -116,19 +196,218 @@ object AppStateStore { } } - fun addPrivateMessage(peerID: String, msg: BitchatMessage) { + /** Replace a live media row by ID, or append it if the row was not admitted yet. */ + fun upsertPublicMessage(msg: BitchatMessage) { synchronized(this) { - if (seenMessageIds.contains(msg.id)) return - seenMessageIds.add(msg.id) - PrivateMessageArrivalOrder.record(msg.id) - val conversationID = ContactDirectory.canonicalConversationId(peerID) - val map = _privateMessages.value.toMutableMap() - val list = (map[conversationID] ?: emptyList()) + msg - map[conversationID] = list - _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + val index = _publicMessages.value.indexOfFirst { it.id == msg.id } + if (index >= 0) { + _publicMessages.value = _publicMessages.value.toMutableList().also { it[index] = msg } + } else { + seenMessageIds.add(msg.id) + seenPublicMessageKeys.add(publicMessageKey(msg)) + _publicMessages.value = _publicMessages.value + msg + } } } + fun removePublicMessage(messageID: String) { + synchronized(this) { + val existing = _publicMessages.value.firstOrNull { it.id == messageID } ?: return + _publicMessages.value = _publicMessages.value.filterNot { it.id == messageID } + seenMessageIds.remove(messageID) + seenPublicMessageKeys.remove(publicMessageKey(existing)) + } + } + + fun addPrivateMessage( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean = false + ): Boolean = synchronized(this) { + addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true) + } + + /** Replace-or-append used by a live voice row as its partial file becomes final media. */ + fun upsertPrivateMessage(peerID: String, msg: BitchatMessage, forceRead: Boolean = false) { + synchronized(this) { + if (privateConversationWritesSuspended) return + val canonicalID = ContactDirectory.canonicalConversationId(peerID) + val map = _privateMessages.value.toMutableMap() + val matchingKey = map.keys.firstOrNull { + ContactDirectory.canonicalConversationId(it).equals(canonicalID, ignoreCase = true) + } ?: canonicalID + val messages = map[matchingKey].orEmpty().toMutableList() + val index = messages.indexOfFirst { it.id == msg.id } + if (index >= 0) { + messages[index] = msg + } else { + messages += msg + seenMessageIds.add(msg.id) + } + map[matchingKey] = messages + _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + if (forceRead) { + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id + } + conversationRepository?.upsertMessage( + conversationID = canonicalID, + aliases = privateConversationAliases(peerID, canonicalID), + displayName = ContactDirectory.resolve(canonicalID).displayName, + message = msg, + isRead = forceRead + ) + } + } + + /** + * Persists an incoming private message before it is admitted to UI, unread, haptic, or + * notification state. Transport callbacks invoke this from their background worker. + */ + suspend fun addPrivateMessageDurably( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean = false + ): Boolean { + val persistence = synchronized(this) { + if (privateConversationWritesSuspended) return false + if (seenMessageIds.contains(msg.id) || !reservedPrivateMessageIds.add(msg.id)) { + return false + } + privateMessagePersistence(peerID, msg, forceRead) + } + val repository = persistence.repository + if (repository == null) { + synchronized(this) { reservedPrivateMessageIds.remove(msg.id) } + return false + } + val persisted = repository.upsertMessageAndWait( + conversationID = persistence.conversationID, + aliases = persistence.aliases, + displayName = persistence.displayName, + message = msg, + isRead = persistence.isRead + ) + return synchronized(this) { + reservedPrivateMessageIds.remove(msg.id) + if ( + !persisted || + privateConversationWritesSuspended || + persistence.generation != privateConversationGeneration || + seenMessageIds.contains(msg.id) + ) { + return@synchronized false + } + addPrivateMessageLocked( + peerID = peerID, + msg = msg, + forceRead = forceRead, + persistAsynchronously = false + ) + } + } + + private fun addPrivateMessageLocked( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean, + persistAsynchronously: Boolean + ): Boolean { + if (privateConversationWritesSuspended) return false + if (seenMessageIds.contains(msg.id)) return false + seenMessageIds.add(msg.id) + PrivateMessageArrivalOrder.record(msg.id) + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val map = _privateMessages.value.toMutableMap() + val list = (map[conversationID] ?: emptyList()) + msg + map[conversationID] = list + _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + + val isRead = forceRead || + msg.sender == "system" || + msg.sender == _nickname.value || + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(conversationID, ignoreCase = true) == true + if (isRead) { + _readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id + } else { + val counts = _unreadPrivateMessageCounts.value.toMutableMap() + counts[conversationID] = (counts[conversationID] ?: 0) + 1 + _unreadPrivateMessageCounts.value = counts + } + val aliases = privateConversationAliases(peerID, conversationID) + val displayName = ContactDirectory.resolve(conversationID).displayName + ?: msg.sender.takeUnless { + it.isBlank() || it == "system" || it == _nickname.value + } + displayName + ?.takeUnless { + it.isBlank() || it.equals("Unknown", ignoreCase = true) + } + ?.let { updateConversationDisplayNameLocked(conversationID, it) } + if (persistAsynchronously) { + conversationRepository?.upsertMessage( + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + message = msg, + isRead = isRead + ) + } + prunePrivateMessagesLocked(conversationID) + return true + } + + private fun privateMessagePersistence( + peerID: String, + msg: BitchatMessage, + forceRead: Boolean + ): PendingPrivateMessagePersistence { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val existingMessages = _privateMessages.value[conversationID].orEmpty() + val isRead = forceRead || + msg.sender == "system" || + msg.sender == _nickname.value || + _selectedPrivateChatPeer.value + ?.let(ContactDirectory::canonicalConversationId) + ?.equals(conversationID, ignoreCase = true) == true + val aliases = privateConversationAliases(peerID, conversationID) + val displayName = ContactDirectory.resolve(conversationID).displayName + ?: (existingMessages + msg) + .lastOrNull { candidate -> + candidate.sender.isNotBlank() && + candidate.sender != "system" && + candidate.sender != _nickname.value + } + ?.sender + return PendingPrivateMessagePersistence( + repository = conversationRepository, + conversationID = conversationID, + aliases = aliases, + displayName = displayName, + isRead = isRead, + generation = privateConversationGeneration + ) + } + + /** + * Derive aliases from the routed conversation, never from the message author. + * + * For outgoing messages senderPeerID is our own ID, which is shared by every private chat. + * Persisting it as an alias would merge otherwise unrelated conversation histories. + */ + private fun privateConversationAliases( + peerID: String, + conversationID: String + ): Set = runCatching { + ContactDirectory.aliasesForConversation(peerID) + + ContactDirectory.aliasesForConversation(conversationID) + }.getOrDefault(setOf(peerID, conversationID)) + + fun hasSeenMessage(messageID: String): Boolean = synchronized(this) { + messageID in seenMessageIds + } + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { null -> 0 is DeliveryStatus.Sending -> 1 @@ -141,6 +420,7 @@ object AppStateStore { fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) { synchronized(this) { + if (privateConversationWritesSuspended) return val map = _privateMessages.value.toMutableMap() var changed = false map.keys.toList().forEach { peer -> @@ -149,7 +429,14 @@ object AppStateStore { if (idx >= 0) { val current = list[idx].deliveryStatus // Do not downgrade (e.g., Read -> Delivered) - if (statusPriority(status) >= statusPriority(current)) { + val mayReplace = when { + status is DeliveryStatus.Failed -> + current !is DeliveryStatus.Delivered && + current !is DeliveryStatus.Read + current is DeliveryStatus.Failed -> true + else -> statusPriority(status) >= statusPriority(current) + } + if (mayReplace) { list[idx] = list[idx].copy(deliveryStatus = status) map[peer] = list changed = true @@ -159,13 +446,26 @@ object AppStateStore { if (changed) { _privateMessages.value = map } + // Full histories are unloaded after a chat closes, so the message may only exist in + // SQLite. Always offer the update to the repository; it safely ignores unknown IDs + // and enforces the same monotonic status rules as the in-memory path. + conversationRepository?.updateDeliveryStatus(messageID, status) } } fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List) { if (keysToMerge.isEmpty()) return synchronized(this) { + if (privateConversationWritesSuspended) return val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID) + val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID) + .flatMap { key -> + runCatching { + ContactDirectory.aliasesForConversation(key).toList() + }.getOrDefault(listOf(key)) + } + .toSet() + conversationRepository?.mergeAliases(targetConversationID, persistenceAliases) val map = _privateMessages.value.toMutableMap() val targetList = (map[targetConversationID] ?: emptyList()).toMutableList() val targetIds = targetList.map { it.id }.toMutableSet() @@ -199,20 +499,330 @@ object AppStateStore { } else { map[targetConversationID] = targetList } - _privateMessages.value = ContactDirectory.canonicalizePrivateChats(map) + _privateMessages.value = map } + canonicalizePrivateConversationStateLocked() } } fun canonicalizePrivateChats() { synchronized(this) { - val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value) - if (canonical != _privateMessages.value) { - _privateMessages.value = canonical + canonicalizePrivateConversationStateLocked() + } + } + + /** + * Applies current peer announcements to retained conversations and persists the latest name + * independently of message history. A nickname change must not require another message to + * survive process death. + */ + fun updatePrivateConversationDisplayNames(peerNicknames: Map) { + 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 @@ -228,14 +838,20 @@ object AppStateStore { fun clear() { synchronized(this) { seenMessageIds.clear() + reservedPrivateMessageIds.clear() + privateConversationGeneration += 1 seenPublicMessageKeys.clear() PrivateMessageArrivalOrder.clear() + privateWritesSinceGlobalPrune = 0 peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() _directPeers.value = emptySet() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() + _readPrivateMessageIDs.value = emptySet() + _unreadPrivateMessageCounts.value = emptyMap() + _privateConversationDisplayNames.value = emptyMap() _channelMessages.value = emptyMap() _nickname.value = "" _selectedPrivateChatPeer.value = null @@ -252,4 +868,253 @@ object AppStateStore { msg.content ).joinToString("\u001F") } + + internal fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) { + synchronized(this) { + if (privateConversationWritesSuspended) return + val liveChats = _privateMessages.value + val liveMessageIDs = liveChats.values.flatten().map { it.id } + PrivateMessageArrivalOrder.restore( + snapshot.arrivalOrder, + liveMessageIDs, + snapshot.receivedAtByMessageID, + snapshot.arrivalSequenceByMessageID + ) + + val merged = linkedMapOf>() + 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 index 28cb695c..7c733958 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -81,9 +81,19 @@ object ContactDirectory { conversationID = conversationID, meshPeerID = liveMeshPeerID, noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey }, - nostrPubkey = favorite?.peerNostrPublicKey, - displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + nostrPubkey = favorite?.peerNostrPublicKey + ?: liveMeshPeerID?.let { + runCatching { + FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(it) + }.getOrNull() + }, + // A connected peer's current announcement is authoritative. Favorite and fingerprint + // records are offline fallbacks and can legitimately contain an older nickname. + displayName = liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + ?: favorite?.peerNickname?.takeIf { + it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) + } ?: contactFingerprint?.let { cachedFingerprintNickname(it) }, isMutualFavorite = favorite?.isMutual == true ) 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 a09fa31e..dc6ad4d2 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -99,6 +99,12 @@ class MessageRouter private constructor( startOutboxScheduler() } + fun clearAll() { + outbox.clear() + retryState.clear() + Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state") + } + // Listener for favorites changes to flush outbox when npub mapping appears/changes private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener { diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt index a5c92453..f42a8dff 100644 --- a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt +++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt @@ -11,16 +11,56 @@ import com.bitchat.android.model.BitchatMessage */ internal object PrivateMessageArrivalOrder { private val sequenceByMessageID = mutableMapOf() + private val receivedAtByMessageID = mutableMapOf() private var nextSequence = 0L - fun record(messageID: String) { + fun record(messageID: String, receivedAt: Long = System.currentTimeMillis()) { synchronized(this) { if (messageID !in sequenceByMessageID) { sequenceByMessageID[messageID] = nextSequence++ + receivedAtByMessageID[messageID] = receivedAt } } } + fun restore( + persistedOrder: List, + 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 }) { @@ -33,6 +73,7 @@ internal object PrivateMessageArrivalOrder { 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 3b531846..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,5 +1,6 @@ package com.bitchat.android.services +import android.annotation.SuppressLint import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager @@ -20,6 +21,8 @@ class SeenMessageStore private constructor(private val context: Context) { private const val STORAGE_KEY = "seen_message_store_v1" private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS + // The constructor always receives applicationContext, so process lifetime is intentional. + @SuppressLint("StaticFieldLeak") @Volatile private var INSTANCE: SeenMessageStore? = null fun getInstance(appContext: Context): SeenMessageStore { return INSTANCE ?: synchronized(this) { @@ -54,6 +57,7 @@ class SeenMessageStore private constructor(private val context: Context) { locallyRead.add(id) trim(locallyRead) } + AppStateStore.markPrivateMessageRead(id) persist() } @@ -65,6 +69,14 @@ class SeenMessageStore private constructor(private val context: Context) { persist() } + @Synchronized fun remove(ids: Set) { + if (ids.isEmpty()) return + delivered.removeAll(ids) + locallyRead.removeAll(ids) + readReceiptsSent.removeAll(ids) + persist() + } + @Synchronized fun clear() { delivered.clear() locallyRead.clear() 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 index c2849b01..785c249d 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -67,6 +67,11 @@ class MeshGraphService private constructor() { nicknames.remove(peerID) announcements.remove(peerID) lastUpdate.remove(peerID) + announcements.keys.toList().forEach { originPeerID -> + announcements.computeIfPresent(originPeerID) { _, neighbors -> + neighbors - peerID + } + } publishSnapshot() } } diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 5c786365..42a10ceb 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -79,6 +79,15 @@ class GossipSyncManager( cleanupJob?.cancel(); cleanupJob = null } + @Synchronized + fun clear() { + synchronized(messages) { + messages.clear() + } + latestAnnouncementByPeer.clear() + Log.d(TAG, "Cleared all gossip sync messages and announcements") + } + fun scheduleInitialSync(delayMs: Long = 5_000L) { scope.launch(Dispatchers.IO) { delay(delayMs) 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 4e42e141..54ab0318 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -35,11 +36,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.outlined.Info @@ -52,6 +58,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -62,6 +70,7 @@ import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.util.downloadPhaseLabel import com.bitchat.android.hotspot.HotspotActivity import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode @@ -121,6 +130,69 @@ private fun ThemeChip( } } +@Composable +private fun LanguageSettingsRow( + selectedLanguageName: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.about_app_language), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + text = selectedLanguageName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.UnfoldMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun LanguageMenuItem( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = onClick, + trailingIcon = { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) +} + /** * Unified settings toggle row with icon, title, subtitle, and switch * Apple-like design with proper spacing @@ -260,6 +332,13 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current var selectedTab by remember { mutableStateOf(AboutTab.Info) } + val supportedLanguages = remember(context) { + LanguagePreferenceManager.supportedLanguages(context) + } + var selectedLanguageTag by remember { + mutableStateOf(LanguagePreferenceManager.currentLanguageTag()) + } + var showLanguagePicker by remember { mutableStateOf(false) } if (isPresented) { BitchatBottomSheet( @@ -307,6 +386,7 @@ fun AboutSheet( Column { AboutSectionLabel(text = stringResource(R.string.about_section_theme)) val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState() Surface( modifier = Modifier .fillMaxWidth() @@ -314,30 +394,110 @@ fun AboutSheet( color = colorScheme.surface, shape = AboutCardShape ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - ThemeChip( - label = stringResource(R.string.about_system), - selected = themePref.isSystem, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.about_system), + selected = themePref.isSystem, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_light), + selected = themePref.isLight, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_dark), + selected = themePref.isDark, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + modifier = Modifier.weight(1f) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.chat_ui_bubbles), + selected = chatUiMode.isBubbles, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.chat_ui_matrix), + selected = chatUiMode.isMatrix, + onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) }, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } + + item(key = "language") { + val selectedLanguageName = supportedLanguages + .firstOrNull { it.languageTag == selectedLanguageTag } + ?.endonym + ?: stringResource(R.string.about_system_default) + + Column { + AboutSectionLabel(text = stringResource(R.string.about_language)) + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = AboutCardShape, + ) { + LanguageSettingsRow( + selectedLanguageName = selectedLanguageName, + onClick = { showLanguagePicker = true }, ) - ThemeChip( - label = stringResource(R.string.about_light), - selected = themePref.isLight, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, - modifier = Modifier.weight(1f) - ) - ThemeChip( - label = stringResource(R.string.about_dark), - selected = themePref.isDark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, - modifier = Modifier.weight(1f) + } + DropdownMenu( + expanded = showLanguagePicker, + onDismissRequest = { showLanguagePicker = false }, + modifier = Modifier.width(maxWidth), + ) { + LanguageMenuItem( + label = stringResource(R.string.about_system_default), + selected = selectedLanguageTag.isEmpty(), + onClick = { + showLanguagePicker = false + if (selectedLanguageTag.isNotEmpty()) { + selectedLanguageTag = "" + LanguagePreferenceManager.setLanguage("") + } + }, ) + HorizontalDivider() + supportedLanguages.forEach { language -> + LanguageMenuItem( + label = language.endonym, + selected = selectedLanguageTag == language.languageTag, + onClick = { + showLanguagePicker = false + if (language.languageTag != selectedLanguageTag) { + selectedLanguageTag = language.languageTag + LanguagePreferenceManager.setLanguage(language.languageTag) + } + }, + ) + } } } } @@ -349,6 +509,9 @@ fun AboutSheet( val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } + var liveVoiceEnabled by remember { + mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)) + } val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } val torProvider = remember { ArtiTorManager.getInstance() } val torStatus by torProvider.statusFlow.collectAsState() @@ -387,6 +550,23 @@ fun AboutSheet( color = colorScheme.outlineVariant ) + SettingsToggleRow( + icon = Icons.Filled.Mic, + title = "Live push-to-talk", + subtitle = "Play voice bursts live on the mesh; voice notes are always sent on release", + checked = liveVoiceEnabled, + onCheckedChange = { enabled -> + liveVoiceEnabled = enabled + com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(context, enabled) + } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + // Proof of Work Toggle SettingsToggleRow( icon = Icons.Filled.Speed, @@ -440,7 +620,16 @@ fun AboutSheet( val apkViewModel: ApkDownloadViewModel = viewModel() val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() val apkStatus = apkUiState.apkStatus + val releaseStatus = apkUiState.releaseStatus val downloadProgress = apkUiState.downloadProgress + val shareableApk = when (apkStatus) { + is ApkPreparationStatus.Ready -> apkStatus + is ApkPreparationStatus.Downloading -> + apkStatus.shareableFallback + else -> null + } + val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known) + ?.takeIf { it.isNewerThanSharedApk } // Handle one-shot effects (navigation, toasts, share intents) LaunchedEffect(Unit) { @@ -475,14 +664,22 @@ fun AboutSheet( Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) { + // Enabled by the same mapping that decides what the tap + // does, so the row can never look tappable and do + // nothing. + .clickable( + enabled = prepareRowTapAction( + apkStatus, + releaseStatus + ) != null + ) { apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) } .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = if (apkStatus is ApkPreparationStatus.Ready) { + imageVector = if (shareableApk != null) { Icons.Default.Share } else { Icons.Default.CloudDownload @@ -498,140 +695,210 @@ fun AboutSheet( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = if (apkStatus is ApkPreparationStatus.Ready) { - stringResource(R.string.prepare_apk_ready_title) - } else { - stringResource(R.string.prepare_apk_title) - }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (shareableApk != null) { + stringResource(R.string.prepare_apk_ready_title) + } else { + stringResource(R.string.prepare_apk_title) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + if (availableUpdate != null) { + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + stringResource( + R.string.prepare_apk_update_available, + availableUpdate.version + ) + ) + } + }, + state = rememberTooltipState() + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource( + R.string.prepare_apk_update_warning + ), + tint = colorScheme.tertiary, + modifier = Modifier + .padding(start = 6.dp) + .size(18.dp) + .clickable { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + } + ) + } + } + } Text( text = when (val status = apkStatus) { is ApkPreparationStatus.Loading -> stringResource(R.string.checking) - is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded) + is ApkPreparationStatus.NotDownloaded -> + stringResource( + R.string.prepare_apk_status_not_downloaded + ) is ApkPreparationStatus.Ready -> { val source = when { - status.source == UniversalApkManager.ApkSource.GITHUB -> - stringResource(R.string.prepare_apk_source_github) + status.source == UniversalApkManager.ApkSource.DOWNLOADED -> + stringResource(R.string.prepare_apk_source_downloaded) status.variant == ShareableApkVariant.ARM64 -> stringResource(R.string.prepare_apk_source_installed_arm64) else -> stringResource(R.string.prepare_apk_source_installed) } - stringResource(R.string.prepare_apk_status_ready) + - " • ${status.version} • ${status.sizeMB} MB\n$source" + stringResource( + R.string.prepare_apk_ready_detail, + status.version, + status.sizeMB, + source + ) } - is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})" - is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress) - is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded" - is ApkPreparationStatus.Error -> status.message + is ApkPreparationStatus.Downloading -> + // Only the transfer has a percentage worth + // showing; the other phases are named + // instead of pretending to be at 0%. + if (status.phase.hasMeasurableProgress) { + stringResource(R.string.prepare_apk_status_downloading, downloadProgress) + } else { + stringResource(downloadPhaseLabel(status.phase)) + } + is ApkPreparationStatus.Resumable -> + stringResource( + R.string.prepare_apk_status_resumable, + context.resolveApkFailureMessage( + status.failure + ), + status.progressPercent + ) + is ApkPreparationStatus.Error -> + context.resolveApkFailureMessage( + status.failure + ) }, style = MaterialTheme.typography.bodySmall, color = when (apkStatus) { is ApkPreparationStatus.Error -> colorScheme.error is ApkPreparationStatus.Resumable -> colorScheme.primary - is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary else -> colorScheme.onSurface.copy(alpha = 0.6f) }, lineHeight = 16.sp ) + + // Progress lives in the column, not the trailing slot, + // which leaves that slot free for a single control. + ApkDownloadProgressBar( + status = apkStatus, + progressPercent = downloadProgress + ) } - // Action buttons + // One control, one width, in every state. The progress + // readout moved into the column above, so nothing else + // competes for this slot. when (apkStatus) { - is ApkPreparationStatus.Downloading -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp + is ApkPreparationStatus.Downloading -> + ApkPrepareRowIconButton( + icon = Icons.Default.Close, + description = stringResource( + R.string.prepare_apk_stop + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.CancelDownload + ) + } ) - } is ApkPreparationStatus.Ready -> { - if (apkStatus.variant == ShareableApkVariant.ARM64) { - TextButton( + if ( + apkStatus.source == + UniversalApkManager.ApkSource.INSTALLED + ) { + ApkPrepareRowIconButton( + icon = Icons.Default.CloudDownload, + description = stringResource( + R.string.prepare_apk_get_universal + ), onClick = { apkViewModel.onEvent( ApkUiEvent.DownloadUniversalClicked ) - } - ) { - Icon( - imageVector = Icons.Default.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - stringResource( - R.string.prepare_apk_get_universal - ) - ) - } - } else if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) { - androidx.compose.material3.IconButton( - onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(48.dp) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource( - R.string.prepare_apk_delete_confirm - ), - tint = colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } - } - } - is ApkPreparationStatus.UpdateAvailable -> { - androidx.compose.material3.IconButton( - onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(48.dp) + }, + tint = colorScheme.primary + ) + } else if ( + apkStatus.source == + UniversalApkManager.ApkSource.DOWNLOADED ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource( - R.string.prepare_apk_delete_confirm + ApkPrepareRowIconButton( + icon = Icons.Default.Delete, + description = stringResource( + R.string.prepare_apk_button_delete ), - tint = colorScheme.error, - modifier = Modifier.size(20.dp) + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DeleteClicked + ) + }, + tint = colorScheme.error ) } } + is ApkPreparationStatus.Resumable, + is ApkPreparationStatus.Error -> + ApkPrepareRowIconButton( + icon = Icons.Default.Refresh, + description = stringResource( + R.string.prepare_apk_retry + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.PrepareRowClicked + ) + }, + tint = colorScheme.primary + ) else -> {} } } // Prepare Dialog if (apkUiState.showPrepareDialog) { - val status = apkStatus - val sizeMB: Int? = when (status) { - is ApkPreparationStatus.NotDownloaded -> status.sizeMB - is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB - else -> null - } AlertDialog( onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, title = { Text( - text = if (status is ApkPreparationStatus.UpdateAvailable) { - stringResource(R.string.prepare_apk_update_dialog_title) - } else { - stringResource(R.string.prepare_apk_dialog_title) - }, + text = stringResource( + if (availableUpdate != null) { + R.string.prepare_apk_update_dialog_title + } else { + R.string.prepare_apk_dialog_title + } + ), style = MaterialTheme.typography.titleLarge ) }, text = { Text( - text = if (status is ApkPreparationStatus.UpdateAvailable) { - stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion) - } else if (sizeMB != null) { - stringResource(R.string.prepare_apk_dialog_message, sizeMB) + text = if (availableUpdate != null) { + stringResource( + R.string.prepare_apk_update_dialog_message, + availableUpdate.version, + availableUpdate.sizeMB + ) } else { - stringResource(R.string.prepare_apk_dialog_message_unknown_size) + stringResource( + R.string.prepare_apk_dialog_message_unknown_size + ) }, style = MaterialTheme.typography.bodyMedium ) @@ -678,7 +945,11 @@ fun AboutSheet( containerColor = colorScheme.error ) ) { - Text("Delete") + Text( + stringResource( + R.string.prepare_apk_button_delete + ) + ) } }, dismissButton = { @@ -690,9 +961,9 @@ fun AboutSheet( ) } - // Show sharing rows only when APK is ready - val canShareAPK = apkStatus is ApkPreparationStatus.Ready || - apkStatus is ApkPreparationStatus.UpdateAvailable + // A GitHub update is optional. Keep sharing visible while the + // replacement downloads or while metadata refreshes. + val canShareAPK = shareableApk != null AnimatedVisibility( visible = canShareAPK, diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt index aea1afac..4d0c2d5b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -1,16 +1,22 @@ package com.bitchat.android.ui import android.app.Application +import android.content.Context import android.util.Log +import androidx.annotation.StringRes import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.bitchat.android.R import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.AppVersion +import com.bitchat.android.util.GitHubReleaseClient +import com.bitchat.android.util.LatestReleaseProvider import com.bitchat.android.util.ShareableApkVariant import com.bitchat.android.util.UniversalApkManager import com.bitchat.android.util.WorkManagerApkDownloader import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -24,25 +30,61 @@ import kotlinx.coroutines.withContext sealed class ApkPreparationStatus { object Loading : ApkPreparationStatus() - data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus() + object NotDownloaded : ApkPreparationStatus() data class Ready( val version: String, val sizeMB: Int, val source: UniversalApkManager.ApkSource, val variant: ShareableApkVariant ) : ApkPreparationStatus() - data class UpdateAvailable( - val currentVersion: String, - val newVersion: String, - val newSizeMB: Int + /** [phase] is what the operation is actually doing; only a transfer has a real percentage. */ + data class Downloading( + val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource, + val shareableFallback: Ready? = null ) : ApkPreparationStatus() - object Downloading : ApkPreparationStatus() - data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() - data class Error(val message: String) : ApkPreparationStatus() + data class Resumable( + val progressPercent: Int, + val failure: ApkFailureMessage + ) : ApkPreparationStatus() + data class Error(val failure: ApkFailureMessage) : ApkPreparationStatus() +} + +/** A localizable failure kept as data until the UI or a one-shot effect renders it. */ +data class ApkFailureMessage( + @StringRes val messageRes: Int, + val messageArgs: List = emptyList() +) + +/** + * Resolves a failure defensively. The reason and its arguments cross the WorkManager boundary + * independently, so an argument list that does not match the format string is possible; a row + * showing generic text beats one that throws while formatting. + */ +internal fun Context.resolveApkFailureMessage(failure: ApkFailureMessage): String { + return runCatching { + getString( + failure.messageRes, + *failure.messageArgs.toTypedArray() + ) + }.getOrElse { + getString(R.string.prepare_apk_error_generic) + } +} + +sealed class ApkReleaseStatus { + object Unknown : ApkReleaseStatus() + object Checking : ApkReleaseStatus() + data class Known( + val version: String, + val sizeMB: Int, + val isNewerThanSharedApk: Boolean, + val fromStaleCache: Boolean + ) : ApkReleaseStatus() } data class ApkUiState( val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, + val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, val downloadProgress: Int = 0, val showPrepareDialog: Boolean = false, val showDeleteDialog: Boolean = false, @@ -67,6 +109,36 @@ sealed class ApkUiEvent { object CancelDownload : ApkUiEvent() } +// --- Row tap --- + +/** What tapping the body of the prepare row does. */ +internal enum class PrepareRowTapAction { + OpenPrepareDialog, + StartDownload +} + +/** + * What a tap on the prepare row means for [status], or null when the row has nothing to offer. + * + * The trailing controls are icon-only, so the row body is the discoverable half of every action + * and has to stay in step with them. Deriving both the tap handler and the row's `enabled` flag + * from this one function keeps the row from looking clickable while doing nothing. + */ +internal fun prepareRowTapAction( + status: ApkPreparationStatus, + releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown +): PrepareRowTapAction? = when { + status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog + // Consent was already given for these; resuming straight away avoids a redundant prompt. + status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Ready && + (status.source == UniversalApkManager.ApkSource.INSTALLED || + (releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) -> + PrepareRowTapAction.OpenPrepareDialog + else -> null +} + // --- Effects (ViewModel → UI, one-shot) --- sealed class ApkUiEffect { @@ -79,21 +151,32 @@ sealed class ApkUiEffect { * ViewModel for APK download/status/share logic following MVI pattern. * UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect]. */ -class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) { +class ApkDownloadViewModel internal constructor( + application: Application, + private val apkManager: UniversalApkManager, + private val downloader: ApkDownloader, + private val latestReleaseProvider: LatestReleaseProvider +) : AndroidViewModel(application) { + + constructor(application: Application) : this( + application = application, + apkManager = UniversalApkManager(application), + downloader = WorkManagerApkDownloader(application), + latestReleaseProvider = GitHubReleaseClient(application) + ) companion object { private const val TAG = "ApkDownloadVM" } - private val apkManager = UniversalApkManager(application) - private val downloader: ApkDownloader = WorkManagerApkDownloader(application) - private val _state = MutableStateFlow(ApkUiState()) val state: StateFlow = _state.asStateFlow() private val _effect = Channel(Channel.BUFFERED) val effect = _effect.receiveAsFlow() + private var metadataRefreshJob: Job? = null + init { observeDownloader() } @@ -117,16 +200,16 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat } private fun onPrepareRowClicked() { - when (_state.value.apkStatus) { - is ApkPreparationStatus.NotDownloaded, - is ApkPreparationStatus.UpdateAvailable, - is ApkPreparationStatus.Error -> { + when ( + prepareRowTapAction( + _state.value.apkStatus, + _state.value.releaseStatus + ) + ) { + PrepareRowTapAction.OpenPrepareDialog -> _state.update { it.copy(showPrepareDialog = true) } - } - is ApkPreparationStatus.Resumable -> { - startDownload() - } - else -> {} + PrepareRowTapAction.StartDownload -> startDownload() + null -> {} } } @@ -137,8 +220,10 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onDownloadUniversalClicked() { val status = _state.value.apkStatus + val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known) + ?.isNewerThanSharedApk == true if (status is ApkPreparationStatus.Ready && - status.variant == ShareableApkVariant.ARM64 + (status.source == UniversalApkManager.ApkSource.INSTALLED || hasUpdate) ) { _state.update { it.copy(showPrepareDialog = true) } } @@ -193,14 +278,31 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onCancelDownload() { downloader.cancelDownload() - checkStatus() + + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + _state.update { + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) + } + if (fallback == null) checkStatus() } private fun startDownload() { + val current = _state.value.apkStatus + val fallback = when (current) { + is ApkPreparationStatus.Ready -> current + is ApkPreparationStatus.Downloading -> current.shareableFallback + else -> null + } val partial = apkManager.getPartialDownloadProgress() _state.update { it.copy( - apkStatus = ApkPreparationStatus.Downloading, + apkStatus = ApkPreparationStatus.Downloading( + shareableFallback = fallback + ), downloadProgress = partial ?: 0 ) } @@ -209,21 +311,68 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun checkStatus() { viewModelScope.launch { - // WorkManager is the source of truth for active work. A queued or - // newly started job legitimately has no partial file yet, so never - // infer that it is orphaned from cache contents. - if (_state.value.apkStatus is ApkPreparationStatus.Downloading) { - return@launch - } - val resolvedStatus = resolveApkStatus() _state.update { current -> - if (current.apkStatus is ApkPreparationStatus.Downloading) { - current - } else { - current.copy(apkStatus = resolvedStatus) + // WorkManager is the source of truth for active work. A queued or newly started + // job legitimately has no partial file yet, so never infer that it is orphaned + // from cache contents, and never let a resolved status overwrite it - the user + // may have started a download while the local artifact was being inspected. + when (val active = current.apkStatus) { + is ApkPreparationStatus.Downloading -> + // Active work still adopts a local artifact it was created without. A + // ViewModel restored onto a running download starts from Loading, so the + // observer had no Ready to carry into shareableFallback, and an installed + // APK - with both sharing actions - would stay hidden for the whole + // transfer. Deciding here covers the observer arriving before this runs + // and during the resolve above, which are different orderings. + if (active.shareableFallback == null) { + current.copy( + apkStatus = active.copy( + shareableFallback = shareableReady(resolvedStatus) + ) + ) + } else { + current + } + else -> current.copy( + apkStatus = resolvedStatus, + downloadProgress = 0 + ) } } + // Metadata is skipped while work is active, as it was before: an in-flight download + // has no use for a freshness check and the API budget is scarce. + if (_state.value.apkStatus is ApkPreparationStatus.Downloading) return@launch + // Local availability is resolved and published before this independent network task + // starts. Metadata can add a freshness warning, but can never hide sharing. + refreshReleaseMetadata() + } + } + + private fun refreshReleaseMetadata() { + if (metadataRefreshJob?.isActive == true) return + metadataRefreshJob = viewModelScope.launch { + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Checking) } + latestReleaseProvider.latestRelease() + .onSuccess { snapshot -> + val shared = shareableReady(_state.value.apkStatus) + _state.update { + it.copy( + releaseStatus = ApkReleaseStatus.Known( + version = snapshot.release.versionName, + sizeMB = (snapshot.release.universalApkSize / 1024 / 1024).toInt(), + isNewerThanSharedApk = shared?.let { ready -> + AppVersion.isNewer(ready.version, snapshot.release.versionName) + } ?: false, + fromStaleCache = snapshot.isStale + ) + ) + } + } + .onFailure { + // Metadata is an optional enhancement. Keep the locally resolved APK state. + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Unknown) } + } } } @@ -232,12 +381,28 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat downloader.downloadState.collect { downloadState -> when (downloadState) { is ApkDownloader.DownloadState.Idle -> { - // Don't overwrite — status set by checkStatus() + val downloading = _state.value.apkStatus as? ApkPreparationStatus.Downloading + if (downloading != null) { + val fallback = downloading.shareableFallback + _state.update { + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) + } + if (fallback == null) checkStatus() + } } is ApkDownloader.DownloadState.Downloading -> { _state.update { + val fallback = (it.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: (it.apkStatus as? ApkPreparationStatus.Ready) it.copy( - apkStatus = ApkPreparationStatus.Downloading, + apkStatus = ApkPreparationStatus.Downloading( + phase = downloadState.phase, + shareableFallback = fallback + ), downloadProgress = downloadState.progressPercent ) } @@ -245,48 +410,53 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat is ApkDownloader.DownloadState.Success -> { val info = apkManager.getCachedApkInfo() _state.update { + val ready = ApkPreparationStatus.Ready( + version = info?.version ?: downloadState.version, + sizeMB = info?.let { cached -> + (cached.size / 1024 / 1024).toInt() + } ?: downloadState.sizeMB, + source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED, + variant = info?.variant ?: ShareableApkVariant.UNIVERSAL + ) it.copy( - apkStatus = ApkPreparationStatus.Ready( - version = info?.version ?: downloadState.version, - sizeMB = info?.let { cached -> - (cached.size / 1024 / 1024).toInt() - } ?: downloadState.sizeMB, - source = info?.source ?: UniversalApkManager.ApkSource.GITHUB, - variant = info?.variant ?: ShareableApkVariant.UNIVERSAL - ), + apkStatus = ready, + releaseStatus = releaseStatusFor(ready, it.releaseStatus), downloadProgress = 100 ) } } is ApkDownloader.DownloadState.Failed -> { - val localArm64 = apkManager.getCachedApkInfo() - ?.takeIf { it.variant == ShareableApkVariant.ARM64 } - if (localArm64 != null) { + val failure = downloadState.toFailureMessage() + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: apkManager.getCachedApkInfo()?.toReady() + if (fallback != null) { _state.update { it.copy( - apkStatus = ApkPreparationStatus.Ready( - version = localArm64.version, - sizeMB = (localArm64.size / 1024 / 1024).toInt(), - source = localArm64.source, - variant = localArm64.variant - ) + apkStatus = fallback, + releaseStatus = releaseStatusFor(fallback, it.releaseStatus), + downloadProgress = 0 ) } - _effect.send(ApkUiEffect.ShowToast(downloadState.message)) + _effect.send( + ApkUiEffect.ShowToast( + getApplication().resolveApkFailureMessage( + failure + ) + ) + ) } else { _state.update { if (downloadState.resumablePercent != null) { it.copy( apkStatus = ApkPreparationStatus.Resumable( progressPercent = downloadState.resumablePercent, - message = downloadState.message + failure = failure ), downloadProgress = downloadState.resumablePercent ) } else { - it.copy( - apkStatus = ApkPreparationStatus.Error(downloadState.message) - ) + it.copy(apkStatus = ApkPreparationStatus.Error(failure)) } } } @@ -306,71 +476,55 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat return getApplication().getString(resId) } + private fun ApkDownloader.DownloadState.Failed.toFailureMessage() = ApkFailureMessage( + messageRes = reason.messageRes, + messageArgs = messageArgs + ) + + private fun shareableReady(status: ApkPreparationStatus): ApkPreparationStatus.Ready? = + when (status) { + is ApkPreparationStatus.Ready -> status + is ApkPreparationStatus.Downloading -> status.shareableFallback + else -> null + } + + private fun releaseStatusFor( + ready: ApkPreparationStatus.Ready, + releaseStatus: ApkReleaseStatus + ): ApkReleaseStatus = (releaseStatus as? ApkReleaseStatus.Known)?.let { + it.copy(isNewerThanSharedApk = AppVersion.isNewer(ready.version, it.version)) + } ?: releaseStatus + + private fun UniversalApkManager.ApkInfo.toReady() = ApkPreparationStatus.Ready( + version = version, + sizeMB = (size / 1024 / 1024).toInt(), + source = source, + variant = variant + ) + private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { try { - val updateStatus = apkManager.checkForUpdate() - when (updateStatus) { - is UniversalApkManager.UpdateStatus.NotDownloaded -> { - val partial = apkManager.getPartialDownloadProgress() - if (partial != null) { - ApkPreparationStatus.Resumable( - progressPercent = partial, - message = getString(R.string.prepare_apk_download_interrupted) + val info = apkManager.prepareLocalApkInfo() + if (info != null) { + info.toReady() + } else { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + failure = ApkFailureMessage( + messageRes = R.string.prepare_apk_download_interrupted ) - } else { - ApkPreparationStatus.NotDownloaded( - sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() - ) - } - } - is UniversalApkManager.UpdateStatus.UpToDate -> { - val info = apkManager.getCachedApkInfo() - if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) - } else { - ApkPreparationStatus.Error("Cached APK info not found") - } - } - is UniversalApkManager.UpdateStatus.UpdateAvailable -> { - ApkPreparationStatus.UpdateAvailable( - currentVersion = updateStatus.currentVersion, - newVersion = updateStatus.latestRelease.versionName, - newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() ) - } - is UniversalApkManager.UpdateStatus.Error -> { - // A cached artifact stays shareable even when the update - // check fails or the release lags the installed version. - val info = apkManager.getCachedApkInfo() - if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) - } else { - val partial = apkManager.getPartialDownloadProgress() - if (partial != null) { - ApkPreparationStatus.Resumable( - progressPercent = partial, - message = getString(R.string.prepare_apk_download_interrupted) - ) - } else { - ApkPreparationStatus.Error(updateStatus.message) - } - } + } else { + ApkPreparationStatus.NotDownloaded } } } catch (e: Exception) { - Log.e(TAG, "Error checking APK status", e) + // The exception text is English and often internal; log it, show a translated line. + Log.e(TAG, "Error reading APK status", e) ApkPreparationStatus.Error( - e.message ?: getString(R.string.prepare_apk_error_github) + ApkFailureMessage(messageRes = R.string.share_apk_error) ) } } 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 f8820e91..aac5283c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -83,6 +84,28 @@ private val HeaderTextSize = 17.sp /** Minimum tap target for every interactive element in the header. */ private val HeaderTapTarget = 44.dp +internal enum class HeaderCrowdingMode { + Full, + HideJoinedChannelCount, + IconOnlyLocationChannel, +} + +internal fun headerCrowdingMode(availableWidth: Dp): HeaderCrowdingMode = when { + availableWidth < 360.dp -> HeaderCrowdingMode.IconOnlyLocationChannel + availableWidth < 400.dp -> HeaderCrowdingMode.HideJoinedChannelCount + else -> HeaderCrowdingMode.Full +} + +internal fun locationChannelContentDescription( + actionDescription: String, + channelLabel: String, + showLabel: Boolean, +): String = if (showLabel) { + actionDescription +} else { + "$channelLabel. $actionDescription" +} + /** Corner radius for the header's tappable label+icon clusters. */ private val HeaderClusterShape = RoundedCornerShape(8.dp) @@ -475,6 +498,19 @@ fun ConversationHeaderAction( content = content ) +/** A read-only status slot matching the footprint of [ConversationHeaderAction]. */ +@Composable +fun ConversationHeaderStatus( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier.size(HeaderTapTarget), + contentAlignment = Alignment.Center, + content = { content() } + ) +} + @Composable fun NicknameEditor( value: String, @@ -518,6 +554,7 @@ fun NicknameEditor( } ), modifier = Modifier + .weight(1f) .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) @@ -533,7 +570,8 @@ fun PeerCounter( selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, geohashPeople: List, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + showJoinedChannelCount: Boolean = true ) { val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme @@ -590,7 +628,7 @@ fun PeerCounter( fontWeight = FontWeight.Medium ) - if (joinedChannels.isNotEmpty()) { + if (showJoinedChannelCount && joinedChannels.isNotEmpty()) { AnimatedCount( count = joinedChannels.size, prefix = stringResource(R.string.channel_count_prefix), @@ -690,22 +728,17 @@ private fun MainHeader( val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - Row( - modifier = Modifier - .fillMaxWidth() - .height(ChatHeaderHeight) - .padding(start = HeaderInsetStart, end = HeaderInsetEnd), - verticalAlignment = Alignment.CenterVertically - ) { - // MARK: - Identity cluster. - // - // Weighted so it yields space to the status cluster rather than pushing it off screen. - // Compose measures unweighted children first, so the icons on the right always get the - // width they need and a long nickname simply scrolls within what is left. + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val crowdingMode = headerCrowdingMode(maxWidth) + Row( - modifier = Modifier.weight(1f), + modifier = Modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), verticalAlignment = Alignment.CenterVertically ) { + // Keep the brand and trailing actions fixed. Only the nickname yields under pressure. BitChatBrandButton( onClick = onTitleClick, onTripleClick = onTripleTitleClick, @@ -713,11 +746,13 @@ private fun MainHeader( modifier = Modifier.size(HeaderTapTarget), ) - // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than - // spacing between the mark and the path label. + // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than the + // spacing between the mark and path label. Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.offset(x = (-6).dp) + modifier = Modifier + .weight(1f) + .offset(x = (-6).dp) ) { Text( text = "/", @@ -731,66 +766,58 @@ private fun MainHeader( NicknameEditor( value = nickname, - onValueChange = onNicknameChange + onValueChange = onNicknameChange, + modifier = Modifier.weight(1f) ) } - } - // MARK: - Status cluster. - // - // Order, left to right: unread DMs, notes, channel, people. - // Tor health is read from the location channel / notes icon colour rather than a - // dedicated status dot. - Row( - verticalAlignment = Alignment.CenterVertically, - // Tight, because every child below is its own >=44.dp tap target. - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - // Unread private messages badge (click to open most recent DM) - if (hasUnreadPrivateMessages.isNotEmpty()) { - HeaderIconButton( - onClick = { viewModel.openLatestUnreadPrivateChat() }, - contentDescription = stringResource(R.string.cd_unread_private_messages) - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_envelope), - contentDescription = stringResource(R.string.cd_unread_private_messages), - modifier = Modifier.size(HeaderIconSize), - tint = palette.accentOrange - ) - } - } - - // Location notes + channel badge: one tight unit so the document glyph and the - // bluetooth/globe glyph sit at the same visual pitch as other header pairings. + // Order, left to right: unread DMs, notes, channel, people. This cluster is measured + // before the weighted nickname, so actions cannot be pushed off-screen by identity. Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(0.dp) + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - LocationNotesButton( - viewModel = viewModel, - onClick = onLocationNotesClick - ) + if (hasUnreadPrivateMessages.isNotEmpty()) { + HeaderIconButton( + onClick = { viewModel.openLatestUnreadPrivateChat() }, + contentDescription = stringResource(R.string.cd_unread_private_messages) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_private_messages), + modifier = Modifier.size(HeaderIconSize), + tint = palette.accentOrange + ) + } + } - // Bookmarking lives in the Location Channels sheet, one tap away via the channel - // button. Duplicating it here bought a shortcut for a rare action at the cost - // of a slot in the app's most crowded row. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) - LocationChannelsButton( - viewModel = viewModel, - onClick = onLocationChannelsClick + LocationChannelsButton( + viewModel = viewModel, + onClick = onLocationChannelsClick, + showLabel = crowdingMode != HeaderCrowdingMode.IconOnlyLocationChannel + ) + } + + PeerCounter( + connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, + joinedChannels = joinedChannels, + hasUnreadChannels = hasUnreadChannels, + isConnected = isConnected, + selectedLocationChannel = selectedLocationChannel, + geohashPeople = geohashPeople, + onClick = onSidebarClick, + showJoinedChannelCount = crowdingMode == HeaderCrowdingMode.Full ) } - - PeerCounter( - connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, - joinedChannels = joinedChannels, - hasUnreadChannels = hasUnreadChannels, - isConnected = isConnected, - selectedLocationChannel = selectedLocationChannel, - geohashPeople = geohashPeople, - onClick = onSidebarClick - ) } } } @@ -805,7 +832,8 @@ private fun MainHeader( @Composable private fun LocationChannelsButton( viewModel: ChatViewModel, - onClick: () -> Unit + onClick: () -> Unit, + showLabel: Boolean ) { val colorScheme = MaterialTheme.colorScheme @@ -832,35 +860,44 @@ private fun LocationChannelsButton( } else { R.drawable.ic_spec_range } + val actionDescription = stringResource(R.string.cd_open_location_channels) + val contentDescription = locationChannelContentDescription( + actionDescription = actionDescription, + channelLabel = badgeText, + showLabel = showLabel + ) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = if (showLabel) Arrangement.spacedBy(6.dp) else Arrangement.Center, modifier = Modifier .clip(HeaderClusterShape) .pressScaleClickable( onClick = onClick, - onClickLabel = stringResource(R.string.location_channels_title) + onClickLabel = actionDescription ) .height(HeaderTapTarget) - // No start padding: the notes icon is paired directly to the left; keep end - // padding so the gap to PeerCounter matches other cluster separations. - .padding(start = 0.dp, end = 6.dp) + .widthIn(min = HeaderTapTarget) + .padding(end = if (showLabel) 6.dp else 0.dp) ) { TorAwareHeaderIcon( painter = painterResource(badgeIconRes), tint = torVisual.tint, isProgress = torVisual.isProgress, - contentDescription = stringResource(R.string.cd_tor_status) + contentDescription = contentDescription ) - Text( - text = badgeText, - style = MaterialTheme.typography.bodyMedium, - fontSize = HeaderTextSize, - fontWeight = FontWeight.Medium, - color = channelColor, - maxLines = 1 - ) + if (showLabel) { + Text( + text = badgeText, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = channelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 88.dp) + ) + } } } 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 e9c8e343..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,5 +1,6 @@ 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 @@ -98,6 +99,14 @@ fun ChatScreen(viewModel: ChatViewModel) { 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 @@ -111,6 +120,9 @@ fun ChatScreen(viewModel: ChatViewModel) { 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) @@ -133,10 +145,12 @@ fun ChatScreen(viewModel: ChatViewModel) { 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) } } @@ -148,6 +162,16 @@ fun ChatScreen(viewModel: ChatViewModel) { 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() } } @@ -251,8 +275,19 @@ fun ChatScreen(viewModel: ChatViewModel) { Column( modifier = Modifier .fillMaxSize() - .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets - .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded + .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 @@ -356,14 +391,22 @@ fun ChatScreen(viewModel: ChatViewModel) { 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()) - messageText = TextFieldValue("") - forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll + viewModel.sendMessage(messageText.text.trim()) { accepted -> + if (accepted) { + messageText = TextFieldValue("") + viewModel.setConversationDraft(selectedPrivatePeer, "") + // Clearing the field in code does not run onMessageTextChange, + // so the popups have to be dismissed here. + viewModel.clearSuggestions() + forceScrollToBottom = !forceScrollToBottom + } + } } }, onSendVoiceNote = { peer, onionOrChannel, path -> @@ -375,6 +418,7 @@ fun ChatScreen(viewModel: ChatViewModel) { onSendFileNote = { peer, onionOrChannel, path -> viewModel.sendFileNote(peer, onionOrChannel, path) }, + recorderFactory = viewModel::createVoiceRecorder, showCommandSuggestions = showCommandSuggestions, commandSuggestions = commandSuggestions, @@ -492,6 +536,10 @@ fun ChatScreen(viewModel: ChatViewModel) { onAppInfoDismiss = { viewModel.hideAppInfo() }, showLocationChannelsSheet = showLocationChannelsSheet, onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false }, + onLocationNotesFromChannelsClick = { + showLocationChannelsSheet = false + showLocationNotesSheet = true + }, showLocationNotesSheet = showLocationNotesSheet, onLocationNotesSheetDismiss = { showLocationNotesSheet = false }, showUserSheet = showUserSheet, @@ -596,8 +644,13 @@ fun ChatInputSection( 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 @@ -674,6 +727,8 @@ fun ChatInputSection( nickname = nickname, showMediaButtons = showMediaButtons, mentionPeerIdentities = mentionPeerIdentities, + recorderFactory = recorderFactory, + activePublicTalker = activePublicTalker, modifier = Modifier.fillMaxWidth() ) } @@ -769,6 +824,7 @@ private fun ChatDialogs( onAppInfoDismiss: () -> Unit, showLocationChannelsSheet: Boolean, onLocationChannelsSheetDismiss: () -> Unit, + onLocationNotesFromChannelsClick: () -> Unit, showLocationNotesSheet: Boolean, onLocationNotesSheetDismiss: () -> Unit, showUserSheet: Boolean, @@ -815,6 +871,7 @@ private fun ChatDialogs( LocationChannelsSheet( isPresented = showLocationChannelsSheet, onDismiss = onLocationChannelsSheetDismiss, + onLocationNotesClick = onLocationNotesFromChannelsClick, viewModel = viewModel ) } 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 d2ab06d3..14c7879d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -53,11 +53,14 @@ fun getRSSIColor(rssi: Int): Color { } } +/** Thin space (U+2009) separating a display name from its `#abcd` disambiguation suffix. */ +internal const val SUFFIX_THIN_SPACE = " " + /** * Build the sender label shown above the first message of a group. * - * Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click` - * annotation for everyone except yourself. + * Renders `@name` plus a dimmed `#abcd` suffix, separated by a thin space. The name carries + * a `nickname_click` annotation for everyone except yourself. */ fun formatTextMessageSender( message: BitchatMessage, @@ -97,6 +100,7 @@ fun formatTextMessageSender( builder.pop() if (suffix.isNotEmpty()) { + builder.append(SUFFIX_THIN_SPACE) builder.pushStyle( SpanStyle( color = senderColor.copy(alpha = SUFFIX_ALPHA), @@ -304,6 +308,7 @@ fun formatMessageHeaderAnnotatedString( builder.pop() if (suffix.isNotEmpty()) { + builder.append(SUFFIX_THIN_SPACE) builder.pushStyle( SpanStyle( color = baseColor.copy(alpha = SUFFIX_ALPHA), 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 ba5775a0..f40549b6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel 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 @@ -14,6 +15,8 @@ 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 @@ -27,7 +30,6 @@ import com.bitchat.android.protocol.BitchatPacket import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay import java.util.Date import kotlin.random.Random @@ -37,6 +39,15 @@ 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 @@ -58,12 +69,29 @@ class ChatViewModel( companion object { private const val TAG = "ChatViewModel" + private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L } 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) } @@ -109,8 +137,20 @@ class ChatViewModel( 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 { @@ -131,11 +171,16 @@ class ChatViewModel( seenMessageStore.markReadLocally(messageID) } ) - private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) + 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( @@ -193,20 +238,73 @@ class ChatViewModel( val privateChats: StateFlow>> = state.privateChats val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages - internal val unreadConversations: StateFlow> = combine( + 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, - state.connectedPeers - ) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs -> + conversationLiveIdentityState, + com.bitchat.android.services.AppStateStore.unreadPrivateMessageCounts + ) { unreadConversationIDs, chats, currentNickname, liveIdentity, unreadCounts -> val seenStore = seenMessageStore - val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() } - buildUnreadConversationSummaries( + 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 -> seenStore.hasBeenReadLocally(message.id) } + 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 @@ -221,16 +319,34 @@ class ChatViewModel( ?.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 = resolution.displayName - ?.takeUnless { - it.isBlank() || it.equals("Unknown", ignoreCase = true) - } - ?: summary.displayName, + 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 = aliases.any(connectedPeerIDSet::contains), + isConnected = connectedPeerID != null, + connectedPeerID = connectedPeerID, sourceGeohash = aliases .asSequence() .mapNotNull(GeohashConversationRegistry::get) @@ -238,6 +354,24 @@ class ChatViewModel( ) } } + + 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, @@ -290,10 +424,18 @@ class ChatViewModel( } 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 -> @@ -317,7 +459,12 @@ class ChatViewModel( } } catch (_: Exception) { } } viewModelScope.launch { - try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> + 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 { @@ -327,10 +474,14 @@ class ChatViewModel( messages.any { message -> message.sender != myNick && message.sender != "system" && + !com.bitchat.android.services.AppStateStore + .isPrivateMessageRead(message.id) && !seenMessageStore.hasBeenReadLocally(message.id) } } - .keys + .keys + unreadCounts + .filterValues { it > 0 } + .keys } catch (_: Exception) { state.getUnreadPrivateMessagesValue() } @@ -357,6 +508,60 @@ class ChatViewModel( // 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) @@ -420,11 +625,9 @@ class ChatViewModel( refreshPeerFavoritedUs() try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener( - object : com.bitchat.android.favorites.FavoritesChangeListener { - override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs() - override fun onAllCleared() = refreshPeerFavoritedUs() - } + favoriteRelationshipChangeListener ) + favoriteRelationshipListenerRegistered = true } catch (_: Exception) { } // Load verified fingerprints from secure storage @@ -443,9 +646,16 @@ class ChatViewModel( } override fun onCleared() { + if (favoriteRelationshipListenerRegistered) { + runCatching { + FavoritesPersistenceService.shared.removeListener( + favoriteRelationshipChangeListener + ) + } + favoriteRelationshipListenerRegistered = false + } geohashViewModel.shutdownUiSubscriptions() com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) - super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } @@ -489,6 +699,13 @@ class ChatViewModel( 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, @@ -509,7 +726,17 @@ class ChatViewModel( } 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 @@ -518,6 +745,133 @@ class ChatViewModel( 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() { @@ -574,8 +928,14 @@ class ChatViewModel( // MARK: - Message Sending - fun sendMessage(content: String) { - if (content.isEmpty()) return + fun sendMessage( + content: String, + onAccepted: (Boolean) -> Unit = {} + ) { + if (content.isEmpty()) { + onAccepted(false) + return + } // Check for commands if (content.startsWith("/")) { @@ -589,10 +949,25 @@ class ChatViewModel( 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 } @@ -621,18 +996,33 @@ class ChatViewModel( } // Send private message val recipientNickname = nicknameForPeer(selectedPeer) - privateChatManager.sendPrivateMessage( - content, - selectedPeer, - 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) + 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 @@ -678,6 +1068,7 @@ class ChatViewModel( mesh.sendMessage(content, mentions, null) } } + onAccepted(true) } } @@ -979,7 +1370,11 @@ class ChatViewModel( fun updateCommandSuggestions(input: String) { commandProcessor.updateCommandSuggestions(input) } - + + fun clearSuggestions() { + commandProcessor.clearSuggestions() + } + fun selectCommandSuggestion(suggestion: CommandSuggestion): String { return commandProcessor.selectCommandSuggestion(suggestion) } @@ -1041,8 +1436,22 @@ class ChatViewModel( } // 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 @@ -1053,26 +1462,35 @@ class ChatViewModel( // 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 seen message store + // Clear seen message store and MessageRouter outbox try { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } - - // Clear all mesh service data - clearAllMeshServiceData() + 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()) @@ -1099,8 +1517,17 @@ class ChatViewModel( val newNickname = "anon${Random.nextInt(1000, 9999)}" state.setNickname(newNickname) dataManager.saveNickname(newNickname) - + + 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}") 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 34b205af..1cccfb19 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -4,6 +4,8 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Handles processing of IRC-style commands @@ -12,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 @@ -23,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") @@ -38,6 +42,7 @@ class CommandProcessor( when (cmd) { "/j", "/join" -> handleJoinCommand(parts, myPeerID) "/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel) + "/pay" -> handlePayCommand(command, meshService, myPeerID, onSendMessage, viewModel) "/w" -> handleWhoCommand(meshService, viewModel) "/clear" -> handleClearCommand() "/pass" -> handlePassCommand(parts, myPeerID) @@ -90,15 +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 -> - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel) - } + getMyPeerID(meshService), + meshService, + viewModel + ) } else { val systemMessage = BitchatMessage( sender = "system", @@ -189,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 @@ -300,15 +308,15 @@ class CommandProcessor( // 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, viewModel) - } + myPeerID, + meshService, + viewModel + ) } else if (isInLocationChannel) { // Let the transport layer add the echo; just send it out onSendMessage(actionMessage, emptyList(), null) @@ -357,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( @@ -368,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) { @@ -404,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 { @@ -524,7 +638,51 @@ class CommandProcessor( private fun getMyPeerID(meshService: MeshService): String { return meshService.myPeerID } - + + 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, 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/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index d784610a..0c0e583f 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -281,6 +281,7 @@ private fun GeohashPersonItem( onTap: () -> Unit ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val statusIconRes = if (isTeleported) R.drawable.ic_spec_teleport @@ -298,25 +299,14 @@ private fun GeohashPersonItem( Row( modifier = Modifier .fillMaxWidth() - // Exact height, not padding: a row that sizes to its content makes the card change - // height whenever the list reorders. - .height(SheetRowHeight) .clickable(onClick = onTap) - .padding(horizontal = SheetRowHorizontal), + .padding(horizontal = SheetRowHorizontal, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier.size(SheetRowLeadingSlot), - contentAlignment = Alignment.Center - ) { - if (hasUnreadDM) { - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(22.dp), - tint = palette.accentOrange - ) - } else { + PeerAvatar( + name = baseNameRaw, + color = baseColor, + badge = { Icon( painter = painterResource(statusIconRes), contentDescription = if (isTeleported) { @@ -324,13 +314,13 @@ private fun GeohashPersonItem( } else { stringResource(R.string.section_on_location) }, - modifier = Modifier.size(22.dp), - tint = baseColor + modifier = Modifier.size(13.dp), + tint = if (isTeleported) palette.accentPurple else colorScheme.primary ) } - } + ) - Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Spacer(modifier = Modifier.width(12.dp)) Row( modifier = Modifier.weight(1f), @@ -365,5 +355,16 @@ private fun GeohashPersonItem( ) } } + + 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 index 9565c384..ce450ded 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -1,50 +1,48 @@ 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 android.annotation.SuppressLint -import android.app.Activity -import android.content.Intent -import android.content.res.Configuration -import android.os.Bundle -import android.view.ViewGroup -import android.webkit.JavascriptInterface -import android.webkit.WebChromeClient -import android.webkit.WebSettings -import android.webkit.WebView -import android.webkit.WebResourceRequest -import android.webkit.WebViewClient -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape 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.Scaffold 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 com.bitchat.android.ui.theme.BitchatTheme import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.res.stringResource -import com.bitchat.android.ui.theme.BitchatFontFamily +import androidx.core.content.res.ResourcesCompat import com.bitchat.android.R -import androidx.core.view.updateLayoutParams 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 -@OptIn(ExperimentalMaterial3Api::class) class GeohashPickerActivity : OrientationAwareActivity() { companion object { @@ -52,13 +50,13 @@ class GeohashPickerActivity : OrientationAwareActivity() { const val EXTRA_RESULT_GEOHASH = "result_geohash" } - @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase() var geohashToFocus: String? = null - var (initLat, initLon) = 0.0 to 0.0 + var initLat = 20.0 + var initLon = 0.0 if (!initialGeohash.isNullOrEmpty()) { geohashToFocus = initialGeohash @@ -84,201 +82,206 @@ class GeohashPickerActivity : OrientationAwareActivity() { } } - val initialPrecision = geohashToFocus?.length ?: 5 + val initialPrecision = (geohashToFocus?.length ?: 2).coerceIn(1, 12) + val targetLat = initLat + val targetLon = initLon setContent { BitchatTheme { - var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") } - var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) } - var webViewRef by remember { mutableStateOf(null) } + 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 standardGreen = colorScheme.primary - - Scaffold { padding -> - Box(Modifier.fillMaxSize()) { - AndroidView( - factory = { context -> - WebView(context).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.cacheMode = WebSettings.LOAD_DEFAULT - settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW - settings.allowFileAccess = true - settings.allowContentAccess = true - webChromeClient = WebChromeClient() - webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - val url = request?.url?.toString() ?: return true - // Block navigation away from the local geohash picker asset - return !url.startsWith("file:///android_asset/geohash_picker.html") - } - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - // Initialize to last/initial geohash if provided, otherwise center - if (!geohashToFocus.isNullOrEmpty()) { - evaluateJavascript( - "window.focusGeohash('${geohashToFocus}')", - null - ) - } else { - evaluateJavascript( - "window.setCenter(${initLat}, ${initLon})", - null - ) - } - - // Apply theme to map tiles - val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" - evaluateJavascript("window.setMapTheme('" + theme + "')", null) - } - } - addJavascriptInterface(object { - @JavascriptInterface - fun onGeohashChanged(geohash: String) { - runOnUiThread { - currentGeohash = geohash - } - } - }, "Android") - - loadUrl("file:///android_asset/geohash_picker.html") - } - }, - modifier = Modifier - .fillMaxSize() - .padding(padding), - update = { webView -> - webViewRef = webView - // ensure it fills parent - webView.updateLayoutParams { - width = ViewGroup.LayoutParams.MATCH_PARENT - height = ViewGroup.LayoutParams.MATCH_PARENT - } - }, - onRelease = { webView -> - // Best-effort cleanup to avoid leaks and timers - try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {} - try { webView.stopLoading() } catch (_: Throwable) {} - try { webView.clearHistory() } catch (_: Throwable) {} - try { webView.clearCache(true) } catch (_: Throwable) {} - try { webView.loadUrl("about:blank") } catch (_: Throwable) {} - try { webView.removeAllViews() } catch (_: Throwable) {} - try { webView.destroy() } catch (_: Throwable) {} - } + 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 + ) + } + } - // Floating info pill - Surface( + 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 - .align(Alignment.TopCenter) - .padding(top = 20.dp) - .fillMaxWidth(0.75f), + .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 ) { - 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) - .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 (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location", + text = if (globeState.selectedGeohash.isNotEmpty()) "#${globeState.selectedGeohash}" else "select location", fontSize = BASE_FONT_SIZE.sp, fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(horizontal = 14.dp, vertical = 10.dp) + 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)) } - // Button row - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically + // Increase precision + Button( + onClick = { globeState.animatePrecision(globeState.precision + 1) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.primary + ) ) { - // Decrease precision - Button( - onClick = { - precision = (precision - 1).coerceAtLeast(1) - webViewRef?.evaluateJavascript("window.setPrecision($precision)", null) - }, - colors = ButtonDefaults.buttonColors( - containerColor = standardGreen.copy(alpha = 0.12f), - contentColor = standardGreen - ) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision)) - } - } + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision)) + } - // Increase precision - Button( - onClick = { - precision = (precision + 1).coerceAtMost(12) - webViewRef?.evaluateJavascript("window.setPrecision($precision)", null) - }, - colors = ButtonDefaults.buttonColors( - containerColor = standardGreen.copy(alpha = 0.12f), - contentColor = standardGreen - ) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - 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() } - } - - // Select button - Button( - onClick = { - webViewRef?.evaluateJavascript("window.getGeohash()") { value -> - val gh = value?.trim('"') ?: currentGeohash - val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) } - setResult(Activity.RESULT_OK, result) - finish() - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - 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 - ) - } - } + }, + 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 + ) } } } @@ -286,4 +289,36 @@ class GeohashPickerActivity : OrientationAwareActivity() { } } } + + 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 index d2b706ec..63a5c0a7 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -119,6 +119,8 @@ class GeohashViewModel( 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?) { 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 7302379b..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,5 +1,6 @@ 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 @@ -13,7 +14,9 @@ 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 @@ -43,6 +46,11 @@ 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 @@ -57,9 +65,13 @@ 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -306,6 +318,8 @@ fun MessageInput( nickname: String, showMediaButtons: Boolean, mentionPeerIdentities: Map = emptyMap(), + recorderFactory: ((String?, String?) -> VoiceRecorder)? = null, + activePublicTalker: String? = null, modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current @@ -314,24 +328,75 @@ fun MessageInput( 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) + } - // Recording is the one state worth shouting about, so it overrides focus. + // 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 + } + } + + // 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.7f) + isRecording -> colorScheme.error.copy(alpha = 0.65f) isFocused.value -> palette.inputOutlineFocused else -> palette.inputOutline }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerBorder" ) - // A barely-there lift on focus. Enough to register, not enough to look like a different - // component. Slightly translucent so the messages scrolling underneath stay faintly visible. + 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 = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) - .copy(alpha = ComposerFillAlpha), + 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" ) @@ -351,7 +416,7 @@ fun MessageInput( animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) ) .background(containerColor, ComposerShape) - .border(1.dp, borderColor, ComposerShape), + .border(borderWidth, borderColor, ComposerShape), verticalAlignment = Alignment.Bottom ) { Box( @@ -367,16 +432,17 @@ fun MessageInput( // user is composing rather than reading, and green-on-black is tiring to // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.onSurface, + color = if (cashuToken == null) colorScheme.onSurface else Color.Transparent, fontFamily = BitchatFontFamily ), cursorBrush = SolidColor( - if (isRecording) Color.Transparent else colorScheme.onSurface + 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( @@ -405,6 +471,14 @@ fun MessageInput( } ) + 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( @@ -414,7 +488,9 @@ fun MessageInput( ) if (placeholderAlpha > 0f) { Text( - text = stringResource(R.string.type_a_message_placeholder), + 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 ), @@ -436,25 +512,29 @@ fun MessageInput( 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 ) { - RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(22.dp), - amplitudeNorm = normalizeAmplitudeSample(amplitude) - ) - Spacer(Modifier.width(12.dp)) + // 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() - val maxSecs = 10 // 10 second max recording time Text( - text = String.format( - "%02d:%02d / %02d:%02d", - secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 - ), + 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) + ) } } } @@ -528,10 +608,45 @@ fun MessageInput( ) } + // 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, - onStart = { + 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. @@ -545,6 +660,7 @@ fun MessageInput( }, 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 -> @@ -565,6 +681,7 @@ fun MessageInput( // waveform over an empty field. onCancel = { isRecording = false + isLiveRecording = false amplitude = 0 elapsedMs = 0L } @@ -583,6 +700,76 @@ fun MessageInput( // 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. 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/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 505ea5b0..2b4d6514 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -11,6 +11,7 @@ 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 @@ -52,6 +53,7 @@ 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 @@ -106,16 +108,21 @@ private const val SelectionConfirmDelayMs = 180L fun LocationChannelsSheet( isPresented: Boolean, onDismiss: () -> Unit, + onLocationNotesClick: () -> Unit, viewModel: ChatViewModel, modifier: Modifier = Modifier ) { val context = LocalContext.current val locationManager = LocationChannelManager.getInstance(context) 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.shared.revealed.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() @@ -130,6 +137,18 @@ fun LocationChannelsSheet( var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } + 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() @@ -151,8 +170,7 @@ fun LocationChannelsSheet( if (result.resultCode == android.app.Activity.RESULT_OK) { val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH) if (!gh.isNullOrBlank()) { - customGeohash = gh - customError = null + teleportToGeohash(gh) } } } @@ -171,6 +189,17 @@ fun LocationChannelsSheet( 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) { BitchatBottomSheet( @@ -485,15 +514,7 @@ fun LocationChannelsSheet( mapPickerLauncher.launch(intent) }, onTeleport = { - val normalized = customGeohash.trim().lowercase().replace("#", "") - if (validateGeohash(normalized)) { - val level = levelForLength(normalized.length) - val channel = GeohashChannel(level = level, geohash = normalized) - locationManager.selectManual(channel) - onDismiss() - } else { - customError = context.getString(R.string.invalid_geohash) - } + teleportToGeohash(customGeohash) } ) } @@ -527,6 +548,43 @@ fun LocationChannelsSheet( } } + 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() } @@ -662,6 +720,7 @@ private fun ChannelOptionRow( titleColor: Color? = null, titleBold: Boolean = false, leadingIcon: ImageVector? = null, + leadingIconRes: Int? = null, trailingContent: (@Composable (() -> Unit))? = null, onClick: () -> Unit ) { @@ -695,6 +754,14 @@ private fun ChannelOptionRow( modifier = Modifier.size(22.dp) ) } + leadingIconRes != null -> { + Icon( + painter = painterResource(leadingIconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } } } @@ -1109,6 +1176,15 @@ private fun validateGeohash(geohash: String): Boolean { 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 diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 489ddaf4..098f190b 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -48,12 +48,10 @@ fun LocationNotesButton( val notes by notesManager.notes.collectAsStateWithLifecycle() val notesCount = notes.size - // Only show in mesh mode when location is authorized (iOS pattern) - if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { - val hasNotes = notesCount > 0 + // 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 normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant - val torVisual = rememberTorConnectionVisual(normal = normalTint) + val torVisual = rememberTorConnectionVisual(normal = colorScheme.primary) Box( modifier = modifier diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index e982189f..d5db98bb 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -57,7 +57,8 @@ class MediaSendingManager( private data class PendingPrivateMedia( val request: LegacyPrivateMediaConsentRequest, - val peerID: String, + val conversationID: String, + val recipientMeshPeerID: String, val filePacket: BitchatFilePacket, val filePath: String, val messageType: BitchatMessageType, @@ -68,7 +69,8 @@ class MediaSendingManager( private data class PendingAutomaticPrivateMedia( val requestId: String, - val peerID: String, + val conversationID: String, + val recipientMeshPeerID: String, val filePacket: BitchatFilePacket, val filePath: String, val messageType: BitchatMessageType, @@ -299,10 +301,19 @@ class MediaSendingManager( 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(), - peerID = toPeerID, + conversationID = recipient.conversationID, + recipientMeshPeerID = recipient.meshPeerID, filePacket = filePacket, filePath = filePath, messageType = messageType, @@ -311,7 +322,7 @@ class MediaSendingManager( ) if (!reserveAutomaticPending(pending)) { addPrivateMediaSystemMessage( - toPeerID, + recipient.conversationID, "Private media was not sent because another secure media send is still pending." ) return @@ -334,7 +345,8 @@ class MediaSendingManager( val pending = consumePendingConsent(requestId) ?: return val automatic = PendingAutomaticPrivateMedia( requestId = UUID.randomUUID().toString(), - peerID = pending.peerID, + conversationID = pending.conversationID, + recipientMeshPeerID = pending.recipientMeshPeerID, filePacket = pending.filePacket, filePath = pending.filePath, messageType = pending.messageType, @@ -343,7 +355,7 @@ class MediaSendingManager( ) if (!reserveAutomaticPending(automatic)) { addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent because another secure media send is still pending." ) return @@ -374,7 +386,7 @@ class MediaSendingManager( private suspend fun retryPendingPrivateMediaOnScope(peerID: String) { val pending = synchronized(pendingConsentLock) { pendingAutomaticPrivateMedia - ?.takeIf { it.peerID == peerID } + ?.takeIf { it.recipientMeshPeerID == peerID } } ?: return evaluateAutomaticPending(pending) } @@ -397,7 +409,7 @@ class MediaSendingManager( val preparation = try { withContext(mediaWorkDispatcher) { meshService.prepareFilePrivate( - recipientPeerID = pending.peerID, + recipientPeerID = pending.recipientMeshPeerID, file = pending.filePacket, transferId = pending.transferId, allowLegacyFallback = pending.allowLegacyFallback @@ -429,7 +441,7 @@ class MediaSendingManager( } } - private fun handlePrivatePreparation( + private suspend fun handlePrivatePreparation( preparation: PrivateMediaPreparation, pending: PendingAutomaticPrivateMedia ) { @@ -438,7 +450,8 @@ class MediaSendingManager( clearAutomaticPending(pending.requestId) commitPreparedPrivateFile( preparation, - pending.peerID, + pending.conversationID, + pending.recipientMeshPeerID, pending.filePath, pending.messageType, pending.transferId @@ -450,16 +463,16 @@ class MediaSendingManager( if (pending.allowLegacyFallback) { Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted") addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent because its security policy changed." ) return } val nickname = try { - meshService.getPeerNicknames()[pending.peerID] + meshService.getPeerNicknames()[pending.recipientMeshPeerID] } catch (_: Exception) { null - } ?: pending.peerID.take(8) + } ?: pending.recipientMeshPeerID.take(8) val request = LegacyPrivateMediaConsentRequest( requestId = UUID.randomUUID().toString(), recipientNickname = nickname, @@ -473,7 +486,8 @@ class MediaSendingManager( } pendingPrivateMedia = PendingPrivateMedia( request, - pending.peerID, + pending.conversationID, + pending.recipientMeshPeerID, pending.filePacket, pending.filePath, pending.messageType, @@ -487,7 +501,7 @@ class MediaSendingManager( ensureAutomaticPendingTimeout(pending) Log.d(TAG, "Private media needs a Noise handshake; retaining first-send intent") try { - meshService.initiateNoiseHandshake(pending.peerID) + meshService.initiateNoiseHandshake(pending.recipientMeshPeerID) } catch (e: Exception) { Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}") } @@ -502,7 +516,7 @@ class MediaSendingManager( clearAutomaticPending(pending.requestId) Log.w(TAG, "Private media not sent: ${preparation.reason}") addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent: ${preparation.reason}" ) } @@ -542,7 +556,7 @@ class MediaSendingManager( } if (expired) { addPrivateMediaSystemMessage( - pending.peerID, + pending.conversationID, "Private media was not sent because secure session setup timed out." ) } @@ -585,9 +599,10 @@ class MediaSendingManager( } } - private fun commitPreparedPrivateFile( + private suspend fun commitPreparedPrivateFile( preparation: PrivateMediaPreparation.Ready, - toPeerID: String, + conversationID: String, + recipientMeshPeerID: String, filePath: String, messageType: BitchatMessageType, transferId: String @@ -605,13 +620,24 @@ class MediaSendingManager( timestamp = Date(), isRelay = false, isPrivate = true, - recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null }, + 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. - messageManager.addPrivateMessage(toPeerID, msg) + 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 @@ -622,14 +648,19 @@ class MediaSendingManager( ) if (!preparation.transfer.commit()) { - messageManager.removeMessageById(msg.id) synchronized(transferMessageMap) { transferMessageMap.remove(transferId) messageTransferMap.remove(msg.id) } - Log.w(TAG, "Prepared private-media commit failed; local echo rolled back") + 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( - toPeerID, + conversationID, "Private media was not sent because the prepared transfer could not be committed." ) return 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 de2f12c5..c6f7eb49 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -109,7 +109,6 @@ class MeshDelegateHandler( private suspend fun processPeerUpdate(mergedPeers: List) { state.setConnectedPeers(mergedPeers) state.setIsConnected(mergedPeers.isNotEmpty()) - notificationManager.showActiveUserNotification(mergedPeers) // Flush router outbox for any peers that just connected (and their noiseHex aliases) runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 27bb7448..c6233e1c 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -6,6 +6,7 @@ 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 @@ -23,18 +24,28 @@ 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 @@ -50,6 +61,7 @@ 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 @@ -60,6 +72,7 @@ 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 /** @@ -77,7 +90,6 @@ fun MeshPeerListSheet( 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() @@ -88,17 +100,51 @@ fun MeshPeerListSheet( val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - val unreadConversations by viewModel.unreadConversations.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 unreadIdentityAliases = remember(unreadConversations) { - unreadConversations + 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 -> - peerID.lowercase() in unreadIdentityAliases + 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( @@ -134,16 +180,172 @@ fun MeshPeerListSheet( else -> visibleConnectedPeers.count { it != viewModel.myPeerID } } - if (unreadConversations.isNotEmpty()) { - item(key = "unread_private_messages_section") { - UnreadDirectMessagesSection( - conversations = unreadConversations, + 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() }, - modifier = Modifier.padding(top = 8.dp) + 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() ) } } @@ -156,7 +358,7 @@ fun MeshPeerListSheet( iconRes = R.drawable.ic_spec_chat_bubbles, title = stringResource(R.string.channels), modifier = Modifier.padding( - top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp + top = if (conversations.isNotEmpty()) 20.dp else 8.dp ) ) Surface( @@ -209,11 +411,11 @@ fun MeshPeerListSheet( GeohashPeopleList( viewModel = viewModel, onTapPerson = onDismiss, - excludedIdentityAliases = unreadIdentityAliases, + excludedIdentityAliases = conversationIdentityAliases, modifier = Modifier.padding( top = if ( joinedChannels.isNotEmpty() || - unreadConversations.isNotEmpty() + conversations.isNotEmpty() ) 20.dp else 8.dp ) ) @@ -224,7 +426,7 @@ fun MeshPeerListSheet( modifier = Modifier.padding( top = if ( joinedChannels.isNotEmpty() || - unreadConversations.isNotEmpty() + conversations.isNotEmpty() ) 20.dp else 8.dp ), connectedPeers = visibleConnectedPeers, @@ -235,7 +437,7 @@ fun MeshPeerListSheet( selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, peopleCount = peopleCount, - excludedIdentityAliases = unreadIdentityAliases, + excludedIdentityAliases = conversationIdentityAliases, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -270,14 +472,101 @@ fun MeshPeerListSheet( }, 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( @@ -541,18 +830,14 @@ fun PeopleSection( displayName = displayName, isDirect = isDirectLive, isWifiAware = peerID in wifiAwarePeerIDs, + isConnected = true, isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer, isFavorite = isFavorite, theyFavoritedUs = theyFavoritedUs, isVerified = isVerified, - 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 @@ -591,18 +876,14 @@ fun PeopleSection( peerID = favPeerID, displayName = dn, isDirect = false, + isConnected = false, isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, theyFavoritedUs = fav.theyFavoritedUs, isVerified = isVerified, - hasUnreadDM = hasUnread, colorScheme = colorScheme, viewModel = viewModel, onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, - onToggleFavorite = { - Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") - viewModel.toggleFavorite(favPeerID) - }, unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), showHashSuffix = showHash @@ -612,149 +893,592 @@ fun PeopleSection( } } } + } } @Composable -private fun UnreadDirectMessagesSection( - conversations: List, +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 palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme - - Column(modifier = modifier) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_envelope, - title = stringResource(R.string.cd_unread_private_messages) + 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 + ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = colorScheme.surface, - shape = AboutCardShape - ) { - AnimatedRowColumn( - items = conversations, - key = { it.conversationID } - ) { index, conversation -> - Column { - if (index > 0) SheetCardDivider() + LaunchedEffect(dismissState.currentValue, conversation.conversationID) { + when (dismissState.currentValue) { + SwipeToDismissBoxValue.StartToEnd -> { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onReadStateRequested(conversation, conversation.unreadCount > 0) + dismissState.reset() + } - val subtitle = when { - conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}" - conversation.transport == DirectMessageTransport.NOSTR -> - stringResource(R.string.cd_reachable_via_nostr) - !conversation.isConnected -> - stringResource(R.string.cd_offline_mesh_chat) - else -> null - } - val peerIdentity = conversation.nostrPubkey - ?.let(viewModel::peerIdentityForNostrPubkey) - ?: viewModel.peerIdentityForMeshPeer(conversation.conversationID) - val assignedColor = colorForPeer(peerIdentity, palette) - val (baseNameRaw, suffix) = splitSuffix(conversation.displayName) + 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 - .fillMaxWidth() - .clickable { - onPrivateChatStart(conversation.conversationID) - } - .padding( - horizontal = SheetRowHorizontal, - vertical = SheetRowVertical - ), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier.size(SheetRowLeadingSlot), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_envelope), - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentOrange - ) - } - - Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) - - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = truncateNickname(baseNameRaw), - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = assignedColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (suffix.isNotEmpty()) { - Text( - text = suffix, - fontFamily = BitchatFontFamily, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = assignedColor.copy(alpha = SUFFIX_ALPHA) - ) + .fillMaxSize() + .background( + if (startToEnd) { + colorScheme.secondaryContainer + } else { + colorScheme.errorContainer } - } - - if (subtitle != null) { - Text( - text = subtitle, - fontFamily = BitchatFontFamily, - fontSize = 11.sp, - color = palette.textTertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + ) + .padding(horizontal = SheetRowHorizontal), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (startToEnd) { + Arrangement.Start + } else { + Arrangement.End } - - UnreadBadge( - count = conversation.unreadCount, - colorScheme = colorScheme + ) { + 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, - hasUnreadDM: Boolean, colorScheme: ColorScheme, viewModel: ChatViewModel, onItemClick: () -> Unit, - onToggleFavorite: () -> 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) @@ -773,61 +1497,48 @@ private fun PeerItem( modifier = Modifier .fillMaxWidth() .clickable(onClick = onItemClick) - .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + .padding(horizontal = SheetRowHorizontal, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier.size(SheetRowLeadingSlot), - contentAlignment = Alignment.Center - ) { - if (isSelected) { - Box( - modifier = Modifier - .size(SheetRowSelectedDot) - .background(colorScheme.primary, CircleShape) - ) - } else if (hasUnreadDM) { - Icon( - painter = painterResource(R.drawable.ic_spec_envelope), - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentOrange - ) - } else if (showNostrGlobe) { - Icon( - painter = painterResource(R.drawable.ic_spec_globe), - contentDescription = stringResource(R.string.cd_reachable_via_nostr), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentPurple - ) - } else if (!isDirect && isFavorite) { - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textTertiary - ) - } else { - Icon( - painter = painterResource( - conversationTransportIcon( - isReachedOverInternet = false, - isWifiAware = isWifiAware, - isDirect = isDirect - ) - ), - contentDescription = when { - isWifiAware -> "Direct Wi-Fi Aware" - isDirect -> "Direct Bluetooth" - else -> "Routed" - }, - modifier = Modifier.size(PeerRowIconSize), - tint = colorScheme.onSurfaceVariant - ) - } - } + 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 + ) - Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + 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), @@ -853,37 +1564,37 @@ private fun PeerItem( color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - - if (isVerified) { - Icon( - painter = painterResource(R.drawable.ic_spec_check), - contentDescription = stringResource(R.string.verify_title), - modifier = Modifier.size(16.dp), - tint = colorScheme.primary - ) - } } - Box( - modifier = Modifier - .size(36.dp) - .clickable(onClick = onToggleFavorite), - contentAlignment = Alignment.Center - ) { - // Three-state star (matches private-chat header): grey outline (no relation), - // orange outline (they favorited us), filled orange (we favorited them). - Icon( - painter = painterResource( - if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star - ), - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(PeerRowIconSize), - tint = if (isFavorite || theyFavoritedUs) palette.accentOrange else palette.textTertiary + 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 */ @@ -1028,7 +1739,11 @@ fun PrivateChatSheet( val conversationID = contactResolution.conversationID val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList() - val sessionState = activeMeshPeerID?.let { peerSessionStates[it] } ?: peerSessionStates[peerID] + val sessionState = resolveConversationSessionState( + conversationID = peerID, + activeMeshPeerID = activeMeshPeerID, + peerSessionStates = peerSessionStates + ) val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] } ?: peerFingerprints[peerID] ?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID) @@ -1121,10 +1836,10 @@ fun PrivateChatSheet( // Input section. No divider here: ChatInputSection draws its own fade and // hairline. - var messageText by remember { + var messageText by remember(peerID) { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( - "" + viewModel.conversationDraft(peerID) ) ) } @@ -1133,13 +1848,21 @@ fun PrivateChatSheet( messageText = messageText, onMessageTextChange = { newText -> messageText = newText - viewModel.updateMentionSuggestions(newText.text) + 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()) - messageText = androidx.compose.ui.text.input.TextFieldValue("") - forceScrollToBottom = !forceScrollToBottom + viewModel.sendMessage(messageText.text.trim()) { accepted -> + if (accepted) { + messageText = + androidx.compose.ui.text.input.TextFieldValue("") + viewModel.setConversationDraft(peerID, "") + forceScrollToBottom = !forceScrollToBottom + } + } } }, onSendVoiceNote = { peer, channel, path -> @@ -1151,6 +1874,7 @@ fun PrivateChatSheet( onSendFileNote = { peer, channel, path -> viewModel.sendFileNote(peer, channel, path) }, + recorderFactory = viewModel::createVoiceRecorder, showCommandSuggestions = false, commandSuggestions = emptyList(), showMentionSuggestions = false, @@ -1214,8 +1938,21 @@ fun PrivateChatSheet( ) } - // Encryption state, and the verification badge that qualifies it. Both are - // read-only for Nostr peers, which have no Noise session at all. + 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() }, @@ -1230,20 +1967,6 @@ fun PrivateChatSheet( } } - if (isVerified) { - Box( - modifier = Modifier.size(HeaderIconSize), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_check), - contentDescription = stringResource(R.string.verify_title), - modifier = Modifier.size(HeaderIconSize), - tint = colorScheme.primary - ) - } - } - 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 e28f37eb..e5e36fc2 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,8 +1,14 @@ 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.AnimatedContent +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FiniteAnimationSpec @@ -10,12 +16,11 @@ 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.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith 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.Arrangement import androidx.compose.foundation.layout.Box @@ -25,9 +30,12 @@ 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.LazyListState @@ -35,12 +43,15 @@ 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.Surface 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 @@ -53,14 +64,21 @@ 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.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 com.bitchat.android.ui.theme.BitchatFontFamily @@ -73,10 +91,12 @@ import com.bitchat.android.model.DeliveryStatus 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.Locale @@ -213,6 +233,10 @@ fun MessagesList( 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. // @@ -342,6 +366,7 @@ fun MessagesList( meshService = meshService, mentionPeerIdentities = resolvedMentionPeerIdentities, showSender = !isGrouped, + bubbles = bubbles.isBubbles, topSpacing = MessageGrouping.topSpacingFor( isGrouped = isGrouped, isFirstInList = originalIndex == 0 @@ -375,6 +400,7 @@ fun MessageItem( messages: List = emptyList(), mentionPeerIdentities: Map = emptyMap(), showSender: Boolean = true, + bubbles: Boolean = false, topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, @@ -424,6 +450,68 @@ fun MessageItem( } } + 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 } } @@ -439,6 +527,7 @@ fun MessageItem( colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, showSender: Boolean, + bubbles: Boolean = false, onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, @@ -457,6 +546,7 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -475,6 +565,7 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -495,7 +586,15 @@ fun MessageItem( } else -> null to null } - Column(modifier = modifier.fillMaxWidth()) { + 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, @@ -541,7 +640,14 @@ fun MessageItem( null } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) { + Arrangement.End + } else { + Arrangement.Start + } + ) { Box { if (packet != null) { if (overrideProgress != null) { @@ -594,6 +700,25 @@ fun MessageItem( 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) { @@ -631,6 +756,7 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, + bubbles = bubbles, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, @@ -650,6 +776,7 @@ internal fun TextMessageLayout( onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, showSender: Boolean = true, + bubbles: Boolean = false, bodyContent: String = message.content, ) { val palette = LocalBitchatPalette.current @@ -665,6 +792,14 @@ internal fun TextMessageLayout( palette = palette, ) } + val isSelf = message.isFromSelf(currentUserNickname, myPeerId) + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + 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. The body is built further // below against the bubble's own content color (see resolvedBodyText). @@ -696,13 +831,18 @@ internal fun TextMessageLayout( // Rebuild the body against the bubble's own content color so self-message text stays legible // on the tinted primary container rather than defaulting to the surface's onSurface tone. val resolvedBodyText = remember( + // 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, bubbleContentColor, colorScheme.secondary, mentionPeerIdentities, - timeFormatter + timeFormatter, + bubbles, + isSelf ) { formatTextMessageBody( message = displayMessage, @@ -712,9 +852,25 @@ internal fun TextMessageLayout( 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), @@ -797,43 +953,415 @@ internal fun TextMessageLayout( } } +/** + * 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 + } + ) + 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 + 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", + ) - // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker. - // Keyed on the status *type* rather than the instance, because Delivered/Read carry a - // timestamp that would otherwise retrigger the transition on every identical update. - AnimatedContent( - targetState = status::class, - transitionSpec = { - fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith - fadeOut(tween(BitchatMotion.QUICK_MS)) - }, - label = "deliveryStatus" - ) { statusClass -> - val (text, color, weight) = when (statusClass) { - DeliveryStatus.Sending::class -> - Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) - // Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity). - DeliveryStatus.Sent::class -> - Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) - DeliveryStatus.Delivered::class -> - Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) - DeliveryStatus.Read::class -> - Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold) - DeliveryStatus.Failed::class -> - Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal) - // A single subdued check, without the numeric label. - else -> - Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + // 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)) } - - Text( - text = stringResource(text), - fontSize = 10.sp, - color = color, - fontWeight = weight - ) } + + 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/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 41a610f4..31e7d1af 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -3,7 +3,6 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.services.ContactDirectory -import com.bitchat.android.services.PrivateMessageArrivalOrder import java.util.* import java.util.Collections @@ -103,6 +102,49 @@ class MessageManager(private val state: ChatState) { 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(conversationID)) { currentPrivateChats[conversationID] = mutableListOf() @@ -111,14 +153,16 @@ class MessageManager(private val state: ChatState) { val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() chatMessages.add(message) currentPrivateChats[conversationID] = chatMessages - // Record the local arrival sequence before canonicalizing UI aliases. - PrivateMessageArrivalOrder.record(message.id) state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) - // Reflect into process-wide store - try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } // Mark as unread if not currently viewing this chat - if (state.getSelectedPrivateChatPeerValue() != conversationID && 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(conversationID) state.setUnreadPrivateMessages(currentUnread) @@ -128,25 +172,29 @@ class MessageManager(private val state: ChatState) { // 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 conversationID = ContactDirectory.canonicalConversationId(peerID) - val currentPrivateChats = state.getPrivateChatsValue().toMutableMap() - if (!currentPrivateChats.containsKey(conversationID)) { - currentPrivateChats[conversationID] = mutableListOf() + val accepted = try { + com.bitchat.android.services.AppStateStore.addPrivateMessage( + peerID = conversationID, + msg = message, + forceRead = true + ) + } catch (_: Exception) { + false } - val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf() - chatMessages.add(message) - currentPrivateChats[conversationID] = chatMessages - // Record the local arrival sequence before canonicalizing UI aliases. - PrivateMessageArrivalOrder.record(message.id) - state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats)) - // Reflect into process-wide store - try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { } + 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[conversationID] = emptyList() + updatedChats.keys.removeAll { key -> + ContactDirectory.canonicalConversationId(key) + .equals(conversationID, ignoreCase = true) + } state.setPrivateChats(updatedChats) + clearPrivateUnreadMessages(conversationID) } fun initializePrivateChat(peerID: String) { @@ -239,12 +287,19 @@ class MessageManager(private val state: ChatState) { is DeliveryStatus.PartiallyDelivered -> 3 is DeliveryStatus.Delivered -> 4 is DeliveryStatus.Read -> 5 - is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering + is DeliveryStatus.Failed -> 0 } private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { - // Never downgrade (e.g., Read -> Delivered). Keep the higher priority. - return if (statusPriority(new) >= statusPriority(old)) new else old + // 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) { @@ -325,7 +380,10 @@ class MessageManager(private val state: ChatState) { changed = true } } - if (changed) state.setPrivateChats(chats) + if (changed) { + state.setPrivateChats(chats.filterValues { it.isNotEmpty() }) + com.bitchat.android.services.AppStateStore.removePrivateMessage(messageID) + } } // Channels run { 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 f15ada44..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,19 +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.service.ConversationNotificationReceiver import com.bitchat.android.services.ContactDirectory -import com.bitchat.android.util.NotificationIntervalManager +import com.bitchat.android.services.ConversationListPreferences +import java.util.Collections +import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap /** @@ -24,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 { @@ -42,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 = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS + private const val MAX_MESSAGES_IN_NOTIFICATION = 25 // Intent extras for notification handling const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat" @@ -51,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>() @@ -88,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) @@ -105,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) @@ -146,6 +181,10 @@ class NotificationManager( */ 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 != conversationID) @@ -176,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 @@ -219,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) { @@ -242,47 +271,110 @@ 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) { - val extra = messageCount - 5 - style.setSummaryText(context.resources.getQuantityString( - R.plurals.notification_and_more, extra, extra - )) - } - - 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 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 @@ -311,44 +403,12 @@ class NotificationManager( .setShowWhen(true) .setWhen(System.currentTimeMillis()) - notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build()) - } - - private fun showNotificationForActivePeers(peersSize: Int) { - // Create intent to open the app - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - - val pendingIntent = PendingIntent.getActivity( - context, - ACTIVE_PEERS_NOTIFICATION_ID, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + notifySafely( + (System.currentTimeMillis() and 0x7FFFFFFF).toInt(), + builder.build() ) - - // Build notification content - val contentTitle = context.getString(R.string.notification_active_peers_title) - val contentText = if (peersSize == 1) { - context.getString(R.string.notification_active_peers_one) - } else { - context.getString(R.string.notification_active_peers_many, peersSize) - } - - 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()) - - notificationManager.notify(ACTIVE_PEERS_NOTIFICATION_ID, builder.build()) - Log.d(TAG, "Displayed notification for $contentTitle with ID $ACTIVE_PEERS_NOTIFICATION_ID") } + private fun showSummaryNotification() { if (pendingNotifications.isEmpty()) return @@ -398,7 +458,7 @@ class NotificationManager( 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") } @@ -431,6 +491,15 @@ class NotificationManager( 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 + ) + } + /** * Show a notification for a geohash message with mention or first message */ @@ -559,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") } @@ -626,7 +695,7 @@ class NotificationManager( 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") } @@ -767,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") } @@ -799,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/OrientationAwareActivity.kt b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt index 82d8feaa..a3914c90 100644 --- a/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt @@ -2,14 +2,14 @@ package com.bitchat.android.ui import android.content.pm.ActivityInfo import android.os.Bundle -import androidx.activity.ComponentActivity +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 : ComponentActivity() { +abstract class OrientationAwareActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index ee28a534..12678625 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -134,6 +134,50 @@ class PrivateChatManager( 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 { @@ -375,6 +419,51 @@ class PrivateChatManager( } } + /** + * 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 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 index 40da30be..71e59f0d 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -48,6 +48,7 @@ import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.services.ContactDirectory private data class SecurityStatusInfo( val text: String, @@ -100,7 +101,12 @@ fun SecurityVerificationSheet( val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID) val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID) val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint) - val sessionState = peerSessionStates[selectedPeerID] + val activeMeshPeerID = ContactDirectory.resolve(selectedPeerID).meshPeerID + val sessionState = resolveConversationSessionState( + conversationID = selectedPeerID, + activeMeshPeerID = activeMeshPeerID, + peerSessionStates = peerSessionStates + ) val statusInfo = buildStatusInfo( isVerified = isVerified, sessionState = sessionState, diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c9922bf1..1545cd2c 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -1,18 +1,25 @@ 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 android.Manifest -import androidx.compose.foundation.gestures.detectTapGestures 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.res.stringResource 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 @@ -22,6 +29,7 @@ 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. @@ -54,29 +62,44 @@ private const val ReleaseTailMs = 500L @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, - onStart: () -> Unit, + /** + * 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, or the button being torn down mid-capture. The caller needs this to clear its own - * recording state; without it a failed capture left the composer stuck in recording mode. + * 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) } @@ -86,6 +109,9 @@ fun VoiceRecordButton( 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. @@ -107,9 +133,10 @@ fun VoiceRecordButton( ampJob = null if (isCapturing) { isCapturing = false - runCatching { recorder?.stop() } + runCatching { recorder?.stop(canceled = true) } recorder = null recordedFilePath = null + latestOnTrackFinger.value(null) latestOnCancel.value() } } @@ -117,102 +144,129 @@ fun VoiceRecordButton( // Same disc, same sizing and the same press feedback as the camera and send buttons. ComposerActionSurface( - isActive = isRecording || isCapturing, + 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) { - detectTapGestures( - onPress = { - // Guard 1: ignore anything arriving before the swap animation settled. - if (System.currentTimeMillis() - composedAt < ArmDelayMs) { - return@detectTapGestures - } - // Guard 2: never start a second capture on top of a live one. - if (isCapturing) return@detectTapGestures + 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@detectTapGestures - } + if (micPermission.status !is PermissionStatus.Granted) { + micPermission.launchPermissionRequest() + return@awaitEachGesture + } - // Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on - // release and false on cancellation; either way the press was not a hold, - // so nothing should happen. Only a timeout means the finger is still down. - val stillHeld = withTimeoutOrNull(HoldToRecordMs) { - tryAwaitRelease() - } == null - if (!stillHeld) return@detectTapGestures + // 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 = 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() } - latestOnCancel.value() - return@detectTapGestures - } + 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() - buzz() + 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) + 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 - recordedFilePath = 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) - } - } - - try { - tryAwaitRelease() - } finally { - if (isCapturing) { - // Keep going briefly past the release so the tail is not clipped. - delay(ReleaseTailMs) - } - if (isCapturing) { + if (elapsed >= MaxRecordingMs && isCapturing) { val file = recorder?.stop() isCapturing = false recorder = null - val path = file?.absolutePath ?: recordedFilePath + 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 } - ampJob?.cancel() - ampJob = null + 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( 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 index c646d570..0fc3b622 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -8,6 +8,8 @@ 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 @@ -23,6 +25,7 @@ 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( @@ -35,9 +38,14 @@ fun AudioMessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier, - showSender: Boolean = true + 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) { @@ -48,7 +56,56 @@ fun AudioMessageItem( } else -> null to null } - Column(modifier = modifier.fillMaxWidth()) { + // 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, @@ -78,10 +135,18 @@ fun AudioMessageItem( ) 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 + progressColor = overrideColor, ) val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered) if (showCancel) { 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 index 5d9908fc..5644e094 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -2,9 +2,11 @@ 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 @@ -43,11 +45,59 @@ fun ImageMessageItem( onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, modifier: Modifier = Modifier, - showSender: Boolean = true + showSender: Boolean = true, + bubbles: Boolean = false ) { val palette = LocalBitchatPalette.current val path = message.content.trim() - Column(modifier = modifier.fillMaxWidth()) { + // 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, @@ -57,7 +107,6 @@ fun ImageMessageItem( timeFormatter = timeFormatter, includeSender = showSender ) - val haptic = LocalHapticFeedback.current AnnotatedClickableText( text = headerText, annotationTags = listOf("nickname_click"), @@ -75,75 +124,93 @@ fun ImageMessageItem( color = colorScheme.onSurface, ) - 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() } - } - if (bmp != null) { - 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 - } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - Box { - if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) { - // Cyberpunk block-reveal while sending - BlockRevealImage( - bitmap = img, - progress = progressFraction, - blocksX = 24, - blocksY = 16, - modifier = Modifier - .widthIn(max = 300.dp) - .aspectRatio(aspect) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) - .clickable { - val currentIndex = imagePaths.indexOf(path) - onImageClick?.invoke(path, imagePaths, currentIndex) - } - ) - } else { - // Fully revealed image - Image( - bitmap = img, - contentDescription = stringResource(com.bitchat.android.R.string.cd_image), - modifier = Modifier - .widthIn(max = 300.dp) - .aspectRatio(aspect) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) - .clickable { - val currentIndex = imagePaths.indexOf(path) - onImageClick?.invoke(path, imagePaths, currentIndex) - }, - 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)) - } - } - } + 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/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/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 10109194..1f3f567e 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp @Composable fun VoiceNotePlayer( path: String, + modifier: Modifier = Modifier, progressOverride: Float? = null, progressColor: Color? = null ) { @@ -86,7 +87,7 @@ fun VoiceNotePlayer( DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } } Row( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { 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 index 9e51781a..10ab9e9e 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -37,18 +37,29 @@ internal object ChatVisualTokens { val SenderTopPadding: Dp = 8.dp val SenderToBodySpacing: Dp = 4.dp - // MARK: - Bubble geometry - /** Rounded corner used on the three "free" corners of a message bubble. */ + // 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 side, giving the bubble a subtle tail. */ + + /** Tightened corner on the speaker's own side, giving the bubble a subtle tail. */ val BubbleTailRadius: Dp = 4.dp - /** Horizontal / vertical padding inside a bubble, around the text. */ + + /** 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.82f - /** Background tint alpha for a received (non-self) bubble sitting on the chat surface. */ - const val BubbleSurfaceAlpha: Float = 0.65f + 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 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 index b8285321..40edde59 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -33,13 +33,13 @@ class ApkDownloadWorker( // 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 = "error" + const val KEY_ERROR_REASON = "error_reason" + const val KEY_ERROR_ARGS = "error_args" const val KEY_RESUMABLE_PERCENT = "resumable_percent" - private const val MAX_RETRIES = 3 - private const val CHANNEL_ID = "apk_download" private const val NOTIFICATION_ID = 4201 private const val NOTIFY_STEP_PERCENT = 5 @@ -50,6 +50,8 @@ class ApkDownloadWorker( 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") @@ -64,10 +66,20 @@ class ApkDownloadWorker( Log.w(TAG, "Could not promote download to foreground work", e) } - val result = apkManager.downloadUniversalApk { progress -> - setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build()) - updateNotification(progress) - } + 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() @@ -81,20 +93,36 @@ class ApkDownloadWorker( // Retry transient network errors with backoff; the partial file // is kept on disk, so the retry resumes where it left off. - val isRetryable = when (error) { - is GitHubReleaseClient.ReleaseFetchException -> error.retryable - is java.io.IOException -> true - else -> false - } - if (isRetryable && runAttemptCount < MAX_RETRIES) { - Log.w(TAG, "Transient download error (attempt $runAttemptCount), retrying", error) + 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() - .putString(KEY_ERROR, error?.message ?: "Download failed") + // 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) } @@ -118,16 +146,28 @@ class ApkDownloadWorker( } } + 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) - .setProgress(100, progress, progress <= 0) + // 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), @@ -136,8 +176,8 @@ class ApkDownloadWorker( .build() } - private fun updateNotification(progress: Int) { - if (progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return + 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)) @@ -149,13 +189,11 @@ class ApkDownloadWorker( } private fun ensureChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - applicationContext.getString(R.string.apk_download_channel_name), - NotificationManager.IMPORTANCE_LOW - ) - notificationManager.createNotificationChannel(channel) - } + 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 index 3bf234ae..01be6280 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -29,8 +29,83 @@ interface ApkDownloader { */ sealed class DownloadState { object Idle : DownloadState() - data class Downloading(val progressPercent: Int) : DownloadState() + data class Downloading( + val progressPercent: Int, + val phase: DownloadPhase = DownloadPhase.Transferring + ) : DownloadState() data class Success(val version: String, val sizeMB: Int) : DownloadState() - data class Failed(val message: String, val resumablePercent: 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() } -} \ No newline at end of file + + /** + * 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 index 0210f58c..b1822806 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -23,9 +23,6 @@ object AppConstants { const val CONNECTION_CLEANUP_INTERVAL_MS: Long = 30_000L const val BROADCAST_CLEANUP_DELAY_MS: Long = 500L - // GATT client RSSI updates - const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L - object Gatt { val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") @@ -125,7 +122,6 @@ object AppConstants { 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 ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_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" } diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt index 699637bd..d00122d8 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -1,338 +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.delay +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 -/** - * Client for fetching BitChat release information from GitHub API. - */ -object GitHubReleaseClient { - private const val TAG = "GitHubAPI" - private const val GITHUB_API_URL = "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest" - private const val USER_AGENT = "BitChat-Android" - private const val CACHE_TTL_MILLIS = 10 * 60 * 1000L - private const val MAX_FETCH_ATTEMPTS = 3 - private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L +internal interface LatestReleaseProvider { + suspend fun latestRelease(): Result +} - private val fetchMutex = Mutex() +/** 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" - @Volatile - private var cachedRelease: CachedRelease? = null + 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" + ) - private val client - get() = OkHttpProvider.httpClient().newBuilder() - // GitHub requests may travel through Tor, where a 15-second total - // timeout is too aggressive during circuit establishment. - .callTimeout(45, TimeUnit.SECONDS) - .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - /** - * Fetch the latest release information from GitHub. - * Successful metadata is cached briefly so the status screen and download - * worker use the same release snapshot instead of making duplicate calls. - */ - suspend fun fetchLatestRelease(forceRefresh: Boolean = false): Result = - withContext(Dispatchers.IO) { - fetchMutex.withLock { - if (!forceRefresh) { - cachedRelease - ?.takeIf { System.currentTimeMillis() - it.fetchedAtMillis < CACHE_TTL_MILLIS } - ?.let { return@withLock Result.success(it.release) } - } - - if (!awaitSelectedNetworkRoute()) { - return@withLock Result.failure( - ReleaseFetchException( - message = "Tor is still connecting. Try again when Tor is ready.", - retryable = true - ) - ) - } - - var lastFailure: Throwable = ReleaseFetchException( - "Failed to fetch the latest release from GitHub" - ) - - repeat(MAX_FETCH_ATTEMPTS) { attempt -> - val result = fetchLatestReleaseOnce() - result.onSuccess { release -> - cachedRelease = CachedRelease(release, System.currentTimeMillis()) - return@withLock Result.success(release) - } - lastFailure = result.exceptionOrNull() ?: lastFailure - - if (!isRetryable(lastFailure) || attempt == MAX_FETCH_ATTEMPTS - 1) { - return@withLock Result.failure(lastFailure) - } - - delay(1_000L shl attempt) - } - - Result.failure(lastFailure) - } - } - - /** - * Wait for Tor when it is the selected route. This deliberately does not - * fall back to a direct connection because doing so would violate the - * user's Tor preference. - */ - suspend fun awaitSelectedNetworkRoute(): Boolean { - return ArtiTorManager.getInstance() - .awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS) - } - - private fun fetchLatestReleaseOnce(): Result { - return try { - Log.d(TAG, "Fetching latest release from GitHub API") - val request = Request.Builder() - .url(GITHUB_API_URL) - .addHeader("User-Agent", USER_AGENT) - .addHeader("Accept", "application/vnd.github+json") - .addHeader("X-GitHub-Api-Version", "2022-11-28") - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - val remaining = response.header("X-RateLimit-Remaining") - val resetAt = response.header("X-RateLimit-Reset") - val message = when { - response.code == 403 && remaining == "0" -> - "GitHub API rate limit exceeded. Try again after reset time $resetAt." - response.code == 429 -> - "GitHub API rate limit exceeded. Please try again later." - else -> - "GitHub release request failed: HTTP ${response.code} ${response.message}" - } - Log.e(TAG, message) - return Result.failure( - ReleaseFetchException( - message = message, - httpCode = response.code, - retryable = response.code == 403 || - response.code == 408 || - response.code == 429 || - response.code >= 500 - ) - ) - } - - val body = response.body?.string() - if (body.isNullOrBlank()) { - return Result.failure( - ReleaseFetchException( - message = "GitHub returned an empty response", - retryable = true - ) - ) - } - - val release = parseRelease(body) - ?: return Result.failure( - ReleaseFetchException( - message = "GitHub's latest release has no universal APK asset", - retryable = false - ) - ) - Result.success(release) - } - } catch (e: IOException) { - Log.e(TAG, "Network error fetching release", e) - Result.failure( - ReleaseFetchException( - "Could not reach GitHub${e.message?.let { ": $it" } ?: ""}", - cause = e - ) - ) - } catch (e: Exception) { - Log.e(TAG, "Error fetching release", e) - Result.failure(ReleaseFetchException("Invalid GitHub release response", cause = e)) - } - } - - private fun isRetryable(error: Throwable): Boolean { - return error !is ReleaseFetchException || error.retryable - } - - /** - * Parse GitHub API JSON response into Release object. - */ - internal fun parseRelease(jsonString: String): Release? { - try { + internal fun parseRelease(jsonString: String): Release? = runCatching { val json = JSONObject(jsonString) - val tagName = json.optString("tag_name", "") - val versionName = tagName.removePrefix("v") // Remove "v" prefix if present - - if (versionName.isBlank()) { - Log.e(TAG, "No version tag found in release") - return null - } - - Log.d(TAG, "Found release: $versionName") - - // Parse assets array to find universal APK - val assets = json.optJSONArray("assets") - if (assets == null || assets.length() == 0) { - Log.e(TAG, "No assets found in release") - return null - } - - // Look for universal APK (usually named "app-universal-release.apk") - for (i in 0 until assets.length()) { - val asset = assets.getJSONObject(i) - val name = asset.optString("name", "") - - if (name.contains("universal", ignoreCase = true) && name.endsWith(".apk")) { - val downloadUrl = asset.optString("browser_download_url", "") - val size = asset.optLong("size", 0L) - - if (downloadUrl.isBlank()) { - Log.e(TAG, "Universal APK found but no download URL") - continue - } - - // Prefer GitHub's asset digest when available, then fall - // back to release notes used by older releases. - val body = json.optString("body", "") - val assetDigest = asset.optString("digest", "") - .takeIf { it.startsWith("sha256:", ignoreCase = true) } - ?.substringAfter(":") - ?.takeIf { it.matches(Regex("[a-fA-F0-9]{64}")) } - ?.lowercase() - val sha256 = assetDigest ?: extractSha256FromBody(body, name) - - Log.d(TAG, "Found universal APK: $name (${size / 1024 / 1024}MB)") + 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( - tagName = tagName, versionName = versionName, - universalApkUrl = downloadUrl, - universalApkSha256 = sha256, - universalApkSize = size, + universalApkSize = asset.optLong("size", 0L), + universalApkUrl = url, universalApkName = name ) } } - - Log.e(TAG, "No universal APK found in release assets") - return null - - } catch (e: Exception) { - Log.e(TAG, "Error parsing release JSON", e) - return null - } + null + }.getOrNull() } - /** - * Extract SHA256 checksum from release body/notes. - * Looks for patterns like: - * - sha256:abc123... - * - SHA256: abc123... - * - app-universal-release.apk: abc123... - */ - private fun extractSha256FromBody(body: String, apkName: String): String? { - if (body.isBlank()) return null + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val mutex = Mutex() - try { - // Pattern 1: Look for "sha256:" followed by hash - val sha256Pattern = Regex("""sha256:\s*([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) - sha256Pattern.find(body)?.let { match -> - return match.groupValues[1].lowercase() + 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)) } - // Pattern 2: Look for APK name followed by hash - val apkPattern = Regex("""${Regex.escape(apkName)}.*?([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) - apkPattern.find(body)?.let { match -> - return match.groupValues[1].lowercase() + 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) + ) } - Log.w(TAG, "Could not extract SHA256 from release body") - return null + 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() - } catch (e: Exception) { - Log.w(TAG, "Error extracting SHA256", e) - return null - } - } + 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) + } - /** - * Check if a newer version is available. - * @param currentVersion Current installed/cached version - * @param latestRelease Latest release from GitHub - * @return true if latestRelease is newer - */ - fun isNewerVersion(currentVersion: String, latestRelease: Release): Boolean { - return isNewerVersion(currentVersion, latestRelease.versionName) - } - - internal fun isNewerVersion(currentVersion: String, candidateVersion: String): Boolean { - return try { - // Simple version comparison (assumes semantic versioning) - // Remove any non-numeric prefixes - val current = currentVersion.removePrefix("v").trim() - val latest = candidateVersion.removePrefix("v").trim() - - if (current == latest) { - return false - } - - // Split by dots and compare each part - val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } - val latestParts = latest.split(".").mapNotNull { it.toIntOrNull() } - - val maxLength = maxOf(currentParts.size, latestParts.size) - - for (i in 0 until maxLength) { - val currentPart = currentParts.getOrNull(i) ?: 0 - val latestPart = latestParts.getOrNull(i) ?: 0 - - if (latestPart > currentPart) { - return true - } else if (latestPart < currentPart) { - return false + 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) } - - false - } catch (e: Exception) { - Log.e(TAG, "Error comparing versions", e) - false } } - /** - * Release information from GitHub. - */ + 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 tagName: String, val versionName: String, - val universalApkUrl: String, - val universalApkSha256: String?, val universalApkSize: Long, + val universalApkUrl: String, val universalApkName: String ) - class ReleaseFetchException( - message: String, - val httpCode: Int? = null, - val retryable: Boolean = true, - cause: Throwable? = null - ) : IOException(message, cause) + 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 index 1427e0e0..8480fc27 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -5,9 +5,11 @@ 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 @@ -26,7 +28,15 @@ import java.security.MessageDigest /** * Manages local and downloaded APK artifacts for offline sharing. */ -class UniversalApkManager(private val context: Context) { +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" @@ -34,6 +44,8 @@ class UniversalApkManager(private val context: Context) { 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 @@ -44,14 +56,19 @@ class UniversalApkManager(private val context: Context) { 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 Tor proxy settings but with no call timeout - // for large file downloads that can take minutes - private val downloadClient - get() = OkHttpProvider.httpClient().newBuilder() - .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) - .build() + // 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. @@ -64,13 +81,16 @@ class UniversalApkManager(private val context: Context) { val json = JSONObject(metadataFile.readText()) val version = json.optString("version", "") - val checksum = json.optString("checksum", "") val downloadDate = json.optLong("downloadDate", 0L) val size = json.optLong("size", 0L) val fileName = json.optString("fileName", "") - val source = runCatching { - ApkSource.valueOf(json.optString("source", ApkSource.GITHUB.name)) - }.getOrDefault(ApkSource.GITHUB) + 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 @@ -91,12 +111,12 @@ class UniversalApkManager(private val context: Context) { ApkInfo( version = version, - checksum = checksum, downloadDate = downloadDate, size = size, file = apkFile, source = source, - variant = variant + variant = variant, + downloadSourceId = downloadSourceId ) } catch (e: Exception) { Log.e(TAG, "Error reading cached APK info", e) @@ -116,10 +136,10 @@ class UniversalApkManager(private val context: Context) { * Returns the progress percentage (0-100) or null if no partial download. */ fun getPartialDownloadProgress(): Int? { - val tempFile = File(cacheDir, "download_temp.apk") + val tempFile = File(cacheDir, TEMP_FILE_NAME) val resumeInfo = loadResumeInfo() if (tempFile.exists() && resumeInfo != null) { - val expectedSize = resumeInfo.optLong("expectedSize", 0L) + val expectedSize = resumeInfo.expectedSize if (expectedSize > 0) { return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99) } @@ -128,58 +148,12 @@ class UniversalApkManager(private val context: Context) { } /** - * Check for updates from GitHub. - * @return UpdateStatus indicating if update is available, current version, etc. + * 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 checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) { - try { - // A supported standalone APK is already an installable sharing - // artifact. Split installs still need the universal GitHub artifact. - val installedApkInfo = cacheInstalledApkIfPreferred() - if (installedApkInfo?.source == ApkSource.INSTALLED) { - return@withContext UpdateStatus.UpToDate(installedApkInfo.version) - } - - val cachedInfo = getCachedApkInfo() - val latestRelease = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> - return@withContext UpdateStatus.Error( - error.message ?: "Failed to fetch latest release from GitHub" - ) - } - // The GitHub release may briefly lag behind the installed version - // (upstream bumps versionName in main before tagging the release). - // An older release is still a genuine, signed, universal artifact — - // recipients with a newer install can't be downgraded by Android - // anyway — so share it rather than disabling the feature. - if (isOlderThanInstalledVersion(latestRelease.versionName)) { - Log.i( - TAG, - "GitHub universal APK ${latestRelease.versionName} is older than installed " + - "app ${installedVersionName()}; sharing it until the matching release ships" - ) - } - - if (cachedInfo == null) { - // No cached APK - return@withContext UpdateStatus.NotDownloaded(latestRelease) - } - - // Compare versions - val isNewer = GitHubReleaseClient.isNewerVersion(cachedInfo.version, latestRelease) - - if (isNewer) { - UpdateStatus.UpdateAvailable( - currentVersion = cachedInfo.version, - latestRelease = latestRelease - ) - } else { - UpdateStatus.UpToDate(cachedInfo.version) - } - - } catch (e: Exception) { - Log.e(TAG, "Error checking for update", e) - UpdateStatus.Error(e.message ?: "Unknown error") - } + suspend fun prepareLocalApkInfo(): ApkInfo? = withContext(Dispatchers.IO) { + cacheInstalledApkIfPreferred() ?: getCachedApkInfo() } /** @@ -196,248 +170,393 @@ class UniversalApkManager(private val context: Context) { val availableMB = availableSpace / 1024 / 1024 val error = "Insufficient storage: need ${requiredMB}MB, have ${availableMB}MB" Log.e(TAG, error) - throw IOException(error) + throw ApkDownloadException( + message = error, + reason = ApkDownloadFailureReason.InsufficientStorage, + messageArgs = listOf(requiredMB.toString(), availableMB.toString()), + retryable = false + ) } } /** - * Download the universal APK from GitHub with resume support. - * @param progressCallback Called with progress percentage (0-100) - * @return Result with File on success, or error message + * 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 + progressCallback: ((Int) -> Unit)? = null, + phaseCallback: ((ApkDownloader.DownloadPhase) -> Unit)? = null ): Result = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Starting universal APK download") - - // Fetch latest release info - // Reuses the short-lived release metadata cache populated by the - // status check. If this worker is running after process death, the - // client performs a retried network fetch instead. - val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> - return@withContext Result.failure(error) - } - - if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) { - return@withContext Result.failure( - IOException("Tor is still connecting. Try the download again when Tor is ready.") + if (downloadSources.isEmpty()) { + return@withContext Result.failure( + ApkDownloadException( + message = "No APK download sources are configured.", + reason = ApkDownloadFailureReason.NoSources, + retryable = false ) - } - - val url = release.universalApkUrl - val expectedSize = release.universalApkSize - - Log.d(TAG, "Downloading from: $url") - Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB") - - val tempFile = File(cacheDir, "download_temp.apk") - - // Check for resumable download - var existingBytes = 0L - if (tempFile.exists()) { - val resumeInfo = loadResumeInfo() - if (resumeInfo != null && - resumeInfo.optString("url") == url && - resumeInfo.optString("versionName") == release.versionName - ) { - existingBytes = tempFile.length() - Log.d(TAG, "Resuming download from $existingBytes bytes") - } else { - Log.d(TAG, "Stale temp file found, starting fresh") - tempFile.delete() - progressFile.delete() - } - } - - // Bytes already in the temp file have already consumed storage, so - // a resume only needs room for the remaining tail. Promotion is a - // rename and needs no extra space. - checkDiskSpace((expectedSize - existingBytes).coerceAtLeast(0)) - - // A temp file that already holds the full asset means the process - // died between download and verification. Requesting - // "Range: bytes=-" for it would get HTTP 416 forever, so skip - // the network and let checksum/signature verification decide its fate. - if (expectedSize > 0 && existingBytes >= expectedSize) { - Log.d(TAG, "Temp file already complete ($existingBytes bytes), skipping to verification") - } else { - val requestBuilder = Request.Builder() - .url(url) - .addHeader("User-Agent", "BitChat-Android") - - if (existingBytes > 0) { - requestBuilder.addHeader("Range", "bytes=$existingBytes-") - Log.d(TAG, "Added Range header: bytes=$existingBytes-") - } - - val request = requestBuilder.build() - downloadToTempFile( - call = downloadClient.newCall(request), - tempFile = tempFile, - url = url, - expectedSize = expectedSize, - versionName = release.versionName, - existingBytes = existingBytes, - progressCallback = progressCallback - ) - } - - // Verify checksum if available - if (release.universalApkSha256 != null) { - Log.d(TAG, "Verifying checksum...") - val isValid = verifyChecksum(tempFile, release.universalApkSha256) - if (!isValid) { - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - Exception("Checksum verification failed. Downloaded file may be corrupted.") - ) - } - Log.d(TAG, "Checksum verified successfully") - } else { - Log.w(TAG, "No checksum available for verification") - } - - // Verify the downloaded APK against trusted signing certificates. - Log.d(TAG, "Verifying APK signature...") - if (!verifyApkSignature(tempFile)) { - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - Exception("APK signature verification failed. The downloaded APK is not signed by a trusted BitChat release key.") - ) - } - Log.d(TAG, "Signature verified successfully") - - if (!DistributionInfoProvider.isUniversalApk(tempFile)) { - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - Exception( - "GitHub asset is architecture-specific, not universal. " + - "Release packaging must be corrected." - ) - ) - } - - // Move to final location without deleting the currently usable APK - // first. Old versions are removed only after the replacement and - // metadata have both been committed. - val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk" - val finalFile = File(cacheDir, finalFileName) - replaceFileSafely(tempFile, finalFile) - - // Clean up resume metadata on success - progressFile.delete() - - // Save metadata - saveMetadata( - version = release.versionName, - checksum = release.universalApkSha256 ?: "", - size = finalFile.length(), - fileName = finalFileName, - source = ApkSource.GITHUB, - variant = ShareableApkVariant.UNIVERSAL ) - cleanupOldApks(except = finalFile) + } - Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}") - Result.success(finalFile) + 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: IOException) { - Log.e(TAG, "Network error downloading APK", e) - Result.failure(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] while keeping the coroutine - * suspended for the lifetime of the response body. Cancelling the worker - * therefore cancels the OkHttp call and promptly unblocks a pending read. + * 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, - url: String, - expectedSize: Long, - versionName: String, existingBytes: Long, + previousResume: ResumeInfo?, progressCallback: ((Int) -> Unit)? ) = suspendCancellableCoroutine { continuation -> fun completeSuccessfully() { - continuation.resumeWith(Result.success(Unit)) + if (continuation.isActive) continuation.resumeWith(Result.success(Unit)) } fun completeWithError(error: Throwable) { - continuation.resumeWith(Result.failure(error)) + if (continuation.isActive) continuation.resumeWith(Result.failure(error)) } - continuation.invokeOnCancellation { - call.cancel() - } + continuation.invokeOnCancellation { call.cancel() } try { call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - completeWithError(e) + 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) { - // Our offset is no longer valid for this asset; discard - // the partial state so the retry starts from scratch. - Log.w(TAG, "Server rejected resume range, restarting download") - tempFile.delete() - progressFile.delete() - throw IOException( - "Resume rejected by server. Download will restart." + 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 && response.code != 206) { - throw IOException( - "Download failed: ${response.code} ${response.message}" + 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 - ?: throw IOException("Empty response body") - - // Handle resume: 206 = partial content (append), 200 = full - // content (overwrite). - val append = response.code == 206 - val resumedBytes = if (!append && existingBytes > 0) { - Log.d( - TAG, - "Server didn't honor Range request, starting from scratch" - ) - 0L + val range = if (response.code == 206) { + parseContentRange(response.header("Content-Range")) + ?: throw invalidResumeResponse(source, tempFile) } else { - existingBytes + null + } + if (range != null && range.start != existingBytes) { + throw invalidResumeResponse(source, tempFile) } - saveResumeInfo(url, expectedSize, versionName) + 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)) + } - if (resumedBytes > 0 && expectedSize > 0) { - val initialProgress = - ((resumedBytes * 100) / expectedSize).toInt() - progressCallback?.invoke(initialProgress) + 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 -> - FileOutputStream(tempFile, append).use { output -> + // 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 > 0) { - ((resumedBytes * 100) / expectedSize).toInt() + var lastProgress = if (expectedSize > 0L) { + ((resumedBytes * 100L) / expectedSize).toInt() } else { 0 } @@ -445,23 +564,29 @@ class UniversalApkManager(private val context: Context) { while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) totalBytesRead += bytesRead - - if (expectedSize > 0) { - val progress = - ((totalBytesRead * 100) / expectedSize).toInt() + if (expectedSize > 0L) { + val progress = ( + (totalBytesRead * 100L) / expectedSize + ).toInt().coerceIn(0, 100) if (progress != lastProgress) { lastProgress = progress progressCallback?.invoke(progress) } } } - - Log.d( - TAG, - "Download complete: ${totalBytesRead / 1024 / 1024}MB" - ) } } + + 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) { @@ -474,6 +599,106 @@ class UniversalApkManager(private val context: Context) { } } + 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. @@ -502,7 +727,7 @@ class UniversalApkManager(private val context: Context) { // 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.GITHUB && + cachedInfo?.source == ApkSource.DOWNLOADED && cachedInfo.variant == ShareableApkVariant.UNIVERSAL ) { return cachedInfo @@ -510,9 +735,9 @@ class UniversalApkManager(private val context: Context) { // 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 GitHub release. + // silently downgrade recipients to an older downloadable release. if (cachedInfo != null && - !GitHubReleaseClient.isNewerVersion(cachedInfo.version, installedVersion) + !AppVersion.isNewer(cachedInfo.version, installedVersion) ) { return cachedInfo } @@ -534,14 +759,13 @@ class UniversalApkManager(private val context: Context) { } replaceFileSafely(pendingFile, finalFile) - val checksum = calculateChecksum(finalFile) saveMetadata( version = installedVersion, - checksum = checksum, size = finalFile.length(), fileName = finalFileName, source = ApkSource.INSTALLED, - variant = installedVariant + variant = installedVariant, + downloadSourceId = null ) cleanupOldApks(except = finalFile) @@ -561,15 +785,10 @@ class UniversalApkManager(private val context: Context) { ?: BuildConfig.VERSION_NAME } - private fun isOlderThanInstalledVersion(candidateVersion: String): Boolean { - return GitHubReleaseClient.isNewerVersion(candidateVersion, installedVersionName()) - } - /** * Verify the downloaded APK against either the running app's signing lineage - * or the pinned GitHub release certificate. The latter supports Play installs - * when GitHub distribution uses a separate, explicitly trusted release key. - * Debug builds without a configured pin accept any signed (never unsigned) APK. + * 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 { @@ -587,6 +806,8 @@ class UniversalApkManager(private val context: Context) { 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 ) @@ -595,7 +816,7 @@ class UniversalApkManager(private val context: Context) { // 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 GitHub release certificate. + // 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 @@ -656,39 +877,6 @@ class UniversalApkManager(private val context: Context) { .takeIf { it.matches(Regex("[a-f0-9]{64}")) } } - /** - * Verify the SHA256 checksum of a file. - */ - suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) { - try { - val checksum = calculateChecksum(file) - val matches = checksum.equals(expectedSha256, ignoreCase = true) - - if (!matches) { - Log.e(TAG, "Checksum mismatch!") - Log.e(TAG, "Expected: $expectedSha256") - Log.e(TAG, "Actual: $checksum") - } - - matches - } catch (e: Exception) { - Log.e(TAG, "Error verifying checksum", e) - false - } - } - - private fun calculateChecksum(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - while (input.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } - } - /** * Delete the cached universal APK. */ @@ -735,20 +923,20 @@ class UniversalApkManager(private val context: Context) { */ private fun saveMetadata( version: String, - checksum: String, size: Long, fileName: String, source: ApkSource, - variant: ShareableApkVariant + variant: ShareableApkVariant, + downloadSourceId: String? ) { val json = JSONObject().apply { put("version", version) - put("checksum", checksum) 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") @@ -757,12 +945,13 @@ class UniversalApkManager(private val context: Context) { Log.d(TAG, "Saved metadata: $version") } - private fun saveResumeInfo(url: String, expectedSize: Long, versionName: String) { + private fun saveResumeInfo(info: ResumeInfo) { try { val json = JSONObject().apply { - put("url", url) - put("expectedSize", expectedSize) - put("versionName", versionName) + 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) { @@ -770,17 +959,33 @@ class UniversalApkManager(private val context: Context) { } } - private fun loadResumeInfo(): JSONObject? { + private fun loadResumeInfo(): ResumeInfo? { return try { - if (progressFile.exists()) { - JSONObject(progressFile.readText()) - } else null + 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 @@ -809,29 +1014,16 @@ class UniversalApkManager(private val context: Context) { */ data class ApkInfo( val version: String, - val checksum: String, val downloadDate: Long, val size: Long, val file: File, val source: ApkSource, - val variant: ShareableApkVariant + val variant: ShareableApkVariant, + val downloadSourceId: String? ) enum class ApkSource { INSTALLED, - GITHUB - } - - /** - * Update check status. - */ - sealed class UpdateStatus { - data class NotDownloaded(val latestRelease: GitHubReleaseClient.Release) : UpdateStatus() - data class UpToDate(val currentVersion: String) : UpdateStatus() - data class UpdateAvailable( - val currentVersion: String, - val latestRelease: GitHubReleaseClient.Release - ) : UpdateStatus() - data class Error(val message: String) : UpdateStatus() + 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 index feb31172..6d482c34 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -3,7 +3,6 @@ package com.bitchat.android.util import android.content.Context import androidx.work.Constraints import androidx.work.BackoffPolicy -import com.bitchat.android.R import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder @@ -59,13 +58,21 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { return when (workInfo.state) { WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> { - // Waiting for constraints (network). Show existing partial progress if any. + // 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) + ApkDownloader.DownloadState.Downloading( + partial ?: 0, + queuedPhase(workInfo.runAttemptCount) + ) } WorkInfo.State.RUNNING -> { val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0) - ApkDownloader.DownloadState.Downloading(progress) + 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) ?: "" @@ -73,16 +80,28 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { ApkDownloader.DownloadState.Success(version, sizeMB) } WorkInfo.State.FAILED -> { - val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download 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(error, if (resumable >= 0) resumable else null) + 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( - appContext.getString(R.string.prepare_apk_download_cancelled), - partial + reason = ApkDownloadFailureReason.Cancelled, + messageArgs = emptyList(), + resumablePercent = partial ) } else { ApkDownloader.DownloadState.Idle 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 index 1c2b7e5f..d5a694f1 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -11,7 +11,9 @@ 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. @@ -27,9 +29,30 @@ object WifiAwareController { 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) @@ -87,9 +110,69 @@ object WifiAwareController { 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 @@ -149,7 +232,7 @@ object WifiAwareController { return } } - if (!_enabled.value) { + if (!_enabled.value || heldForHotspot()) { synchronized(lifecycleLock) { starting = false } return } @@ -159,23 +242,40 @@ object WifiAwareController { WifiAwareMeshService(ctx) } startedService.startServices() - if (startedService.isRunning()) { - synchronized(lifecycleLock) { + + // 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 { - if (reusableService == null) { + // stopServices() can block, so keep it out of the lock. + val heldForHotspot = heldForHotspot() + if (heldForHotspot || reusableService == null) { try { startedService.stopServices() } catch (_: Exception) { } } - synchronized(lifecycleLock) { - if (service === startedService) service = null - _running.value = false + if (heldForHotspot) { + Log.i(TAG, "Abandoned Wi-Fi Aware start: hotspot claimed the radio") } - try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware did not start")) } catch (_: Exception) {} + 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) @@ -217,6 +317,13 @@ object WifiAwareController { * 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 @@ -224,18 +331,30 @@ object WifiAwareController { scope.launch { try { if (delayMs > 0L) delay(delayMs) - 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) - } + 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() + } } } 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 index 85669aba..02bfb2d3 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -93,8 +93,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // Service-level notification manager for background (no-UI) DMs private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( context.applicationContext, - androidx.core.app.NotificationManagerCompat.from(context.applicationContext), - com.bitchat.android.util.NotificationIntervalManager() + androidx.core.app.NotificationManagerCompat.from(context.applicationContext) ) // Wi-Fi Aware transport @@ -188,21 +187,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG) } - private fun handleMessageReceived(message: BitchatMessage) { - try { - when { - message.isPrivate -> { - val peer = message.senderPeerID ?: "" - if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) - } - message.channel != null -> { - com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) - } - else -> { - com.bitchat.android.services.AppStateStore.addPublicMessage(message) - } - } - } catch (_: Exception) { } + private 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 { @@ -215,6 +206,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } } catch (_: Exception) { } } + return true } /** @@ -1430,6 +1422,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendFilePrivate(recipientPeerID, file) } + override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) { + meshCore.sendVoiceFrame(recipientPeerID, payload) + } + override fun prepareFilePrivate( recipientPeerID: String, file: BitchatFilePacket, diff --git a/app/src/main/jniLibs/arm64-v8a/libarti_android.so b/app/src/main/jniLibs/arm64-v8a/libarti_android.so index f4776ae5..77e066cd 100755 Binary files a/app/src/main/jniLibs/arm64-v8a/libarti_android.so 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 index 846f0842..c15c82fc 100755 Binary files a/app/src/main/jniLibs/armeabi-v7a/libarti_android.so 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 index c3b7b5c6..d88a13cd 100755 Binary files a/app/src/main/jniLibs/x86/libarti_android.so 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 index 7b632704..b092b073 100755 Binary files a/app/src/main/jniLibs/x86_64/libarti_android.so and b/app/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 13c8d51c..772e48a5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -46,7 +46,7 @@ و%1$d إضافية %1$d رسالة من %2$d أشخاص و%1$d محادثات أخرى - 👥 مستخدمون قريبون! + مستخدمون قريبون! شخص واحد قريب %1$d أشخاص قريبون رسائل جديدة @@ -74,7 +74,7 @@ يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة تم الانتقال - حالة Tor + فتح إعدادات الموقع والقنوات الأقران المتصلون مشاركو geohash جاهز للمصافحة @@ -389,4 +389,8 @@ تحقّق من الملاحظات المتروكة هنا تُركت ملاحظة واحدة هنا — انقر للقراءة تُركت %d ملاحظات هنا — انقر للقراءة + اللغة + لغة التطبيق + إعداد النظام الافتراضي + اختر اللغة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 7c2617a4..b6557fe2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -46,7 +46,7 @@ এবং %1$d আরো %2$d জন থেকে %1$d বার্তা এবং %1$d আরো কথোপকথন - 👥 কাছাকাছি bitchatter! + কাছাকাছি bitchatter! কাছাকাছি ১ জন কাছাকাছি %1$d জন নতুন বার্তা @@ -74,7 +74,7 @@ Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা টেলিপোর্ট করা হয়েছে - Tor অবস্থা + অবস্থান ও চ্যানেল সেটিংস খুলুন সংযুক্ত পিয়ার জিওহ্যাশ অংশগ্রহণকারী হ্যান্ডশেকের জন্য প্রস্তুত @@ -376,4 +376,8 @@ এখানে রাখা নোট আছে কি না দেখুন এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন + ভাষা + অ্যাপের ভাষা + সিস্টেম ডিফল্ট + ভাষা নির্বাচন করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b13fb001..75cc8927 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -46,7 +46,7 @@ und %1$d weitere %1$d Nachrichten von %2$d Personen und %1$d weitere Unterhaltungen - 👥 bitchatter in der Nähe! + bitchatter in der Nähe! 1 Person in der Nähe %1$d Personen in der Nähe Neue Nachrichten @@ -74,7 +74,7 @@ Über Nostr erreichbar Ungelesene private Nachrichten Teleportiert - Tor‑Status + Standort- und Kanaleinstellungen öffnen Verbundenen Peers Geohash‑Teilnehmer Bereit für Handshake @@ -390,4 +390,8 @@ 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 e1ac2c2b..3f08ead0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -46,7 +46,7 @@ y %1$d más %1$d mensajes de %2$d personas y %1$d conversaciones más - 👥 ¡bitchatters cerca! + ¡bitchatters cerca! 1 persona cerca %1$d personas cerca Nuevos mensajes @@ -74,7 +74,7 @@ Accesible vía Nostr Mensajes privados sin leer Teletransportado - Estado de Tor + Abrir ajustes de ubicación y canales Pares conectados Participantes geohash Listo para handshake @@ -389,4 +389,8 @@ 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 index a3e81d29..1e092b64 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -46,7 +46,7 @@ و %1$d مورد دیگر %1$d پیام از %2$d نفر و %1$d گفت‌وگوی دیگر - 👥 کاربران bitchat در نزدیکی! + کاربران bitchat در نزدیکی! ۱ نفر در نزدیکی %1$d نفر در نزدیکی پیام‌های جدید @@ -74,7 +74,7 @@ قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده انتقال مکانی - وضعیت Tor + باز کردن تنظیمات مکان و کانال‌ها همتایان متصل شرکت‌کنندگان geohash آمادهٔ دست‌دهی @@ -376,4 +376,8 @@ یادداشت‌های باقی‌مانده در اینجا را بررسی کنید ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید %d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید + زبان + زبان برنامه + پیش‌فرض سیستم + انتخاب زبان diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 1da03f06..fa4aea0c 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -46,7 +46,7 @@ at %1$d pa %1$d mensahe mula sa %2$d tao at %1$d pang usapan - 👥 mga bitchatter na malapit! + mga bitchatter na malapit! 1 tao sa paligid %1$d tao sa paligid Mga bagong mensahe @@ -74,7 +74,7 @@ Maabot sa Nostr Hindi nabasang pribadong mensahe Naiteleport - Katayuan ng Tor + Buksan ang mga setting ng lokasyon at channel Nakakonektang peers Mga kalahok sa geohash Handa para sa handshake @@ -293,12 +293,6 @@ File - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ 📷 nagpadala ng larawan @@ -393,4 +387,8 @@ 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 e632bdb8..a884d35e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -46,7 +46,7 @@ et %1$d de plus %1$d messages de %2$d personnes et %1$d conversations de plus - 👥 bitchatters à proximité ! + bitchatters à proximité ! 1 personne à proximité %1$d personnes à proximité Nouveaux messages @@ -74,7 +74,7 @@ Joignable via Nostr Messages privés non lus Téléporté - Statut Tor + Ouvrir les réglages de localisation et des canaux Pairs connectés Participants geohash Prêt pour l’échange de clés @@ -294,12 +294,6 @@ Fichier - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ 📷 a envoyé une image @@ -407,4 +401,8 @@ 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 index 3793942b..f742042e 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -1,6 +1,7 @@ + #%1$s ± 1 • %2$d הערה #%1$s ± 1 • %2$d הערות @@ -14,47 +15,381 @@ היה הראשון להוסיף הערה למקום זה. סגור הוסף הערה למקום זה - Skip - Bluetooth Recommended + דלג + מומלץ Bluetooth רשת 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 - פתיחת אודות - בדיקה אם הושארו כאן פתקים - פתק אחד הושאר כאן — הקש לקריאה - %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 index 1a393f38..4cb18e38 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -46,7 +46,7 @@ और %1$d %2$d लोगों से %1$d संदेश और %1$d वार्तालाप - 👥 पास में उपयोगकर्ता! + पास में उपयोगकर्ता! पास में 1 व्यक्ति पास में %1$d लोग नए संदेश @@ -74,7 +74,7 @@ Nostr के माध्यम से उपलब्ध अपठित निजी संदेश टेलीपोर्ट हुआ - Tor स्थिति + स्थान और चैनल सेटिंग खोलें कनेक्टेड पीयर्स geohash प्रतिभागी हैंडशेक के लिए तैयार @@ -389,4 +389,8 @@ देखें कि यहाँ नोट छोड़े गए हैं या नहीं यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें + भाषा + ऐप की भाषा + सिस्टम डिफ़ॉल्ट + भाषा चुनें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 934556e4..02c86a68 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -46,7 +46,7 @@ dan %1$d lagi %1$d pesan dari %2$d orang dan %1$d percakapan lagi - 👥 bitchatter terdekat! + bitchatter terdekat! 1 orang terdekat %1$d orang terdekat Pesan baru @@ -74,7 +74,7 @@ Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca Diteleportasi - Status Tor + Buka pengaturan lokasi dan channel Peer yang terhubung Peserta geohash Siap untuk handshake @@ -389,4 +389,8 @@ 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 7e67de6d..af59fad3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -46,7 +46,7 @@ e altri %1$d %1$d messaggi da %2$d persone e altre %1$d conversazioni - 👥 bitchatter nelle vicinanze! + bitchatter nelle vicinanze! 1 persona nei dintorni %1$d persone nei dintorni Nuovi messaggi @@ -74,7 +74,7 @@ Raggiungibile via Nostr Messaggi privati non letti Teletrasportato - Stato Tor + Apri le impostazioni di posizione e canali Peer connessi Partecipanti geohash Pronto per l\'handshake @@ -338,12 +338,6 @@ ⚠️ - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ 📄 @@ -422,4 +416,8 @@ 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 1e7da71d..17841dcc 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -46,7 +46,7 @@ ほか %1$d 件 %2$d 人からの %1$d 件のメッセージ ほか %1$d 会話 - 👥 近くに bitchatter ! + 近くに bitchatter ! 近くに 1 人 近くに %1$d 人 新着メッセージ @@ -74,7 +74,7 @@ Nostr 経由で到達可能 未読のプライベートメッセージ テレポート済み - Tor ステータス + 位置情報とチャンネルの設定を開く 接続中のピア Geohash 参加者 ハンドシェイク準備完了 @@ -389,4 +389,8 @@ ここに残されたメモを確認 ここに1件のメモがあります — タップして読む ここに%d件のメモがあります — タップして読む + 言語 + アプリの言語 + システムのデフォルト + 言語を選択 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 4994f9cc..8fe186cc 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -46,7 +46,7 @@ და კიდევ %1$d %2$d ადამიანიდან %1$d შეტყობინება და კიდევ %1$d საუბარი - 👥 bitchatter-ები ახლოს! + bitchatter-ები ახლოს! 1 ადამიანი ახლოს ახლოს %1$d ადამიანი ახალი შეტყობინებები @@ -74,7 +74,7 @@ მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები ტელეპორტირებული - Tor-ის სტატუსი + მდებარეობისა და არხების პარამეტრების გახსნა დაკავშირებული პირები geohash მონაწილეები მზადაა ხელის ჩამოსართმევად @@ -376,4 +376,8 @@ აქ დატოვებული ჩანაწერების შემოწმება აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ + ენა + აპის ენა + სისტემის ნაგულისხმევი + ენის არჩევა diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7327257a..030165d6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -46,7 +46,7 @@ 그리고 %1$d개 더 %2$d명에게서 %1$d개의 메시지 그리고 %1$d개의 대화 더 - 👥 근처 bitchatter! + 근처 bitchatter! 근처 1명 근처 %1$d명 새 메시지 @@ -74,7 +74,7 @@ Nostr로 연결 가능 읽지 않은 개인 메시지 텔레포트됨 - Tor 상태 + 위치 및 채널 설정 열기 연결된 피어 geohash 참여자 핸드셰이크 준비됨 @@ -389,4 +389,8 @@ 여기 남겨진 쪽지 확인 여기 남겨진 쪽지 1개 — 탭하여 읽기 여기 남겨진 쪽지 %d개 — 탭하여 읽기 + 언어 + 앱 언어 + 시스템 기본값 + 언어 선택 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 168eebe7..b7182e09 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -46,7 +46,7 @@ ary %1$d hafa %1$d hafatra avy amin\'ny olona %2$d ary %1$d resaka hafa - 👥 bitchatter akaiky! + bitchatter akaiky! Olona 1 manodidina Olona %1$d manodidina Hafatra vaovao @@ -74,7 +74,7 @@ Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky Nafindra toerana - Toe-javatra Tor + Sokafy ny fikirana toerana sy fantsona Mpiara-miasa mifandray Mpandray anjara Geohash Vonona ho amin\'ny fifampiraharahana @@ -302,12 +302,6 @@ Rakitra - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ 📷 nandefasa sary @@ -402,4 +396,8 @@ 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 index add7b467..79bf5b1c 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,47 +1,436 @@ - - Skip - Bluetooth Recommended + + 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 - 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 Perihal - semak nota yang ditinggalkan di sini - 1 nota ditinggalkan di sini — ketik untuk baca - %d nota ditinggalkan di sini — ketik untuk baca + + 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 index 48a7afbd..59d52853 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -46,7 +46,7 @@ र %1$d थप %1$d सन्देश %2$d जनाबाट र %1$d अरू कुराकानी - 👥 नजिकै bitchatter हरू! + नजिकै bitchatter हरू! १ जना नजिकै %1$d जना नजिकै नयाँ सन्देश @@ -74,7 +74,7 @@ Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश टेलिपोर्ट भयो - Tor स्थिति + स्थान र च्यानल सेटिङ खोल्नुहोस् जडान भएका साथीहरू Geohash सहभागिहरू ह्यान्डशेकका लागि तयार @@ -293,12 +293,6 @@ फाइल - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ 📷 तस्वीर पठाइयो @@ -393,4 +387,8 @@ यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस् यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस् यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस् + भाषा + एपको भाषा + प्रणालीको पूर्वनिर्धारित + भाषा छान्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0eeaec55..1e46f196 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -46,7 +46,7 @@ en %1$d meer %1$d berichten van %2$d personen en %1$d meer conversaties - 👥 bitchatters in de buurt! + bitchatters in de buurt! 1 persoon in de buurt %1$d personen in de buurt Nieuwe berichten @@ -74,7 +74,7 @@ Bereikbaar via Nostr Ongelezen privéberichten Geteleporteerd - Tor-status + Locatie- en kanaalinstellingen openen Verbonden peers Geohash-deelnemers Klaar voor handshake @@ -339,12 +339,6 @@ ? - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ 📄 @@ -420,4 +414,8 @@ 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 index 5f5f5f75..a3f67735 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -46,7 +46,7 @@ تے %1$d ہور %2$d لوکان کولوں %1$d پیغام تے %1$d ہور گلاں - 👥 نیڑے bitchatter! + نیڑے bitchatter! نیڑے 1 بندہ نیڑے %1$d بندے نویں پیغام @@ -74,7 +74,7 @@ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام ٹیلپورٹ کیتا گیا - Tor حالت + مقام تے چینل دیاں ترتیباں کھولو جُڑے پیئر geohash شریک ہینڈشیک لئی تیار @@ -376,4 +376,8 @@ ایتھے چھڈے نوٹس ویکھو ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو + بولی + ایپ دی بولی + سسٹم دی ڈیفالٹ + بولی چݨو diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index f1a67349..3ec84dd4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,6 +1,8 @@ + + #%1$s ± 1 • %2$d notatka #%1$s ± 1 • %2$d notatek @@ -18,43 +20,382 @@ Bluetooth zalecany Mesh działa — %1$d peerów - 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 - Otwórz informacje - sprawdź, czy zostawiono tutaj notatki - 1 notatka zostawiona tutaj — stuknij, aby przeczytać - %d notatek zostawionych tutaj — stuknij, aby przeczytać + 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 fd4901ef..42340857 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -46,7 +46,7 @@ e mais %1$d %1$d mensagens de %2$d pessoas e mais %1$d conversas - 👥 pessoas do bitchat por perto! + pessoas do bitchat por perto! 1 pessoa por perto %1$d pessoas por perto Novas mensagens @@ -74,7 +74,7 @@ Acessível via Nostr Mensagens privadas não lidas Teletransportado - Status do Tor + Abrir configurações de localização e canais Peers conectados Participantes geohash Pronto para handshake @@ -388,4 +388,8 @@ 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 5247916e..0e01bb07 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -46,7 +46,7 @@ e mais %1$d %1$d mensagens de %2$d pessoas e mais %1$d conversas - 👥 bitchatters por perto! + bitchatters por perto! 1 pessoa por perto %1$d pessoas por perto Novas mensagens @@ -74,7 +74,7 @@ Alcançável via Nostr Mensagens privadas não lidas Teletransportado - Estado do Tor + Abrir definições de localização e canais Pares ligados Participantes geohash Pronto para handshake @@ -389,4 +389,8 @@ 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 4dc08928..02b1175d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -40,7 +40,7 @@ и ещё %1$d %1$d сообщений от %2$d людей и ещё %1$d бесед - 👥 bitchatter рядом! + bitchatter рядом! Рядом 1 человек Рядом %1$d человек Новые сообщения @@ -66,7 +66,7 @@ Доступен через Nostr Непрочитанные личные Телепортировано - Статус Tor + Открыть настройки местоположения и каналов Подключённые узлы Участники geohash Готов к рукопожатию @@ -276,13 +276,6 @@ Изображение Файл - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ - 📷 отправил изображение 🎤 отправил голосовое 📎 отправил файл @@ -383,4 +376,8 @@ проверить, есть ли здесь заметки здесь оставлена 1 заметка — нажмите, чтобы прочитать здесь оставлено заметок: %d — нажмите, чтобы прочитать + Язык + Язык приложения + Системный язык + Выбрать язык diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index dcfda208..b958faad 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -40,7 +40,7 @@ och %1$d till %1$d meddelanden från %2$d personer och %1$d fler konversationer - 👥 bitchatter i närheten! + bitchatter i närheten! 1 person i närheten %1$d personer i närheten Nya meddelanden @@ -66,7 +66,7 @@ Nås via Nostr Olästa privata meddelanden Teleporterad - Tor‑status + Öppna plats- och kanalinställningar Anslutna noder Geohash‑deltagare Redo för handshake @@ -276,13 +276,6 @@ Bild Fil - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ - 📷 skickade en bild 🎤 skickade ett röstmeddelande 📎 skickade en fil @@ -381,4 +374,8 @@ 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 index 05bd4750..243f2112 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,47 +1,393 @@ + - - Skip + Skip 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 குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும் + சரிபார் + என்னை சரிபார்க்க ஸ்கேன் செய்யவும் + மற்றவரின் 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 index 7e1fc05a..b21049c1 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -46,7 +46,7 @@ และอีก %1$d ข้อความ %1$d จาก %2$d คน และการสนทนาอีก %1$d รายการ - 👥 มีผู้ใช้ใกล้คุณ! + มีผู้ใช้ใกล้คุณ! มี 1 คนใกล้คุณ มี %1$d คนใกล้คุณ ข้อความใหม่ @@ -74,7 +74,7 @@ ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน เทเลพอร์ตแล้ว - สถานะ Tor + เปิดการตั้งค่าตำแหน่งและช่อง เพียร์ที่เชื่อมต่อ ผู้เข้าร่วม geohash พร้อมสำหรับการจับมือ @@ -376,4 +376,8 @@ ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่ มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน + ภาษา + ภาษาของแอป + ค่าเริ่มต้นของระบบ + เลือกภาษา diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b81fdd08..251d6a46 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -40,7 +40,7 @@ ve %1$d daha %2$d kişiden %1$d mesaj ve %1$d sohbet daha - 👥 yakında bitchatter’lar! + yakında bitchatter’lar! Yakında 1 kişi Yakında %1$d kişi Yeni mesajlar @@ -66,7 +66,7 @@ Nostr üzerinden ulaşılabilir Okunmamış özel mesaj Teleport edildi - Tor durumu + Konum ve kanal ayarlarını aç Bağlı eşler Geohash katılımcıları El sıkışmaya hazır @@ -276,13 +276,6 @@ Görsel Dosya - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ - 📷 görsel gönderdi 🎤 sesli mesaj gönderdi 📎 dosya gönderdi @@ -381,4 +374,8 @@ 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 index 9ca2d2d0..f248ff71 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,47 +1,403 @@ - + Пропустити Рекомендується Bluetooth 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 нотаток — торкніться, щоб прочитати + перевірити + скануйте, щоб підтвердити мене + скануйте 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 index 36c5265d..785338fb 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -46,7 +46,7 @@ اور %1$d مزید %2$d لوگوں سے %1$d پیغامات اور %1$d مزید گفتگو - 👥 قریب میں bitchatter! + قریب میں bitchatter! قریب میں 1 شخص قریب میں %1$d لوگ نئے پیغامات @@ -74,7 +74,7 @@ Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات ٹیلی پورٹ - Tor کی کیفیت + مقام اور چینل کی ترتیبات کھولیں منسلک پیئرز geohash شرکاء ہینڈ شیک کے لیے تیار @@ -389,4 +389,8 @@ دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں + زبان + ایپ کی زبان + سسٹم ڈیفالٹ + زبان منتخب کریں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 20028f38..b97c3fc5 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -46,7 +46,7 @@ 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 đó! + bitchatter gần đó! 1 người gần đó %1$d người gần đó Tin nhắn mới @@ -74,7 +74,7 @@ Có thể tiếp cận qua Nostr Tin nhắn riêng chưa đọc Đã dịch chuyển - Trạng thái Tor + 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 @@ -376,4 +376,8 @@ 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 index 438131b0..fca6b07b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,6 +1,7 @@ + #%1$s ± 1 • %2$d 条笔记 #%1$s ± 1 • %2$d 条笔记 @@ -53,7 +54,338 @@ 已验证 你已验证 %1$s 已验证 %1$s - 查看这里留下的留言 - 这里留有 1 条留言 — 点按阅读 - 这里留有 %d 条留言 — 点按阅读 + + 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 index 328696a0..f427dbaf 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,6 +1,7 @@ + #%1$s ± 1 • %2$d 則筆記 #%1$s ± 1 • %2$d 則筆記 @@ -18,42 +19,374 @@ 建議開啟藍牙 Mesh 運行中 — %1$d 個節點 - 验证 - 扫描以验证我 - 扫描他人的QR码 - 扫描他人的QR码 - 显示我的QR码 - 移除验证 - QR码不可用 - 需要相机权限扫描QR码 - 启用相机 - 粘贴验证链接 - 验证 - 已请求验证 - 安全验证 - 对方指纹 - 你的指纹 + 驗證 + 掃描以驗證我 + 掃描他人的QR碼 + 掃描他人的QR碼 + 顯示我的QR碼 + 移除驗證 + QR碼不可用 + 需要相機權限掃描QR碼 + 啟用相機 + 貼上驗證連結 + 驗證 + 已請求驗證 + 安全驗證 + 對方指紋 + 你的指紋 握手中 - 打开私聊查看指纹 - 已加密且已验证 + 打開私聊查看指紋 + 已加密且已驗證 已加密 握手中 - 握手失败 + 握手失敗 未加密 - 已验证 - 你已验证此人身份。 - 未验证 - 请通过安全渠道与 %1$s 比对指纹。 - 标记为已验证 - 开始握手 - 复制 - 相互验证 - 你和 %1$s 已相互验证 - 与 %1$s 相互验证 - 已验证 - 你已验证 %1$s - 已验证 %1$s - 查看這裡留下的留言 - 這裡留有 1 則留言 — 點按閱讀 - 這裡留有 %d 則留言 — 點按閱讀 + 已驗證 + 你已驗證此人身份。 + 未驗證 + 請通過安全渠道與 %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 d6eada8c..35f7477a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -46,7 +46,7 @@ 以及另外 %1$d 条 来自 %2$d 位的 %1$d 条消息 以及另外 %1$d 个会话 - 👥 附近的 bitchatter! + 附近的 bitchatter! 附近 1 人 附近 %1$d 人 新消息 @@ -74,7 +74,7 @@ 可通过 Nostr 联系 未读私信 已传送 - Tor 状态 + 打开位置和频道设置 已连接节点 Geohash 参与者 准备握手 @@ -293,12 +293,6 @@ 文件 - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ 📷 发送了图片 @@ -406,4 +400,8 @@ 查看这里留下的留言 这里留有 1 条留言 — 点按阅读 这里留有 %d 条留言 — 点按阅读 + 语言 + 应用语言 + 跟随系统 + 选择语言 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c54a2273..a46d596f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,10 @@ + 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. @@ -42,7 +47,7 @@ and %1$d more %1$d messages from %2$d people and %1$d more conversations - 👥 bitchatters nearby! + bitchatters nearby! 1 person around %1$d people around New Messages @@ -69,14 +74,56 @@ 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 - Tor status + Open location and channel settings Connected peers Geohash participants Ready for handshake @@ -149,6 +196,8 @@ System Light Dark + Matrix + Bubbles Proof of Work PoW Off PoW On @@ -187,36 +236,70 @@ Prepare App for Sharing - App Ready for Offline Sharing + App Ready for Offline Sharing Download universal APK for offline sharing Not ready • Tap to download Ready to share - Sharing source: this installed APK - Sharing source: this installed APK • ARM64 devices only - Sharing source: verified GitHub universal APK - Get universal + + 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%% - Update available + + Waiting for network… + Retrying… + Selecting download source… + Waiting for Tor… + Downloading… + Verifying signature… + Stop download Prepare - Update Delete Version %1$s • %2$d MB Download Universal APK? - This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once. - The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading. + 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… - Verifying checksum… Universal APK ready! Network error. Check your connection. - Checksum verification failed. Please try again. Not enough storage space. - Failed to fetch release info from GitHub. + + %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. - Update Available - A newer version (%1$s) is available. Current: %2$s Please prepare the app for sharing first. Download interrupted Download cancelled @@ -228,6 +311,9 @@ 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 @@ -252,6 +338,9 @@ Checking Battery Optimization Battery Optimization Not Supported Bluetooth + Direct Wi-Fi Aware + Direct Bluetooth + Routed mesh Unread message Open map Remove bookmark @@ -486,7 +575,7 @@ · ⧉ Nobody around… (you) - Pan and zoom to select a geohash + Drag to spin · Pinch to zoom · Tap to focus Select Type a message… @%1$s @@ -503,14 +592,6 @@ Image File - - ○ - ○ - ✓ - ✓✓ - ⚠ - ✓ - 📷 sent an image 🎤 sent a voice message @@ -569,4 +650,8 @@ Bluetooth Recommended + Language + App language + System default + Select language 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/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/mesh/BLEPacketPaddingPolicyTest.kt b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt index 96aba61c..ba4bb0cb 100644 --- a/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt @@ -24,6 +24,7 @@ class BLEPacketPaddingPolicyTest { 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 ) @@ -50,7 +51,8 @@ class BLEPacketPaddingPolicyTest { MessageType.LEAVE, MessageType.REQUEST_SYNC, MessageType.FRAGMENT, - MessageType.FILE_TRANSFER + MessageType.FILE_TRANSFER, + MessageType.VOICE_FRAME ) publicTypes.forEach { type -> diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index f6a1042d..c927257f 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -942,6 +942,61 @@ class BinaryProtocolTest { 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 * 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 index 69693b7e..44377856 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -385,7 +385,7 @@ class ChatUIUtilsTest { palette = palette, ) - assertEquals("@carol#04af", sender.text) + 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" } diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 9eff027c..2e52b750 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -2,9 +2,13 @@ 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 @@ -20,11 +24,12 @@ import org.robolectric.RobolectricTestRunner import java.util.Date @RunWith(RobolectricTestRunner::class) -class CommandProcessorTest() { +class CommandProcessorTest { private val context: Context = ApplicationProvider.getApplicationContext() - @OptIn(ExperimentalCoroutinesApi::class) - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) + + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private val chatState = ChatState(scope = testScope) private lateinit var commandProcessor: CommandProcessor @@ -59,11 +64,11 @@ class CommandProcessorTest() { val channel = "channel-1" val result = commandProcessor.processCommand( - command = "/j $channel", - meshService = meshService, - myPeerID = "peer-id", - onSendMessage = { a, b, c -> { } }, - viewModel = null + command = "/j $channel", + meshService = meshService, + myPeerID = "peer-id", + onSendMessage = { a, b, c -> { } }, + viewModel = null ) assertEquals(result, true) @@ -136,4 +141,71 @@ class CommandProcessorTest() { 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/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt index e53665ca..b5d556e3 100644 --- a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt @@ -42,6 +42,22 @@ class LocationChannelsSheetTest { ) } + @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/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt index 8a136e69..2d1ec24a 100644 --- a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt +++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt @@ -5,6 +5,7 @@ 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 @@ -139,6 +140,116 @@ class FileTransferTest { 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 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 cfe8e5b4..00000000 --- a/app/src/test/kotlin/com/bitchat/NotificationManagerTest.kt +++ /dev/null @@ -1,117 +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.Ignore -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 - private val notificationManagerCompat: NotificationManagerCompat = Mockito.mock(NotificationManagerCompat::class.java) - - @Before - fun setup() { - MockitoAnnotations.openMocks(this) - notificationManager = NotificationManager( - context, - notificationManagerCompat, - notificationIntervalManager - ) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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()) - } - - @Ignore // Temporarily disabled due to Mockito final class issues - @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 90021c52..2a726ad0 100644 --- a/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt @@ -1,6 +1,7 @@ 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 @@ -167,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, _ -> 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/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/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index 78d72bee..892f6565 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -12,9 +12,38 @@ class IdentityAnnouncementTest { private val signingKey = ByteArray(32) { 0x22 } @Test - fun `private media capability uses iOS little-endian bytes`() { + 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()) - assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA)) + + 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 @@ -71,5 +100,6 @@ class IdentityAnnouncementTest { .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/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/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt index d7fffc2c..152d4ad0 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -36,17 +36,21 @@ class NostrBackgroundEventProcessorTest { 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 = NostrEvent( - id = "cold-start-$index", - pubkey = index.toString(16).padStart(64, '0'), - createdAt = 1, - kind = NostrKind.EPHEMERAL_EVENT, - tags = listOf(listOf("g", "u4pruy")), - content = "message-$index" - ), + event = event, geohash = "u4pruy" ) } @@ -58,7 +62,7 @@ class NostrBackgroundEventProcessorTest { } assertEquals( - (0 until 300).map { "cold-start-$it" }.toSet(), + 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 index f756447a..634e053f 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -2,6 +2,8 @@ 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 @@ -30,6 +32,7 @@ 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) @@ -37,17 +40,31 @@ import org.robolectric.annotation.Config 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() } 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/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/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 90ab0034..893abe0e 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -4,19 +4,23 @@ 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() } @@ -131,6 +135,60 @@ class AppStateStoreTest { 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) @@ -189,4 +247,145 @@ class AppStateStoreTest { .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/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/meshgraph/MeshGraphServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt index dc27ff99..feecea6a 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphServiceTest.kt @@ -123,4 +123,19 @@ class MeshGraphServiceTest { 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/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 index 60c57198..51204f30 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -1,9 +1,16 @@ 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 @@ -29,6 +36,7 @@ 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 { @@ -37,11 +45,22 @@ class MediaSendingManagerMigrationTest { 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")) @@ -60,6 +79,11 @@ class MediaSendingManagerMigrationTest { @After fun tearDown() { + AppStateStore.clear() + AppStateStore.setConversationRepositoryForTest(null) + conversationRepository.closeForTest() + org.robolectric.RuntimeEnvironment.getApplication() + .deleteDatabase(conversationDatabaseName) file.delete() } @@ -161,6 +185,99 @@ class MediaSendingManagerMigrationTest { 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))) @@ -266,7 +383,7 @@ class MediaSendingManagerMigrationTest { } @Test - fun `failed prepared commit rolls back the local file echo`() { + fun `failed prepared commit keeps a retryable failed local file echo`() { whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) .thenAnswer { invocation -> PrivateMediaPreparation.Ready( @@ -282,9 +399,13 @@ class MediaSendingManagerMigrationTest { manager.sendImageNote(peerID, null, file.absolutePath) val messages = state.privateChats.value[peerID].orEmpty() - assertEquals(1, messages.size) - assertTrue(messages.single().content.contains("could not be committed")) - assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + 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 diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt index 162a5ef6..1bd4f61c 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt @@ -66,14 +66,12 @@ class MeshDelegateHandlerStateContractTest { assertEquals(listOf("peer-a", "peer-b"), state.connectedPeers.value) assertTrue(state.isConnected.value) - verify(notifications).showActiveUserNotification(listOf("peer-a", "peer-b")) verify(channels).cleanupDisconnectedMembers(listOf("peer-a", "peer-b"), "self") handler.didUpdatePeerList(emptyList()) assertTrue(state.connectedPeers.value.isEmpty()) assertFalse(state.isConnected.value) - verify(notifications).showActiveUserNotification(emptyList()) } @Test 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/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/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 index e51990d9..6377752d 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -1,99 +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.assertNull 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 - @Test - fun `parses universal apk and GitHub asset digest`() { - val digest = "a".repeat(64) - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "v1.7.6", - "body": "", - "assets": [ - { - "name": "bitchat-android-universal.apk", - "browser_download_url": "https://example.test/bitchat.apk", - "size": 49283072, - "digest": "sha256:$digest" - } - ] - } - """.trimIndent() - ) + /** How far the clock advances while awaitRoute() waits for Tor to finish bootstrapping. */ + private var routeWaitMillis = 0L - requireNotNull(release) - assertEquals("1.7.6", release.versionName) - assertEquals(49_283_072L, release.universalApkSize) - assertEquals(digest, release.universalApkSha256) + /** 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 `falls back to checksum in release notes`() { - val digest = "b".repeat(64) - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "1.7.6", - "body": "bitchat-android-universal.apk: $digest", - "assets": [ - { - "name": "bitchat-android-universal.apk", - "browser_download_url": "https://example.test/bitchat.apk", - "size": 10 - } - ] - } - """.trimIndent() - ) + fun `cached metadata is conditionally refreshed with its etag`() = runTest { + server.enqueue(successResponse(etag = "release-v1")) + val client = client() - assertEquals(digest, requireNotNull(release).universalApkSha256) + 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 `rejects releases without a universal apk`() { - val release = GitHubReleaseClient.parseRelease( + 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-arm64.apk", - "browser_download_url": "https://example.test/arm64.apk", - "size": 10 + "name": "bitchat-android-universal.apk", + "browser_download_url": "https://downloads.example/bitchat-universal.apk", + "size": 25165824 } ] } """.trimIndent() ) - - assertNull(release) - } - - @Test - fun `compares release versions`() { - val release = GitHubReleaseClient.Release( - tagName = "v1.7.6", - versionName = "1.7.6", - universalApkUrl = "https://example.test/bitchat.apk", - universalApkSha256 = null, - universalApkSize = 10, - universalApkName = "bitchat-android-universal.apk" - ) - - assertTrue(GitHubReleaseClient.isNewerVersion("1.7.5", release)) - assertFalse(GitHubReleaseClient.isNewerVersion("1.7.6", release)) - assertFalse(GitHubReleaseClient.isNewerVersion("1.8.0", release)) - assertTrue(GitHubReleaseClient.isNewerVersion("1.7.4", "1.7.5")) - assertFalse(GitHubReleaseClient.isNewerVersion("1.7.5", "1.7.4")) - } + .build() } 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 5aac5518..16bf6428 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +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 @@ -5,6 +7,39 @@ plugins { 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 diff --git a/docs/ANNOUNCEMENT_GOSSIP.md b/docs/ANNOUNCEMENT_GOSSIP.md index 25593c6f..0328df27 100644 --- a/docs/ANNOUNCEMENT_GOSSIP.md +++ b/docs/ANNOUNCEMENT_GOSSIP.md @@ -29,9 +29,16 @@ Existing TLVs (unchanged): Other optional extension: -- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Bit 8 advertises - authenticated Noise private media (`PRIVATE_MEDIA_V1`); its exact value is - `00 01`. An empty value is valid and means no advertised capabilities. +- `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): @@ -69,6 +76,12 @@ This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in th 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). 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/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 0337ccf9..1abed243 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -14,7 +14,7 @@ The remaining implementation work and milestone progress are tracked in |---|---|---| | 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, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `FragmentManagerTest` | +| 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 | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | 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 index 9ac18f30..8de1e063 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -248,7 +248,7 @@ command and stop the local relay/Tor fixture. 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, announce, file send/receive, BLE toggle, state dumps, and +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`). @@ -296,10 +296,14 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | 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 | @@ -322,8 +326,10 @@ python3 tools/release_gate/mesh_lab.py cmd --serial state # full mesh 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`, `file_send`, -`file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`). +`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 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/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/libs.versions.toml b/gradle/libs.versions.toml index 95e83d07..405f6dc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ kotlin = "2.4.10" compileSdk = "37" minSdk = "26" # API 26 for proper BLE support targetSdk = "37" +buildTools = "37.0.0" # AndroidX Core core-ktx = "1.19.0" @@ -14,6 +15,7 @@ appcompat = "1.7.1" # Compose compose-bom = "2026.06.01" +compose-icons-extended = "1.7.8" # Navigation navigation-compose = "2.9.8" @@ -21,6 +23,10 @@ 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.85" tink-android = "1.23.0" @@ -84,7 +90,7 @@ 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-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" } @@ -95,6 +101,11 @@ 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-jdk18on", version.ref = "bouncycastle" } google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" } @@ -110,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 @@ -181,7 +193,8 @@ testing = [ "mockito-kotlin", "mockito-core", "roboelectric", - "kotlinx-coroutines-test" + "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 a351597e..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-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/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION deleted file mode 100644 index 5b590193..00000000 --- a/tools/arti-build/ARTI_VERSION +++ /dev/null @@ -1 +0,0 @@ -arti-v1.9.0 \ No newline at end of file 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 index 05664f90..d5858590 100644 --- a/tools/arti-build/Cargo.toml +++ b/tools/arti-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "arti-android-wrapper" -version = "1.7.0" +version = "1.9.0" edition = "2021" [workspace] @@ -11,8 +11,8 @@ 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-sqlite"] } -tor-rtcompat = { path = "../crates/tor-rtcompat", features = ["tokio", "rustls"] } +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" @@ -22,4 +22,4 @@ 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 \ No newline at end of file +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 index f547b773..2307a53a 100644 --- a/tools/arti-build/README.md +++ b/tools/arti-build/README.md @@ -1,223 +1,70 @@ -# Arti Android Build Tools +# Rebuilding Arti for Android -This directory contains the build scripts and source files for compiling the custom Arti (Tor in Rust) library for Android. - -## Overview - -bitchat-android uses a custom-built Arti library instead of Guardian Project's outdated `arti-mobile-ex` AAR. This provides: - -- **Smaller APK size**: ~11MB total vs ~140MB with Guardian Project AAR (28x reduction) -- **Latest Arti version**: Currently v1.7.0 with pure Rust TLS (rustls) -- **16KB page size support**: Required for Google Play (Nov 2025) -- **Full transparency**: Build from official Arti source + our JNI wrapper - -## Quick Start - -The pre-built `.so` files are committed to the repo, so you don't need to build unless you want to: - -1. **Verify the binaries** match the source -2. **Update to a new Arti version** -3. **Modify the JNI wrapper** - -## Directory Structure - -``` -tools/arti-build/ -├── README.md # This file -├── build-arti.sh # Main build script (clones Arti, builds .so files) -├── ARTI_VERSION # Pinned Arti version tag (e.g., arti-v1.7.0) -├── Cargo.toml # Rust package configuration -├── src/ -│ └── lib.rs # JNI wrapper (Rust -> Kotlin/Java bridge) -└── .arti-source/ # [GITIGNORED] Cloned official Arti repo - -app/src/main/jniLibs/ # [COMMITTED] Pre-built native libraries -├── arm64-v8a/ -│ └── libarti_android.so (~5.3MB) -└── x86_64/ - └── libarti_android.so (~6.2MB) -``` - -## Rebuilding from Source - -### Prerequisites - -1. **Rust toolchain** with Android targets: - ```bash - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - rustup target add aarch64-linux-android x86_64-linux-android - ``` - -2. **cargo-ndk** for Android cross-compilation: - ```bash - cargo install cargo-ndk - ``` - -3. **Android NDK 25+** (for 16KB page size support): - ```bash - # Via Android Studio SDK Manager, or: - $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973" - - # Set environment variable - export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" - ``` - -### Build Commands +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 -cd tools/arti-build - -# Build both architectures (arm64 + x86_64 for emulator) -./build-arti.sh - -# Build ARM64 only (smaller, for production releases) -./build-arti.sh --release - -# Clean rebuild (re-clone Arti source) -./build-arti.sh --clean +tools/arti-build/rebuild-in-container.sh +tools/arti-build/verify-checksums.sh ``` -The script will: -1. Clone official Arti from https://gitlab.torproject.org/tpo/core/arti -2. Checkout the version specified in `ARTI_VERSION` -3. Copy our JNI wrapper into the cloned repo -4. Build with `cargo ndk` -5. Copy `.so` files to `app/src/main/jniLibs/` +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. -### Verification +## Pinned inputs -After building, verify the libraries: +- `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 -```bash -# Check file sizes -ls -lh ../../app/src/main/jniLibs/*/libarti_android.so +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. -# Verify JNI symbols are exported -nm -gU ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep Java_org_torproject +## Updating Arti or the native toolchain -# Verify 16KB page alignment -readelf -l ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD -# Look for: Align 0x4000 (16KB) -``` +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: -## Updating Arti Version - -1. **Check available versions**: ```bash - git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v + tools/arti-build/rebuild-in-container.sh --update-lockfile ``` -2. **Update the version file**: - ```bash - echo "arti-v1.8.0" > ARTI_VERSION - ``` +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). -3. **Rebuild from scratch**: - ```bash - ./build-arti.sh --clean - ``` +Do not build release native libraries with an unpinned local NDK or Rust +toolchain. `build-arti.sh` deliberately rejects mismatched tool versions. -4. **Test the build**: - ```bash - cd ../.. - ./gradlew clean assembleDebug - ./gradlew installDebug - # Enable Tor in app and verify it works - ``` +## JNI surface -5. **Commit the new libraries**: - ```bash - git add app/src/main/jniLibs/ tools/arti-build/ARTI_VERSION - git commit -m "chore: update Arti to v1.8.0" - ``` +The wrapper exports the native methods used by `ArtiNative`: -## JNI Wrapper Architecture +- `getVersion` +- `setLogCallback` +- `initialize` +- `startSocksProxy` +- `stop` -The `src/lib.rs` file implements a JNI bridge between Kotlin and Rust: - -``` -Kotlin (ArtiNative.kt) - ↓ JNI -Rust (lib.rs) - ↓ -Arti Client (TorClient) - ↓ -SOCKS5 Proxy (localhost:9060) -``` - -**Exported JNI Functions**: -- `getVersion()` - Returns Arti version string -- `setLogCallback(callback)` - Registers log listener for bootstrap progress -- `initialize(dataDir)` - Creates Tokio runtime and TorClient -- `startSocksProxy(port)` - Starts SOCKS5 proxy on specified port -- `stop()` - Stops SOCKS proxy (TorClient is reused) - -**Key Design Decisions**: -- Global `TorClient` persists across stop/start cycles (fixes Nov 2024 toggle bug) -- Tokio runtime created once and never destroyed -- Log messages bridged to Java via `GlobalRef` callback - -## Feature Configuration - -Edit `Cargo.toml` to customize Arti features: - -```toml -[dependencies] -arti-client = { - path = "../crates/arti-client", - default-features = false, - features = [ - "tokio", # Required: async runtime - "rustls", # Required: pure Rust TLS (no OpenSSL) - "compression", # Optional: directory compression - "bridge-client", # Optional: Tor bridge support - "onion-service-client", # Optional: .onion site support - "static-sqlite" # Required: bundled SQLite - ] -} -``` - -## Size Comparison - -| Configuration | arm64-v8a | x86_64 | Total | APK Size | -|---------------|-----------|--------|-------|----------| -| Guardian Project AAR | - | - | ~140 MB | ~150 MB | -| Custom (both arch) | 5.3 MB | 6.2 MB | 11.5 MB | ~15 MB | -| Custom (ARM-only) | 5.3 MB | - | 5.3 MB | ~10 MB | - -**28x size reduction** vs Guardian Project implementation. - -## Troubleshooting - -### "cargo-ndk not found" -```bash -cargo install cargo-ndk -``` - -### "Android NDK not found" -```bash -export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" -``` - -### "Rust target not installed" -```bash -rustup target add aarch64-linux-android x86_64-linux-android -``` - -### "Version not found" -Check available versions: -```bash -git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v | tail -10 -``` - -### Library too large -1. Ensure building with `--release` flag -2. Verify `strip = true` in `Cargo.toml` `[profile.release]` -3. Consider removing optional features +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 Documentation](https://gitlab.torproject.org/tpo/core/arti/-/blob/main/doc/README.md) -- [cargo-ndk](https://github.com/bbqsrc/cargo-ndk) -- [Android NDK Guide](https://developer.android.com/ndk/guides) -- [Google Play 16KB Page Size](https://developer.android.com/guide/practices/page-sizes) +- [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 index 2a31fefa..5d20541a 100755 --- a/tools/arti-build/build-arti.sh +++ b/tools/arti-build/build-arti.sh @@ -8,17 +8,14 @@ # - Update to a new Arti version # - Debug or modify the wrapper code # -# Requirements: -# - Bash 4+ (macOS default bash is 3.2; install via Homebrew: brew install bash) -# - Rust toolchain with Android targets: -# rustup target add aarch64-linux-android x86_64-linux-android -# - cargo-ndk: cargo install cargo-ndk -# - Android NDK 25+ (for 16KB page size support) +# 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 ARM64 only (production) +# ./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 @@ -38,57 +35,21 @@ 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" - -detect_default_ndk_home() { - local candidates=( - "$HOME/Library/Android/sdk/ndk/27.0.12077973" - "$HOME/Library/Android/sdk/ndk" - "$HOME/Library/Android/sdk/ndk-bundle" - "$HOME/Android/Sdk/ndk/27.0.12077973" - "$HOME/Android/Sdk/ndk" - "$HOME/Android/Sdk/ndk-bundle" - ) - - for candidate in "${candidates[@]}"; do - if [ -d "$candidate" ]; then - echo "$candidate" - return - fi - done - - local base - for base in "$HOME/Library/Android/sdk/ndk" "$HOME/Android/Sdk/ndk"; do - if [ -d "$base" ]; then - local latest - latest="$(find "$base" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | tail -1)" - if [ -n "$latest" ]; then - echo "$latest" - return - fi - fi - done - - echo "" -} - -# Read pinned version -if [ ! -f "$SCRIPT_DIR/ARTI_VERSION" ]; then - echo -e "${RED}Error: ARTI_VERSION file not found${NC}" +if [ ! -f "$TOOLCHAIN_FILE" ]; then + echo -e "${RED}Error: TOOLCHAIN.env file not found${NC}" exit 1 fi -VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ARTI_VERSION")" +# shellcheck disable=SC1090 +source "$TOOLCHAIN_FILE" + +VERSION="$ARTI_VERSION" +export RUSTUP_TOOLCHAIN="$RUST_VERSION" -# Android NDK path if [ -z "${ANDROID_NDK_HOME:-}" ]; then - AUTO_NDK_HOME="$(detect_default_ndk_home)" - if [ -n "$AUTO_NDK_HOME" ]; then - ANDROID_NDK_HOME="$AUTO_NDK_HOME" - fi -fi -if [ -z "${ANDROID_NDK_HOME:-}" ]; then - echo -e "${RED}Error: ANDROID_NDK_HOME is not set and automatic detection failed.${NC}" - echo "Set ANDROID_NDK_HOME to your NDK installation (e.g., ~/Android/Sdk/ndk/)." + echo -e "${RED}Error: ANDROID_NDK_HOME must point to NDK $ANDROID_NDK_VERSION.${NC}" exit 1 fi export ANDROID_NDK_HOME @@ -96,9 +57,29 @@ 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 @@ -110,12 +91,17 @@ while [[ $# -gt 0 ]]; do CLEAN_BUILD=true shift ;; + --update-lockfile) + UPDATE_LOCKFILE=true + shift + ;; --help|-h) echo "Usage: $0 [--release] [--clean]" echo "" echo "Options:" - echo " --release Build ARM64 only (smaller, for production)" + 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 ;; @@ -220,10 +206,16 @@ check_prerequisites() { # Rust if ! command -v rustc >/dev/null 2>&1; then - print_error "Rust is not installed. Install from https://rustup.rs/" + print_error "Rust is not installed." exit 1 fi - print_success "Rust found: $(rustc --version)" + 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 @@ -234,10 +226,16 @@ check_prerequisites() { # cargo-ndk if ! command -v cargo-ndk >/dev/null 2>&1; then - print_error "cargo-ndk is not installed. Run: cargo install cargo-ndk" + print_error "cargo-ndk $CARGO_NDK_VERSION is not installed." exit 1 fi - print_success "cargo-ndk found: $(cargo-ndk --version 2>/dev/null || echo 'installed')" + 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 @@ -247,17 +245,18 @@ check_prerequisites() { fi print_success "Android NDK found: $ANDROID_NDK_HOME" - # NDK version (should be 25+) - NDK_VERSION="$(basename "$ANDROID_NDK_HOME" | cut -d'.' -f1)" - if ! [[ "$NDK_VERSION" =~ ^[0-9]+$ ]]; then - print_error "Could not parse NDK version from ANDROID_NDK_HOME: $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 - if [ "$NDK_VERSION" -lt 25 ]; then - print_error "NDK version $NDK_VERSION is too old. NDK 25+ required for 16KB page size support" + 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 "NDK version: $NDK_VERSION (supports 16KB page size)" + print_success "Android NDK version verified: $actual_ndk_version" detect_ndk_host if [ ! -d "$NDK_LLVM_BIN" ]; then @@ -267,10 +266,10 @@ check_prerequisites() { print_success "NDK host tag: $NDK_HOST" if [ ! -x "$LLVM_STRIP" ]; then - print_info "llvm-strip not found at: $LLVM_STRIP (stripping will be skipped)" - else - print_success "llvm-strip found" + 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." @@ -301,7 +300,7 @@ check_prerequisites() { } clone_or_update_arti() { - print_header "Setting up Arti Source (version: $VERSION)" + 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..." @@ -310,29 +309,32 @@ clone_or_update_arti() { if [ ! -d "$ARTI_SOURCE_DIR" ]; then print_info "Cloning official Arti repository..." - git clone https://gitlab.torproject.org/tpo/core/arti.git "$ARTI_SOURCE_DIR" + 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 tags..." - git fetch --tags --quiet + print_info "Fetching pinned commit..." + git fetch --depth 1 origin "$ARTI_COMMIT" --quiet - print_info "Checking out version: $VERSION" - git checkout "$VERSION" --quiet 2>/dev/null || { - print_error "Version $VERSION not found. Available versions:" - git tag | grep "^arti-v" | tail -10 - exit 1 - } + 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 - print_success "Arti source ready at version $VERSION" + 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 @@ -359,11 +361,43 @@ setup_wrapper() { 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]}" @@ -396,10 +430,8 @@ build_for_target() { # Strip debug symbols safely print_info "Stripping debug symbols..." if [ -x "$LLVM_STRIP" ]; then - "$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null || true + "$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" print_success "Stripped debug symbols" - else - print_info "Skipping strip (llvm-strip not available)" fi local SIZE @@ -434,6 +466,44 @@ build_for_target() { 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" @@ -525,11 +595,11 @@ show_summary() { echo "" echo -e "${GREEN}Next steps:${NC}" echo " 1. Test the build: ./gradlew assembleDebug" - echo " 2. Commit the .so files: git add app/src/main/jniLibs/" + echo " 2. Verify checksums: tools/arti-build/verify-checksums.sh" echo "" echo -e "${GREEN}To update Arti version:${NC}" - echo " 1. Edit ARTI_VERSION with new version tag (e.g., arti-v1.8.0)" - echo " 2. Run: ./build-arti.sh --clean" + echo " 1. Update TOOLCHAIN.env and regenerate Cargo.lock" + echo " 2. Run: tools/arti-build/rebuild-in-container.sh" echo "" } @@ -547,6 +617,10 @@ main() { 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 @@ -554,31 +628,11 @@ main() { done verify_jni_symbols + verify_no_host_paths + if [ "$RELEASE_ONLY" = false ]; then + write_checksums + fi show_summary } -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" ]; then - print_success "Cargo.lock already exists" - echo "" - return 0 - fi - - print_info "Cargo.lock missing; generating it once (network access may be required)..." - (cd "$WRAPPER_DIR" && cargo generate-lockfile) - - if [ ! -f "$LOCKFILE" ]; then - print_error "Failed to generate Cargo.lock at $LOCKFILE" - return 1 - fi - - print_success "Generated Cargo.lock" - echo "" -} - 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/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/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 821ce844..7abe8085 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -44,6 +44,10 @@ 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", @@ -55,6 +59,14 @@ PERMISSIONS = [ "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 @@ -65,11 +77,25 @@ def _shell(serial: str, command: str) -> str: class Device: - """One ADB-connected phone running a debug build with the test hook.""" + """One ADB-connected device running a debug build with the test hook.""" - def __init__(self, serial: str, alias: str): + 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 ------------------------------------------------------ @@ -82,24 +108,42 @@ class Device: raise MeshLabError(f"[{self.alias}] install failed: {result.stdout} {result.stderr}") def grant_permissions(self) -> None: - for perm in PERMISSIONS: + for perm in self.permissions: subprocess.run( - [find_adb(), "-s", self.serial, "shell", "pm", "grant", APPLICATION_ID, perm], + [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 {APPLICATION_ID}") - output = _shell(self.serial, f"pm clear {APPLICATION_ID}") + _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 {APPLICATION_ID}") + _shell(self.serial, f"am force-stop {self.package}") def launch(self) -> None: - _shell(self.serial, f"monkey -p {APPLICATION_ID} -c android.intent.category.LAUNCHER 1") - time.sleep(3) + """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). @@ -151,18 +195,19 @@ class Device: ) if result.returncode != 0: raise MeshLabError(f"[{self.alias}] push failed: {result.stderr}") - target = f"{APP_FIXTURE_DIR}/{fname}" + fixture_dir = f"/data/data/{self.package}/cache/fixtures" + target = f"{fixture_dir}/{fname}" _shell( self.serial, - f"run-as {APPLICATION_ID} mkdir -p {APP_FIXTURE_DIR} && " - f"cat {tmp} | run-as {APPLICATION_ID} sh -c 'cat > {target}' && rm -f {tmp}", + 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 {APPLICATION_ID} rm -rf cache/files/incoming cache/images/incoming", + f"run-as {self.package} rm -rf cache/files/incoming cache/images/incoming", ) # -- test hook commands ------------------------------------------------- @@ -170,11 +215,11 @@ class Device: 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 {APPLICATION_ID} rm -f {RESULTS_DIR}/{cmd_id}.json") + _shell(self.serial, f"run-as {self.package} rm -f {RESULTS_DIR}/{cmd_id}.json") args = [ - "am", "broadcast", "-a", TEST_HOOK_ACTION, - "-n", TEST_HOOK_COMPONENT, + "am", "broadcast", "-a", self.hook_action, + "-n", self.hook_component, "--es", "cmd", cmd, "--es", "id", cmd_id, "--el", "timeout_ms", str(timeout_ms), @@ -186,7 +231,9 @@ class Device: if isinstance(value, bool): args += ["--ez", key, "true" if value else "false"] elif isinstance(value, int): - args += ["--el", key, str(value)] + # `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: @@ -199,7 +246,7 @@ class Device: deadline = time.monotonic() + (timeout_ms + 60_000) / 1000 while time.monotonic() < deadline: try: - raw = _shell(self.serial, f"run-as {APPLICATION_ID} cat {RESULTS_DIR}/{cmd_id}.json") + 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: @@ -217,6 +264,35 @@ class Device: 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 = { @@ -241,10 +317,41 @@ def make_fixtures(directory: Path, seed: int = 1337, names: list[str] | None = N 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: Path | None, nickname_a: str, nickname_b: str) -> None: - for device, nickname in ((a, nickname_a), (b, nickname_b)): +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: @@ -317,6 +424,101 @@ def scenario_dm(a: Device, b: Device) -> dict: } +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"] @@ -330,7 +532,103 @@ def scenario_broadcast(a: Device, b: Device) -> dict: return {"send": send_result, "recv": recv_result} -def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool = False) -> dict: +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 @@ -339,7 +637,9 @@ def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool remote = a.push_fixture(fixture["path"]) send_kwargs: dict[str, object] = {"path": remote} if private: - send_kwargs["peer"] = id_b + 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) @@ -357,6 +657,23 @@ def scenario_file(a: Device, b: Device, fixtures: dict[str, dict], private: bool 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() @@ -404,19 +721,33 @@ def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None: 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. + 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 in ((a, id_b), (b, id_a)): - result = device.cmd("connect", timeout_ms=45_000, peer=peer) - if result.get("status") == "ok" and result.get("direct"): - continue - # 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 not match or not match.get("direct"): - raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={result}") + 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: @@ -536,7 +867,10 @@ def scenario_file_oversize(a: Device, b: Device, fixtures: dict[str, dict]) -> d 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( @@ -553,18 +887,46 @@ SCENARIOS = { 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": - evidence["results"] = {n: run_scenario(n, a, b, None)["results"] for n in SCENARIOS} + 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" @@ -587,45 +949,64 @@ def build_parser() -> argparse.ArgumentParser: setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover") setup.add_argument("--serial-a", required=True) - setup.add_argument("--serial-b", 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", 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 extra (repeatable)") + 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( - Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"), - args.apk, args.nickname_a, args.nickname_b, + 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": - evidence = run_scenario( - args.name, Device(args.serial_a, "alpha"), Device(args.serial_b, "beta"), args.out - ) + 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] = int(value) if value.isdigit() else value + 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 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..e177c3e2 --- /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_002 + versionName = "0.1.1" + + 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..fd03e9f3 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -0,0 +1,563 @@ +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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.Alignment +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.TextButton +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.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) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Message alerts", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + 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) + ) + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + onResult(true) + } + } + ) { + Text("Enable") + } + TextButton(onClick = onSkip) { + Text("Not now") + } + } +} + +@Composable +fun PermissionRequestScreen(onGranted: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { onGranted() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "bitchat", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + 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) + ) + Button(onClick = { + launcher.launch(MainActivity.requiredPermissions().toTypedArray()) + }) { + Text("Grant access") + } + } +} + +@Composable +fun BluetoothEnableScreen(onEnabled: () -> Unit) { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { onEnabled() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Bluetooth is off", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + 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..72bdc8af --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -0,0 +1,259 @@ +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.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.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(38.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(38.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)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f)) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .onGloballyPositioned { coords -> + onCancelBounds( + androidx.compose.ui.geometry.Rect( + coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero), + coords.size.toSize() + ) + ) + } + .size(52.dp) + .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(26.dp) + ) + } + } + WaveformBars( + samples = voice.liveSamples, + progress = 1f, + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth() + .height(44.dp) + ) + Text( + text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format( + voice.elapsedMs / 1000 / 60, + voice.elapsedMs / 1000 % 60 + ) + " / 0:10", + style = ChatVisualTokens.SenderStyle, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 10.dp) + ) + 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.padding(top = 2.dp) + ) + } + } +} 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..0bcdfed3 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -0,0 +1,341 @@ +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.PaddingValues +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.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +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.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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 any downward 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 + } + + // One state drives both overlays: visible at the bottom or when scrolling toward it, + // hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately + // small so the controls answer every flick immediately. + var atNewest by remember { mutableStateOf(true) } + val controlsVisible = remember { mutableStateOf(true) } + LaunchedEffect(columnState) { + var lastPosition = -1 + snapshotFlow { + val first = columnState.layoutInfo.visibleItems.firstOrNull() + Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0) + }.collect { (canScrollForward, index, offset) -> + val position = index * 100_000 + offset + atNewest = !canScrollForward + if (!canScrollForward) { + controlsVisible.value = true + } else if (lastPosition >= 0) { + when { + position > lastPosition + 24 -> controlsVisible.value = true + position < lastPosition - 24 -> controlsVisible.value = false + } + } + lastPosition = position + } + } + + // Stick to bottom: follow new messages while resting at the newest. + // Capture this when the message count changes, before the new layout can temporarily make + // canScrollForward true and report that the user is browsing history. + val followNewest = remember(messages.size) { atNewest } + LaunchedEffect(columnState, messages.size) { + if (messages.isNotEmpty() && followNewest) { + 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. + columnState.scroll { scrollBy(Float.MAX_VALUE) } + } + } + } + + ChatBody( + messages = messages, + myPeerID = myPeerID, + emptyText = emptyText, + voice = voice, + onOpenImage = onOpenImage, + columnState = columnState, + controlsVisible = controlsVisible.value, + header = header, + actionBar = actionBar, + modifier = Modifier.fillMaxSize() + ) +} + +internal data class MeasuredChatLayout( + val itemCount: Int, + val singleVisibleItemKey: Any? +) + +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() +} + +@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() + + // 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) { + TransformingLazyColumn( + state = columnState, + modifier = Modifier.fillMaxSize(), + // Arrangement.Bottom anchors short content to the bottom: the first message + // starts just above the action bar and new messages push history upward. + // The padding reserves permanent room for the floating header and action + // bar; being constant, it never disturbs an in-flight scroll gesture. + verticalArrangement = Arrangement.Bottom, + contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp) + ) { + 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)) { + 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 +// 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..d91f864b --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -0,0 +1,275 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.verticalScroll +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.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) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon" + ) + val titleSize by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle" + ) + val vPadding by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad" + ) + + // 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. + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenPeople() } + .padding(horizontal = 8.dp, vertical = vPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (unreadDms == 0) { + Text( + text = "bitchat", + style = MaterialTheme.typography.titleSmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() }, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 8.dp) + ) + } + Icon( + imageVector = Icons.Filled.People, + contentDescription = "people", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(iconSize) + ) + Text( + text = "$peerCount", + style = MaterialTheme.typography.bodySmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { + (iconSize.value * 0.85f).dp.toSp() + }, + 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 = "$unreadDms", + style = MaterialTheme.typography.bodySmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { + (iconSize.value * 0.85f).dp.toSp() + }, + 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.SystemActionStyle, + fontSize = 9.sp, + 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..1d179a0b --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -0,0 +1,209 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.verticalScroll +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.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.Alignment +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.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) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon" + ) + val headerTitleSize by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle" + ) + val headerVPadding by androidx.compose.animation.core.animateDpAsState( + targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad" + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + onClickLabel = "Open user details", + onClick = onClick + ) + .padding(horizontal = 8.dp, vertical = headerVPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = nickname, + style = MaterialTheme.typography.titleSmall, + fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { + headerTitleSize.toSp() + }, + fontWeight = FontWeight.Bold, + color = colorForPeer(nickname + peerID, palette) + ) + 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..68710025 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/NicknameSetupScreen.kt @@ -0,0 +1,134 @@ +package com.bitchat.watch.ui + +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.fillMaxSize +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() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp, bottom = 10.dp) + ) + 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() + } + } + ) + Button( + onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) }, + enabled = name.text.isNotBlank(), + modifier = Modifier.padding(top = 10.dp) + ) { + Text(confirmLabel) + } + } +} 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..3cb86df3 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -0,0 +1,266 @@ +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) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + 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..f73891f9 --- /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(38.dp) + ) { + Icon( + imageVector = Icons.Filled.Mic, + contentDescription = "dictate", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { send() }, + enabled = text.isNotBlank(), + modifier = Modifier.size(38.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..a36c7ec6 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/UserDetailScreen.kt @@ -0,0 +1,213 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.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.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() + val palette = LocalBitchatPalette.current + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.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), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + 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 { + "Verification code" + }, + style = ChatVisualTokens.SenderStyle, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Compare cryptographic fingerprints", + 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..c4c4ef0f --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/VerificationCodeScreen.kt @@ -0,0 +1,177 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.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() + val palette = LocalBitchatPalette.current + + ScreenScaffold(scrollState = listState) { + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.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" + } + ) + } + } + } + } +} + +@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 = 10.sp, + lineHeight = 13.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(4) + .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/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/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..e27f132e --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -0,0 +1,320 @@ +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.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.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.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) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { onClose() }, + contentAlignment = Alignment.Center + ) { + val bitmap = remember(path) { BitmapFactory.decodeFile(path) } + if (bitmap != null) { + Image( + painter = BitmapPainter(bitmap.asImageBitmap()), + contentDescription = "image fullscreen", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "close", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 24.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..ff66567d --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/theme/Typography.kt @@ -0,0 +1,43 @@ +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 = 11.sp, + lineHeight = 14.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..0b3f85b5 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -0,0 +1,82 @@ +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 `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/WearPeerIdentityStateTest.kt b/wear/src/test/java/com/bitchat/watch/ui/WearPeerIdentityStateTest.kt new file mode 100644 index 00000000..1b241690 --- /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(4, formatted.lines().size) + assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size }) + } +}