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..af80579a --- /dev/null +++ b/.agents/skills/android-readme-screenshot-studio/SKILL.md @@ -0,0 +1,249 @@ +--- +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 Pixel 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, +and the final acceptance checklist. + +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; +- Pixel profile, physical resolution, logical width, 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 Pixel canvas + +Prefer the newest stable Android runtime installed locally and a large Pixel Pro +profile. For the current README style, target at least: + +- a 448 dp logical width; +- 1344 px physical width; +- a native portrait height near 2992 px; +- 480 dpi or the profile's native density. + +Pixel 9 Pro XL at 1344×2992 and 480 dpi is a known-good baseline, not a +hard-coded requirement. A newer stable large Pixel profile is acceptable when +its guest properties are verified. + +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. + +## 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. + +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. +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, Pixel/runtime facts, synthetic fixture + disclosure, validation commands, and limitations. +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..029d2b0c --- /dev/null +++ b/.agents/skills/android-readme-screenshot-studio/evals/evals.json @@ -0,0 +1,43 @@ +{ + "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 Pixel, open a PR, and merge it after checks pass.", + "expected_output": "The agent works in a fresh latest-main worktree, captures the real app on a verified large Pixel 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 large Pixel profile.", + "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." + ] + } + ] +} 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..ac8542cc --- /dev/null +++ b/.agents/skills/android-readme-screenshot-studio/references/showcase-recipes.md @@ -0,0 +1,172 @@ +# 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. + +## 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. + +For a Pixel 9 Pro XL profile at 1344×2992, a 1344×2780 final image was a +known-good result in one verified run. Treat those numbers as evidence, not as a +universal crop rule. + +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. + +### 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. + +### 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.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/README.md b/README.md index fe1bab8e..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. 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 8aa8a303..7379b5ef 100644 --- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt +++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt @@ -5,6 +5,12 @@ 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 @@ -80,6 +86,8 @@ object TestHookDriver { "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")) @@ -491,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/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 8f503fb5..a56182bc 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -63,6 +63,27 @@ 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 @@ -103,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 @@ -129,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") @@ -162,10 +189,20 @@ class HotspotManager(private val context: Context) { /** * 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 @@ -177,19 +214,28 @@ class HotspotManager(private val context: Context) { val staleChannel = channel channel = null - if (staleChannel != null) { - wifiP2pManager?.removeGroup(staleChannel, object : ActionListener { - override fun onSuccess() { - Log.d(TAG, "Group removed successfully") - // Nothing of ours is left for a later run to clean up. - ownedGroupName = null - closeChannel(staleChannel) - } - override fun onFailure(reason: Int) { - Log.w(TAG, "Failed to remove group: $reason") - closeChannel(staleChannel) - } - }) + 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 @@ -209,6 +255,84 @@ class HotspotManager(private val context: Context) { currentGroup = null callback = null + + if (teardownAction != null) { + teardownAction.invoke() + } else { + finishTeardown() + } + } + + /** + * Re-check the device-scoped group immediately before removing it. The last poll + * is only a snapshot: our group may have disappeared and another app may have + * claimed Wi-Fi Direct before stop was requested. + */ + @SuppressLint("MissingPermission") + private fun removeOwnGroupIfStillPresent(ch: Channel, expectedGroupName: String?) { + val manager = wifiP2pManager ?: run { + closeChannel(ch) + finishTeardown() + return + } + + try { + manager.requestGroupInfo(ch) { group -> + val stillOurs = HotspotStartupPolicy.isExpectedHostedGroup( + existingGroupName = group?.networkName, + isGroupOwner = group?.isGroupOwner == true, + expectedGroupName = expectedGroupName + ) + if (!stillOurs) { + Log.i( + TAG, + "Current group '${group?.networkName}' is not ours " + + "('$expectedGroupName'); leaving it alone" + ) + closeChannel(ch) + finishTeardown() + return@requestGroupInfo + } + + try { + manager.removeGroup(ch, object : ActionListener { + override fun onSuccess() { + Log.d(TAG, "Group removed successfully") + clearOwnedGroupNameIfMatches(expectedGroupName) + closeChannel(ch) + finishTeardown() + } + + override fun onFailure(reason: Int) { + Log.w(TAG, "Failed to remove group: $reason") + closeChannel(ch) + finishTeardown() + } + }) + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while removing the group", e) + closeChannel(ch) + finishTeardown() + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while confirming group ownership", e) + closeChannel(ch) + finishTeardown() + } + } + + private fun clearOwnedGroupNameIfMatches(removedGroupName: String?) { + if (HotspotStartupPolicy.shouldClearOwnedGroupName(ownedGroupName, removedGroupName)) { + ownedGroupName = null + } + } + + private fun finishTeardown() { + teardownInProgress = false + val callbacks = teardownCallbacks.toList() + teardownCallbacks.clear() + callbacks.forEach { it.invoke() } } /** @@ -305,7 +429,8 @@ class HotspotManager(private val context: Context) { val action = HotspotStartupPolicy.startAction( p2pState = lastP2pState, existingGroupName = existingGroup?.networkName, - ownedGroupName = ownedGroupName + ownedGroupName = ownedGroupName, + confirmedGroupName = confirmedReplacementName ) when (action) { @@ -313,7 +438,19 @@ class HotspotManager(private val context: Context) { Log.w(TAG, "Not attempting group creation: ${action.message}") failStartup(action.message) } - HotspotStartupPolicy.StartAction.Create -> createGroup(attempt) + 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) @@ -327,33 +464,46 @@ class HotspotManager(private val context: Context) { } private fun removeStaleGroup(ch: Channel, attempt: Int) { - wifiP2pManager?.removeGroup(ch, object : ActionListener { - override fun onSuccess() { - if (channel !== ch) return - Log.d(TAG, "Stale group removed") - createGroup(attempt) - } - 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) - } - }) + 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. - ownedGroupName = savedSsid + if (oldGroupCleared) { + ownedGroupName = savedSsid + } // Android 10+: Custom SSID and password val config = WifiP2pConfig.Builder() @@ -376,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() @@ -434,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. */ @@ -472,29 +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) - // Authoritative name straight from the framework - group.networkName?.let { 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()) - } - } 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) { @@ -503,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. */ @@ -618,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 index 48284d0a..80d6f4ee 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotStartupPolicy.kt @@ -19,8 +19,6 @@ internal object HotspotStartupPolicy { 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 FOREIGN_GROUP_MESSAGE = - "Another app is using Wi-Fi Direct. Close it and try again." 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." @@ -32,6 +30,10 @@ internal object HotspotStartupPolicy { 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 } @@ -42,29 +44,55 @@ internal object HotspotStartupPolicy { * 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, so only groups - * we can show are ours get torn down. + * 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? + 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 - else -> StartAction.Fail(FOREIGN_GROUP_MESSAGE) + existingGroupName == confirmedGroupName -> StartAction.RemoveStaleGroupThenCreate + else -> StartAction.ConfirmReplaceExisting } /** - * Primary signal is the name we recorded creating. The SSID prefix is only a - * fallback, covering orphans left by builds that predate that record. + * 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 = - existingGroupName == ownedGroupName || existingGroupName.startsWith(SSID_PREFIX) + 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` 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 581b1d99..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,6 +1,8 @@ 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 @@ -18,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) @@ -25,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 @@ -43,7 +62,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) 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. - WifiAwareController.holdForHotspot() + awareLease = WifiAwareController.acquireHotspotLease() // Start hotspot val manager = HotspotManager(context) @@ -94,10 +113,21 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) } } + override fun onExistingGroupConflict(groupName: String) { + viewModelScope.launch { + // Nothing was disturbed; the manager already stopped + // itself. Release our resources and ask the user. + teardown() + pendingApk = apkFile + pendingGroupName = groupName + _state.value = HotspotState.ConfirmDisconnect + } + } + override fun onError(message: String) { viewModelScope.launch { failWith(message) } } - }) + }, confirmedGroupName) } catch (e: Exception) { Log.e(TAG, "Error starting hotspot", e) @@ -106,11 +136,34 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) } } + /** 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 } @@ -125,6 +178,8 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) */ private fun failWith(message: String) { Log.e(TAG, "Hotspot failed: $message") + pendingApk = null + pendingGroupName = null teardown() _state.value = HotspotState.Error(message) } @@ -134,10 +189,34 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application) webServer?.stopServer() webServer = null - hotspotManager?.stopHotspot() + val manager = hotspotManager hotspotManager = null - WifiAwareController.releaseHotspotHold() + // 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 + ) } /** @@ -160,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/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..40460ceb 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -616,6 +616,16 @@ class BluetoothGattClientManager( Log.d(TAG, "Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes") } } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) { + delegate?.onGattClientWriteComplete(gatt.device.address, linkID, status) + } + } override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { val deviceAddress = gatt.device.address 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 9c8f8eef..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,6 +112,8 @@ 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( @@ -129,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() @@ -607,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) } @@ -949,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, @@ -1583,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 093e3766..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 @@ -111,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 = @@ -122,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() @@ -481,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) } } @@ -611,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, @@ -987,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/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/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index fffcdab7..4125d5ca 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -242,6 +242,7 @@ class NostrDirectMessageHandler( } NoisePayloadType.VERIFY_CHALLENGE, NoisePayloadType.VERIFY_RESPONSE, + NoisePayloadType.VOICE_FRAME, NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. } } 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/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 47ee8d75..9ef9e969 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -17,7 +17,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? { 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/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 66633fee..c4f52675 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -196,6 +196,29 @@ object AppStateStore { } } + /** Replace a live media row by ID, or append it if the row was not admitted yet. */ + fun upsertPublicMessage(msg: BitchatMessage) { + synchronized(this) { + val index = _publicMessages.value.indexOfFirst { it.id == msg.id } + if (index >= 0) { + _publicMessages.value = _publicMessages.value.toMutableList().also { it[index] = msg } + } else { + seenMessageIds.add(msg.id) + seenPublicMessageKeys.add(publicMessageKey(msg)) + _publicMessages.value = _publicMessages.value + msg + } + } + } + + fun removePublicMessage(messageID: String) { + synchronized(this) { + val existing = _publicMessages.value.firstOrNull { it.id == messageID } ?: return + _publicMessages.value = _publicMessages.value.filterNot { it.id == messageID } + seenMessageIds.remove(messageID) + seenPublicMessageKeys.remove(publicMessageKey(existing)) + } + } + fun addPrivateMessage( peerID: String, msg: BitchatMessage, @@ -204,6 +227,38 @@ object AppStateStore { 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. @@ -280,11 +335,7 @@ object AppStateStore { counts[conversationID] = (counts[conversationID] ?: 0) + 1 _unreadPrivateMessageCounts.value = counts } - val aliases = runCatching { - ContactDirectory.aliasesForConversation(peerID) + - ContactDirectory.aliasesForConversation(conversationID) + - listOfNotNull(msg.senderPeerID) - }.getOrDefault(setOf(peerID, conversationID)) + val aliases = privateConversationAliases(peerID, conversationID) val displayName = ContactDirectory.resolve(conversationID).displayName ?: msg.sender.takeUnless { it.isBlank() || it == "system" || it == _nickname.value @@ -320,11 +371,7 @@ object AppStateStore { _selectedPrivateChatPeer.value ?.let(ContactDirectory::canonicalConversationId) ?.equals(conversationID, ignoreCase = true) == true - val aliases = runCatching { - ContactDirectory.aliasesForConversation(peerID) + - ContactDirectory.aliasesForConversation(conversationID) + - listOfNotNull(msg.senderPeerID) - }.getOrDefault(setOf(peerID, conversationID)) + val aliases = privateConversationAliases(peerID, conversationID) val displayName = ContactDirectory.resolve(conversationID).displayName ?: (existingMessages + msg) .lastOrNull { candidate -> @@ -343,6 +390,20 @@ object AppStateStore { ) } + /** + * 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 } 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/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 f58e2cc4..cf0faaa1 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -41,6 +41,7 @@ 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.Public +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.Warning @@ -482,6 +483,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() @@ -520,6 +524,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, 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 6b76a2bf..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) @@ -531,6 +554,7 @@ fun NicknameEditor( } ), modifier = Modifier + .weight(1f) .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) @@ -546,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 @@ -603,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), @@ -703,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, @@ -726,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 = "/", @@ -744,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 - ) } } } @@ -818,7 +832,8 @@ private fun MainHeader( @Composable private fun LocationChannelsButton( viewModel: ChatViewModel, - onClick: () -> Unit + onClick: () -> Unit, + showLabel: Boolean ) { val colorScheme = MaterialTheme.colorScheme @@ -845,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 30e972c2..4d350ee4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -120,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) @@ -142,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) } } @@ -157,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() } } @@ -400,6 +415,7 @@ fun ChatScreen(viewModel: ChatViewModel) { onSendFileNote = { peer, onionOrChannel, path -> viewModel.sendFileNote(peer, onionOrChannel, path) }, + recorderFactory = viewModel::createVoiceRecorder, showCommandSuggestions = showCommandSuggestions, commandSuggestions = commandSuggestions, @@ -517,6 +533,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, @@ -621,8 +641,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 @@ -699,6 +724,8 @@ fun ChatInputSection( nickname = nickname, showMediaButtons = showMediaButtons, mentionPeerIdentities = mentionPeerIdentities, + recorderFactory = recorderFactory, + activePublicTalker = activePublicTalker, modifier = Modifier.fillMaxWidth() ) } @@ -794,6 +821,7 @@ private fun ChatDialogs( onAppInfoDismiss: () -> Unit, showLocationChannelsSheet: Boolean, onLocationChannelsSheetDismiss: () -> Unit, + onLocationNotesFromChannelsClick: () -> Unit, showLocationNotesSheet: Boolean, onLocationNotesSheetDismiss: () -> Unit, showUserSheet: Boolean, @@ -840,6 +868,7 @@ private fun ChatDialogs( LocationChannelsSheet( isPresented = showLocationChannelsSheet, onDismiss = onLocationChannelsSheetDismiss, + onLocationNotesClick = onLocationNotesFromChannelsClick, viewModel = viewModel ) } 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 61c88523..0cb0fdc5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -39,6 +39,9 @@ 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, @@ -73,6 +76,22 @@ class ChatViewModel( 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) } @@ -1445,10 +1464,13 @@ class ChatViewModel( 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) { } + try { + com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() + } catch (_: Exception) { } // Clear all cryptographic data clearAllCryptographicData() 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 cc429dc8..1269fe6b 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -71,6 +71,7 @@ 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 @@ -317,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 @@ -325,6 +328,7 @@ 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) { @@ -484,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 ), @@ -518,7 +524,8 @@ fun MessageInput( // scrolls off the left edge while live data streams in from the right. val secs = (elapsedMs / 1000).toInt() Text( - text = String.format("%02d:%02d", secs / 60, secs % 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 @@ -628,12 +635,18 @@ fun MessageInput( VoiceRecordButton( isRecording = isRecording, + recorderFactory = recorderFactory?.let { factory -> + { factory(latestSelectedPeer.value, latestChannel.value) } + }, + courtesyActive = selectedPrivatePeer == null && currentChannel == null && + activePublicTalker != null, shouldCancel = { pos -> cancelBounds?.inflate(cancelSlackPx)?.contains(pos) == true }, onTrackFinger = { cancelFinger = it }, - onStart = { + onStart = { live -> isRecording = true + isLiveRecording = live elapsedMs = 0L // Keep existing focus to avoid IME collapse, but do not // force-show the keyboard. @@ -647,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 -> @@ -667,6 +681,7 @@ fun MessageInput( // waveform over an empty field. onCancel = { isRecording = false + isLiveRecording = false amplitude = 0 elapsedMs = 0L } 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 8e8d6e5c..c7729922 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 @@ -108,16 +110,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() @@ -132,6 +139,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() @@ -153,8 +172,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) } } } @@ -173,6 +191,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( @@ -487,15 +516,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) } ) } @@ -529,6 +550,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() } @@ -664,6 +722,7 @@ private fun ChannelOptionRow( titleColor: Color? = null, titleBold: Boolean = false, leadingIcon: ImageVector? = null, + leadingIconRes: Int? = null, trailingContent: (@Composable (() -> Unit))? = null, onClick: () -> Unit ) { @@ -697,6 +756,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) + ) + } } } @@ -1111,6 +1178,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/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 93b364e2..f6d90538 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1872,6 +1872,7 @@ fun PrivateChatSheet( onSendFileNote = { peer, channel, path -> viewModel.sendFileNote(peer, channel, path) }, + recorderFactory = viewModel::createVoiceRecorder, showCommandSuggestions = false, commandSuggestions = emptyList(), showMentionSuggestions = false, 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 563f1f02..1545cd2c 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -62,6 +62,8 @@ 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. @@ -79,7 +81,7 @@ fun VoiceRecordButton( * cancel target); null once the gesture ends. */ onTrackFinger: (Offset?) -> Unit = {}, - onStart: () -> Unit, + onStart: (isLive: Boolean) -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, onFinish: (filePath: String) -> Unit, /** @@ -109,6 +111,7 @@ fun VoiceRecordButton( 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. @@ -130,7 +133,7 @@ fun VoiceRecordButton( ampJob = null if (isCapturing) { isCapturing = false - runCatching { recorder?.stop() } + runCatching { recorder?.stop(canceled = true) } recorder = null recordedFilePath = null latestOnTrackFinger.value(null) @@ -141,8 +144,11 @@ 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) { @@ -169,12 +175,12 @@ fun VoiceRecordButton( } if (releasedEarly != null || stolenDuringArm) return@awaitEachGesture - val rec = VoiceRecorder(context) + 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() } + runCatching { rec.stop(canceled = true) } latestOnCancel.value() return@awaitEachGesture } @@ -183,7 +189,7 @@ fun VoiceRecordButton( recordedFilePath = startedFile.absolutePath recordingStart = System.currentTimeMillis() isCapturing = true - latestOnStart.value() + latestOnStart.value(rec.isLive) buzz() ampJob?.cancel() @@ -198,7 +204,7 @@ fun VoiceRecordButton( 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() @@ -237,10 +243,10 @@ fun VoiceRecordButton( withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() } } if (isCapturing) { - val file = recorder?.stop() + val file = recorder?.stop(canceled = cancel) isCapturing = false recorder = null - val path = file?.absolutePath ?: recordedFilePath + val path = file?.absolutePath ?: recordedFilePath?.takeIf { File(it).isFile } recordedFilePath = null if (cancel) { path?.let { runCatching { File(it).delete() } } 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 index 7f79a00f..42f41f8e 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeMath.kt @@ -12,6 +12,33 @@ 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 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 index 6908c716..6c62d0ef 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt @@ -4,6 +4,7 @@ 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 @@ -12,7 +13,9 @@ 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 /** @@ -20,6 +23,7 @@ import kotlin.math.pow * 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, @@ -38,12 +42,15 @@ class GlobeState( 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 @@ -108,20 +115,29 @@ class GlobeState( 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 { - 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() + 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 } - syncSelection() } } } @@ -144,37 +160,64 @@ class GlobeState( animateTo(targetLat, targetLon, targetZoom, targetPrecision, durationMs = 1400) } - /** Inertial spin after a fling. Velocities are in px/ms. */ + /** Inertial spin after a fling. Velocities are in px/second. */ fun fling(velocityX: Float, velocityY: Float) { val s = scope ?: return - if (abs(velocityX) < 0.05f && abs(velocityY) < 0.05f) 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 { - var vx = velocityX - var vy = velocityY - var lastTime = System.nanoTime() - while (abs(vx) > 0.02f || abs(vy) > 0.02f) { - val now = System.nanoTime() - val dtMs = ((now - lastTime) / 1_000_000f).coerceAtMost(50f) - lastTime = now - rotateBy(vx * dtMs, vy * dtMs) - val decay = 0.94f.pow(dtMs / 16f) - vx *= decay - vy *= decay - kotlinx.coroutines.delay(16) + 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() { 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 index 8d7f3937..7066cf92 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt @@ -10,6 +10,7 @@ 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 @@ -17,18 +18,24 @@ 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 @@ -59,6 +66,83 @@ data class GlobeColors( 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, @@ -117,6 +201,7 @@ fun GlobeView( snapshotFlow { state.selectedGeohash } .drop(1) .collect { + @Suppress("DEPRECATION") view.performHapticFeedback( HapticFeedbackConstants.KEYBOARD_TAP, HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING @@ -124,11 +209,55 @@ fun GlobeView( } } + 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() } - Canvas( - modifier = modifier + 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) @@ -142,40 +271,45 @@ fun GlobeView( state.isInteracting = true val downTime = SystemClock.uptimeMillis() val downPos = down.position - var maxPointers = 1 var moved = Offset.Zero - val panTimes = ArrayDeque() - val panVec = ArrayDeque() + 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 - maxPointers = maxOf(maxPointers, pressed.size) + if (pressed.size > 1) { + hadMultiplePointers = true + velocityTracker.resetTracking() + } val pan = event.calculatePan() val zoomChange = event.calculateZoom() if (pan != Offset.Zero) { - state.rotateBy(pan.x, pan.y) moved += pan - val now = SystemClock.uptimeMillis() - panTimes.addLast(now) - panVec.addLast(pan) - while (panTimes.isNotEmpty() && now - panTimes.first() > 120) { - panTimes.removeFirst() - panVec.removeFirst() + 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 = maxPointers == 1 && + val isTap = !hadMultiplePointers && + !dragStarted && upTime - downTime < 400 && moved.getDistance() < viewConfiguration.touchSlop @@ -205,15 +339,13 @@ fun GlobeView( } } } - } else if (panVec.isNotEmpty()) { - var sx = 0f; var sy = 0f - panVec.forEach { sx += it.x; sy += it.y } - val windowMs = (SystemClock.uptimeMillis() - panTimes.first()).coerceAtLeast(1) - state.fling(sx / windowMs, sy / windowMs) + } 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 @@ -221,15 +353,7 @@ fun GlobeView( val r = state.globeRadiusPx val cLat = state.centerLat.toDouble() val cLon = state.centerLon.toDouble() - - // Starfield - stars.forEach { s -> - drawCircle( - color = colors.star.copy(alpha = s.alpha), - radius = s.radius * density.density, - center = Offset(s.x * size.width, s.y * size.height) - ) - } + val preparedProjector = GlobeMath.PreparedProjector(cLat, cLon) // Atmosphere glow drawCircle( @@ -259,17 +383,44 @@ fun GlobeView( val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f) - // Graticule - drawGraticule(cx, cy, r, cLat, cLon, colors.graticule, clip) + val frameDetail = globeFrameDetail( + if (state.isInMotion) motionDetail else GlobeMotionDetail.FULL + ) - // Landmasses + // 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) { - drawLandRing(ring, scratch, cx, cy, r, cLat, cLon, colors, clip) + 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 - for (line in borders) { - drawBorderLine(line, borderScratch, cx, cy, r, cLat, cLon, colors, 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 @@ -297,45 +448,70 @@ fun GlobeView( center = Offset(cx, cy) ) - // Cities (dots + names) over the shaded sphere - drawCities( - cities, state, cx, cy, r, cLat, cLon, colors, - labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density - ) - - // Geohash cells - if (state.selectedGeohash.isNotEmpty()) { - drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip) - } - - // Labels - if (state.selectedGeohash.isNotEmpty()) { - drawGeohashLabels( - state, cx, cy, r, cLat, cLon, colors, - labelPaint, haloPaint, labelTypeface, labelTypefaceBold, - labelTextSize, labelTextSizeSmall + // 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 ) } - // Center crosshair - val crossAlpha = if (state.isInteracting) 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)) + // 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 + cx: Float, + cy: Float, + r: Float, + cLat: Double, + cLon: Double, + color: Color, + clip: ClipRect, + step: Double ) { val path = Path() - val step = 4.0 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) @@ -376,7 +552,7 @@ private fun DrawScope.drawGraticule( drawPath(path, color, style = Stroke(width = 1f)) } -private data class DiscPt(val x: Float, val y: Float, val front: Boolean) +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 @@ -395,7 +571,10 @@ private fun limbPoint(behind: DiscPt, front: DiscPt): Pair { * padded with the horizon intersection point so they can be closed along the horizon. * Pass [closed] = false for open polylines (border lines). */ -private fun buildFrontRuns(pts: List, closed: Boolean = true): List>> { +internal fun buildFrontRuns( + pts: List, + closed: Boolean = true +): List>> { if (pts.isEmpty()) return emptyList() val n = pts.size val runs = mutableListOf>>() @@ -432,6 +611,12 @@ private fun buildFrontRuns(pts: List, closed: Boolean = true): List, cx: Float, cy: Float, r: Float, color: Color, - clip: ClipRect + clip: ClipRect, + invertFill: Boolean = false, + preparedPolygon: List>? = null ) { if (pts.none { it.front }) return - val poly = buildFillPolygon(pts, cx, cy, r) + 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) @@ -580,6 +773,96 @@ private fun DrawScope.fillPolygonClipped( 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, @@ -606,8 +889,8 @@ private fun DrawScope.strokeRuns( private fun DrawScope.drawBorderLine( line: LandData.Ring, scratch: FloatArray, + projector: GlobeMath.PreparedProjector, cx: Float, cy: Float, r: Float, - cLat: Double, cLon: Double, colors: GlobeColors, clip: ClipRect ) { @@ -618,12 +901,13 @@ private fun DrawScope.drawBorderLine( val pts = ArrayList(n) var i = 0 while (i < n) { - val lat = line.coords[i * 2].toDouble() - val lon = line.coords[i * 2 + 1].toDouble() - val p = GlobeMath.projectRaw(lat, lon, cLat, cLon) - val front = p.cosC > 0.005f - pts.add(DiscPt(p.x, p.y, front)) - if (p.cosC >= 0f) anyFront = true + 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 @@ -635,18 +919,21 @@ private fun DrawScope.drawBorderLine( private fun DrawScope.drawCities( cities: List, state: GlobeState, + projector: GlobeMath.PreparedProjector, cx: Float, cy: Float, r: Float, - cLat: Double, cLon: Double, colors: GlobeColors, labelPaint: Paint, haloPaint: Paint, typeface: Typeface?, textSize: Float, - density: Float + density: Float, + maxRankOverride: Int?, + showLabels: Boolean ) { if (cities.isEmpty()) return + val projection = FloatArray(3) val zoom = state.zoom - val maxRank = when { + val maxRank = maxRankOverride ?: when { zoom < 2f -> 1 zoom < 8f -> 3 zoom < 40f -> 4 @@ -655,20 +942,21 @@ private fun DrawScope.drawCities( val canvas = drawContext.canvas.nativeCanvas for (city in cities) { if (city.rank > maxRank) continue - val p = GlobeMath.project(city.lat.toDouble(), city.lon.toDouble(), cLat, cLon) ?: continue - if (p.cosC < 0.03f) continue - val sx = cx + p.x * r - val sy = cy + p.y * r + 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 = p.cosC.coerceIn(0.25f, 1f) + 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 (zoom >= 6f || (important && zoom >= 2.5f)) { + if (showLabels && (zoom >= 6f || (important && zoom >= 2.5f))) { labelPaint.textSize = textSize labelPaint.typeface = typeface labelPaint.textAlign = Paint.Align.LEFT @@ -694,30 +982,34 @@ private fun DrawScope.drawCities( haloPaint.textAlign = Paint.Align.CENTER } -private fun DrawScope.drawLandRing( +private fun DrawScope.drawLandFill( ring: LandData.Ring, scratch: FloatArray, + projector: GlobeMath.PreparedProjector, cx: Float, cy: Float, r: Float, - cLat: Double, cLon: Double, colors: GlobeColors, - clip: ClipRect -) { - val n = ring.size - if (n < 3 || n * 3 > scratch.size) return + 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 lat = ring.coords[i * 2].toDouble() - val lon = ring.coords[i * 2 + 1].toDouble() - val p = GlobeMath.projectRaw(lat, lon, cLat, cLon) - scratch[i * 3] = p.x - scratch[i * 3 + 1] = p.y - scratch[i * 3 + 2] = p.cosC - if (p.cosC >= 0f) anyFront = true + 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 + if (!anyFront) return null val pts = ArrayList(n) i = 0 @@ -726,8 +1018,26 @@ private fun DrawScope.drawLandRing( i++ } val runs = buildFrontRuns(pts) - fillPolygonClipped(pts, cx, cy, r, colors.land, clip) - strokeRuns(runs, cx, cy, r, colors.coastline, 1.4f, clip) + 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( @@ -735,11 +1045,12 @@ private fun DrawScope.drawGeohashGrid( cx: Float, cy: Float, r: Float, cLat: Double, cLon: Double, colors: GlobeColors, - clip: ClipRect + clip: ClipRect, + includeNeighbors: Boolean ) { val selected = state.selectedGeohash val cells = linkedSetOf(selected) - cells.addAll(Geohash.neighborsSamePrecision(selected)) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) for (cell in cells) { val isSelected = cell == selected @@ -782,11 +1093,15 @@ private fun DrawScope.drawGeohashGrid( val runs = buildFrontRuns(discPts) if (isSelected) { - fillPolygonClipped(discPts, cx, cy, r, colors.accent.copy(alpha = 0.20f), clip) + 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) + fillPolygonClipped( + discPts, cx, cy, r, colors.grid.copy(alpha = 0.05f), clip + ) strokeRuns(runs, cx, cy, r, colors.grid, 1.6f, clip) } } @@ -802,11 +1117,12 @@ private fun DrawScope.drawGeohashLabels( labelTypeface: Typeface?, labelTypefaceBold: Typeface?, selectedSize: Float, - neighborSize: Float + neighborSize: Float, + includeNeighbors: Boolean ) { val selected = state.selectedGeohash val cells = linkedSetOf(selected) - cells.addAll(Geohash.neighborsSamePrecision(selected)) + if (includeNeighbors) cells.addAll(Geohash.neighborsSamePrecision(selected)) val canvas = drawContext.canvas.nativeCanvas for (cell in cells) { 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 index 798534fd..7348f88b 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/LandData.kt @@ -2,6 +2,8 @@ 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 @@ -9,9 +11,28 @@ import org.json.JSONObject */ object LandData { - data class Ring(val coords: FloatArray, val size: Int) + 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) + 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 @@ -127,4 +148,17 @@ object LandData { 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..de18baf0 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( @@ -38,6 +41,10 @@ fun AudioMessageItem( showSender: Boolean = true ) { 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) { @@ -78,6 +85,14 @@ 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, 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 ccb8b55f..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,17 +29,29 @@ 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 /** - * Set while a Wi-Fi Direct hotspot is hosting. 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. The hold - * also blocks [startIfPossible], so a resume or mesh-service restart cannot bring - * Aware back while the hotspot is up. + * 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 hotspotHold = AtomicBoolean(false) + 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()) @@ -97,26 +111,65 @@ object WifiAwareController { } /** - * Releases the Wi-Fi radio so a Wi-Fi Direct hotspot can create its P2P interface, - * and prevents Aware restarting until [releaseHotspotHold] is called. + * 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. */ - fun holdForHotspot() { - if (!hotspotHold.compareAndSet(false, true)) return - Log.i(TAG, "Holding Wi-Fi Aware down so the hotspot can use the radio") - stop() + class HotspotLease internal constructor( + private val releaseAction: () -> Unit + ) : AutoCloseable { + private val released = AtomicBoolean(false) + + override fun close() { + if (released.compareAndSet(false, true)) { + releaseAction() + } + } } - /** Drops the hold and restores Aware if the user still has it enabled. */ - fun releaseHotspotHold() { - if (!hotspotHold.compareAndSet(true, false)) return + /** + * 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 (hotspotHold.get()) { + if (heldForHotspot()) { Log.d(TAG, "Not starting Wi-Fi Aware: held down for the hotspot") return } @@ -179,7 +232,7 @@ object WifiAwareController { return } } - if (!_enabled.value || hotspotHold.get()) { + if (!_enabled.value || heldForHotspot()) { synchronized(lifecycleLock) { starting = false } return } @@ -191,13 +244,13 @@ object WifiAwareController { startedService.startServices() // Test the hold inside the same lock that publishes the service, and that - // stop() takes. Testing it outside leaves a window where holdForHotspot() + // 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 holdForHotspot() sets the flag before calling + // 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 = !hotspotHold.get() && startedService.isRunning() + val canPublish = !heldForHotspot() && startedService.isRunning() if (canPublish) { service = startedService _running.value = true @@ -214,7 +267,7 @@ object WifiAwareController { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } catch (_: Exception) {} } else { // stopServices() can block, so keep it out of the lock. - val heldForHotspot = hotspotHold.get() + val heldForHotspot = heldForHotspot() if (heldForHotspot || reusableService == null) { try { startedService.stopServices() } catch (_: Exception) { } } @@ -264,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 @@ -271,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 f6f27a7f..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 @@ -1422,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/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index abaed278..772e48a5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -74,7 +74,7 @@ يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة تم الانتقال - حالة Tor + فتح إعدادات الموقع والقنوات الأقران المتصلون مشاركو geohash جاهز للمصافحة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 4d1f886b..b6557fe2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -74,7 +74,7 @@ Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা টেলিপোর্ট করা হয়েছে - Tor অবস্থা + অবস্থান ও চ্যানেল সেটিংস খুলুন সংযুক্ত পিয়ার জিওহ্যাশ অংশগ্রহণকারী হ্যান্ডশেকের জন্য প্রস্তুত diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d2d6701a..75cc8927 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 477f3ad1..3f08ead0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -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 diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2c24b107..1e092b64 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -74,7 +74,7 @@ قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده انتقال مکانی - وضعیت Tor + باز کردن تنظیمات مکان و کانال‌ها همتایان متصل شرکت‌کنندگان geohash آمادهٔ دست‌دهی diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index c689a986..66cd93bd 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 98ecb5fc..aa857f44 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -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 diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index ea869078..86e4bbcb 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -122,7 +122,7 @@ הודעות פרטיות שלא נקראו הערות מיקום טלפורט - סטטוס Tor + פתיחת הגדרות מיקום וערוצים עמיתים מחוברים משתתפי geohash מוכן ללחיצת יד diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 349b5dd2..4cb18e38 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -74,7 +74,7 @@ Nostr के माध्यम से उपलब्ध अपठित निजी संदेश टेलीपोर्ट हुआ - Tor स्थिति + स्थान और चैनल सेटिंग खोलें कनेक्टेड पीयर्स geohash प्रतिभागी हैंडशेक के लिए तैयार diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index a8bf98cf..02c86a68 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 069b0bb6..a8ecb688 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5d64770d..17841dcc 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -74,7 +74,7 @@ Nostr 経由で到達可能 未読のプライベートメッセージ テレポート済み - Tor ステータス + 位置情報とチャンネルの設定を開く 接続中のピア Geohash 参加者 ハンドシェイク準備完了 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index d75f1fea..8fe186cc 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -74,7 +74,7 @@ მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები ტელეპორტირებული - Tor-ის სტატუსი + მდებარეობისა და არხების პარამეტრების გახსნა დაკავშირებული პირები geohash მონაწილეები მზადაა ხელის ჩამოსართმევად diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 65023145..030165d6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -74,7 +74,7 @@ Nostr로 연결 가능 읽지 않은 개인 메시지 텔레포트됨 - Tor 상태 + 위치 및 채널 설정 열기 연결된 피어 geohash 참여자 핸드셰이크 준비됨 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index e6c61aae..52617e72 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -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 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index ca6bdabf..c6391e6f 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -76,7 +76,7 @@ Mesej peribadi belum dibaca Nota lokasi Diteleport - Status Tor + Buka tetapan lokasi dan saluran Rakan disambungkan Peserta geohash Sedia untuk berjabat tangan diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 1220a858..7a3da0bd 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -74,7 +74,7 @@ Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश टेलिपोर्ट भयो - Tor स्थिति + स्थान र च्यानल सेटिङ खोल्नुहोस् जडान भएका साथीहरू Geohash सहभागिहरू ह्यान्डशेकका लागि तयार diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 3ef1315d..64326ae5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -74,7 +74,7 @@ Bereikbaar via Nostr Ongelezen privéberichten Geteleporteerd - Tor-status + Locatie- en kanaalinstellingen openen Verbonden peers Geohash-deelnemers Klaar voor handshake diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 1450d583..a3f67735 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -74,7 +74,7 @@ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام ٹیلپورٹ کیتا گیا - Tor حالت + مقام تے چینل دیاں ترتیباں کھولو جُڑے پیئر geohash شریک ہینڈشیک لئی تیار diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a5840177..0d2f2689 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -120,7 +120,7 @@ Nieprzeczytane wiadomości prywatne Notatki lokalizacji Teleportowany - Status Tor + Otwórz ustawienia lokalizacji i kanałów Połączeni użytkownicy Uczestnicy geohash Gotowy do uzgadniania diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 8f66a464..42340857 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -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 diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4ae6fc0f..0e01bb07 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8f028c99..613b008d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -66,7 +66,7 @@ Доступен через Nostr Непрочитанные личные Телепортировано - Статус Tor + Открыть настройки местоположения и каналов Подключённые узлы Участники geohash Готов к рукопожатию diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index d3c00726..c0457997 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -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 diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index fa4d69dd..5e7f8bdd 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -112,7 +112,7 @@ படிக்காத தனிப்பட்ட செய்திகள் இருப்பிட குறிப்புகள் டெலிபோர்ட் செய்யப்பட்டது - Tor நிலை + இருப்பிடம் மற்றும் சேனல் அமைப்புகளைத் திற இணைக்கப்பட்ட பியர்ஸ் ஜியோஹாஷ் பங்கேற்பாளர்கள் ஹேண்ட்ஷேக்கிற்கு தயார் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 2d12c647..b21049c1 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -74,7 +74,7 @@ ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน เทเลพอร์ตแล้ว - สถานะ Tor + เปิดการตั้งค่าตำแหน่งและช่อง เพียร์ที่เชื่อมต่อ ผู้เข้าร่วม geohash พร้อมสำหรับการจับมือ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c4ae063c..533e3252 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -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 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 186bc50b..cdc73c91 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -105,7 +105,7 @@ Непрочитані приватні повідомлення Нотатки про місце Телепортовано - Статус Tor + Відкрити налаштування розташування та каналів Підключені піри Учасники геохешу Готовий до рукостискання diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 66131271..785338fb 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -74,7 +74,7 @@ Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات ٹیلی پورٹ - Tor کی کیفیت + مقام اور چینل کی ترتیبات کھولیں منسلک پیئرز geohash شرکاء ہینڈ شیک کے لیے تیار diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 0c47a698..b97c3fc5 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -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 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a639dd00..b9e35372 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -121,7 +121,6 @@ 未读私信 位置笔记 已传送 - Tor 状态 已连接的节点 Geohash 参与者 准备握手 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 70fc5986..36b6bbdd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -121,7 +121,6 @@ 未讀私訊 位置筆記 已傳送 - Tor 狀態 已連線的節點 地理雜湊參與者 準備好進行握手 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8231722b..751580a6 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -74,7 +74,7 @@ 可通过 Nostr 联系 未读私信 已传送 - Tor 状态 + 打开位置和频道设置 已连接节点 Geohash 参与者 准备握手 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18762913..f705c5ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,7 +123,7 @@ You Location notes Teleported - Tor status + Open location and channel settings Connected peers Geohash participants Ready for handshake @@ -275,6 +275,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 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/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/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/android/hotspot/HotspotStartupPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt index bb91b072..4cc68492 100644 --- a/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/hotspot/HotspotStartupPolicyTest.kt @@ -2,6 +2,7 @@ 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 @@ -76,49 +77,83 @@ class HotspotStartupPolicyTest { val action = HotspotStartupPolicy.startAction( p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, existingGroupName = "DIRECT-BC-CUF6EN63", - ownedGroupName = "DIRECT-BC-CUF6EN63" + ownedGroupName = "DIRECT-BC-CUF6EN63", + confirmedGroupName = null ) assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action) } @Test - fun `another app's group is left alone`() { + 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" + ownedGroupName = "DIRECT-BC-CUF6EN63", + confirmedGroupName = null ) - assertEquals( - HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE), - action - ) + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) } @Test - fun `an orphan from before we recorded ownership is still recognised by its prefix`() { + 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 + ownedGroupName = null, + confirmedGroupName = null ) - assertEquals(HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate, action) + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) } @Test - fun `a foreign group is left alone even when we recorded nothing`() { + 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 + ownedGroupName = null, + confirmedGroupName = null ) - assertEquals( - HotspotStartupPolicy.StartAction.Fail(HotspotStartupPolicy.FOREIGN_GROUP_MESSAGE), - action - ) + assertEquals(HotspotStartupPolicy.StartAction.ConfirmReplaceExisting, action) } @Test @@ -126,18 +161,35 @@ class HotspotStartupPolicyTest { val action = HotspotStartupPolicy.startAction( p2pState = WifiP2pManager.WIFI_P2P_STATE_ENABLED, existingGroupName = null, - ownedGroupName = 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" + ownedGroupName = "DIRECT-BC-CUF6EN63", + confirmedGroupName = null ) assertEquals( @@ -167,4 +219,52 @@ class HotspotStartupPolicyTest { assertTrue(decision is HotspotStartupPolicy.Decision.Fail) } -} \ No newline at end of file + + @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/services/IncomingMessageAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt index 94cdcec4..a430eb9a 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt @@ -185,6 +185,51 @@ class IncomingMessageAdmissionTest { 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( 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/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/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/release-gate-runbook.md b/docs/release-gate-runbook.md index c8de2c55..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`). @@ -298,6 +298,8 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `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 | @@ -326,7 +328,8 @@ See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`, `whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`, `announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`, `favorite_status`, `verification_set`, `verification_status`, `file_send`, -`file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`). +`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`, +`clear_results`). ### Troubleshooting diff --git a/docs/screenshots/readme-geohash-globe.png b/docs/screenshots/readme-geohash-globe.png new file mode 100644 index 00000000..f19319b0 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..c66a21c3 Binary files /dev/null and b/docs/screenshots/readme-mesh-chat.png differ diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index b3c04b70..7abe8085 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -532,6 +532,96 @@ def scenario_broadcast(a: Device, b: Device) -> dict: return {"send": send_result, "recv": recv_result} +def _ptt_one_way( + sender: Device, + receiver: Device, + sender_id: str, + receiver_id: str, + scope: str, +) -> dict: + # Long enough to expose sustained GATT/codec backpressure while remaining a quick gate. + send_args: dict[str, object] = {"duration_ms": 3_000} + if scope == "dm": + send_args["peer"] = receiver_id + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + recv = pool.submit( + receiver.cmd_ok, + "ptt_recv", + 240_000, + peer=sender_id, + scope="public" if scope == "public" else "dm", + ) + time.sleep(2) + send = pool.submit(sender.cmd_ok, "ptt_send", 240_000, **send_args) + recv_result, send_result = recv.result(), send.result() + if not recv_result.get("live_observed") or recv_result.get("frames", 0) <= 0: + raise MeshLabError(f"live {scope} burst was not assembled before fallback: {recv_result}") + if recv_result.get("burst_id") != send_result.get("burst_id"): + raise MeshLabError( + f"live {scope} burst/final-note identity mismatch: send={send_result} recv={recv_result}" + ) + encoded = int(send_result.get("encoded_frames", 0)) + queued = int(send_result.get("queued_pcm_frames", 0)) + sent_packets = int(send_result.get("data_packets", 0)) + received_packets = int(recv_result.get("data_packets", 0)) + expected_packets = int(recv_result.get("expected_packets", 0)) + missing_packets = int(recv_result.get("missing_packets", -1)) + received_frames = int(recv_result.get("frames", 0)) + if int(send_result.get("dropped_oversize_frames", -1)) != 0: + raise MeshLabError(f"live {scope} encoder produced an unsent AAC frame: {send_result}") + # AAC encoders may emit up to two priming access units in addition to one unit per PCM block. + if queued <= 0 or encoded < queued or encoded > queued + 2 or sent_packets != encoded: + raise MeshLabError(f"live {scope} encoder/packetizer continuity failed: {send_result}") + outbound_packets = int(send_result.get("outbound_packets", 0)) + delivered_packets = int(send_result.get("delivered_packets", -1)) + if outbound_packets != delivered_packets or outbound_packets != sent_packets + 2: + raise MeshLabError(f"live {scope} network dispatch did not drain in order: {send_result}") + minimum_realtime_pcm_frames = int(3_000 / 64) - 2 + if queued < minimum_realtime_pcm_frames: + raise MeshLabError(f"live {scope} capture fell behind real time: {send_result}") + if missing_packets != 0: + raise MeshLabError(f"live {scope} burst contained sequence gaps: {recv_result}") + if not (received_packets == expected_packets == sent_packets and received_frames == encoded): + raise MeshLabError( + f"live {scope} frame counts differ across the physical link: " + f"send={send_result} recv={recv_result}" + ) + decoded_samples = int(recv_result.get("decoded_samples", 0)) + if decoded_samples < max(1, received_frames - 2) * 1_024: + raise MeshLabError(f"live {scope} decoded PCM is truncated: {recv_result}") + rms = float(recv_result.get("rms", 0.0)) + silent_fraction = float(recv_result.get("silent_block_fraction", 1.0)) + longest_silent_run = int(recv_result.get("longest_silent_block_run", 999)) + crossings_per_second = float(recv_result.get("zero_crossings_per_second", 0.0)) + if rms < 0.05 or silent_fraction > 0.10 or longest_silent_run > 2: + raise MeshLabError(f"live {scope} decoded tone is silent or broken up: {recv_result}") + if not 650.0 <= crossings_per_second <= 1_150.0: + raise MeshLabError(f"live {scope} decoded tone continuity is distorted: {recv_result}") + return {"send": send_result, "recv": recv_result} + + +def scenario_ptt_dm(a: Device, b: Device) -> dict: + """Gap-free Noise-encrypted PTT tone plus finalized note in both directions.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b) + b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a) + return { + "a_to_b": _ptt_one_way(a, b, id_a, id_b, "dm"), + "b_to_a": _ptt_one_way(b, a, id_b, id_a, "dm"), + } + + +def scenario_ptt_broadcast(a: Device, b: Device) -> dict: + """Gap-free signed public PTT tone plus finalized note in both directions.""" + id_a = whoami(a)["peer_id"] + id_b = whoami(b)["peer_id"] + return { + "a_to_b": _ptt_one_way(a, b, id_a, id_b, "public"), + "b_to_a": _ptt_one_way(b, a, id_b, id_a, "public"), + } + + def scenario_file( a: Device, b: Device, @@ -779,6 +869,8 @@ 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( @@ -807,6 +899,8 @@ WATCH_SCENARIOS = [ "dm", "favorite_verification", "broadcast", + "ptt_dm", + "ptt_broadcast", "raw", "file", "file_private", 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 index 6b68c628..1f6d5cd8 100644 --- a/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt +++ b/wear/src/debug/java/com/bitchat/watch/testhook/WearTestHookDriver.kt @@ -4,6 +4,13 @@ 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 @@ -64,6 +71,8 @@ object WearTestHookDriver { "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") @@ -380,6 +389,104 @@ object WearTestHookDriver { 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 { diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index e128a08a..fd03e9f3 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -137,6 +137,8 @@ class MainActivity : ComponentActivity() { 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) @@ -146,6 +148,8 @@ class MainActivity : ComponentActivity() { } override fun onPause() { + com.bitchat.android.features.voice.LiveVoiceManager.getInstance(applicationContext) + .setAppForeground(false) WearChatState.setAppInForeground(false) super.onPause() } diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index d66dd751..eeb09295 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -373,6 +373,10 @@ class WearMeshService private constructor(private val context: Context) { 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 diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt index 7111cd1c..72bdc8af 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -54,7 +54,12 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette * level so [VoiceRecordOverlay] can render full-screen outside this slot. */ @Composable -fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Modifier = Modifier) { +fun ChatActionBar( + onKeyboard: () -> Unit, + voice: VoiceNoteController, + busyTalker: String? = null, + modifier: Modifier = Modifier +) { val context = LocalContext.current val palette = LocalBitchatPalette.current @@ -87,7 +92,11 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: .size(38.dp) .clip(CircleShape) .background( - if (voice.recording) MaterialTheme.colorScheme.primary else palette.inputButton + when { + voice.recording -> MaterialTheme.colorScheme.primary + busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300).copy(alpha = 0.28f) + else -> palette.inputButton + } ) .pointerInput(Unit) { detectTapGestures( @@ -115,8 +124,11 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Icon( imageVector = Icons.Filled.Mic, contentDescription = "push to talk", - tint = if (voice.recording) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.primary, + tint = when { + voice.recording -> MaterialTheme.colorScheme.onPrimary + busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300) + else -> MaterialTheme.colorScheme.primary + }, modifier = Modifier.size(18.dp) ) } @@ -226,7 +238,7 @@ fun VoiceRecordOverlay( .height(44.dp) ) Text( - text = "%d:%02d".format( + text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format( voice.elapsedMs / 1000 / 60, voice.elapsedMs / 1000 % 60 ) + " / 0:10", diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt index 5f740f12..0bcdfed3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -42,6 +42,8 @@ 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 @@ -106,11 +108,31 @@ fun ChatScaffold( } // 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() && atNewest) { - // 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) } + 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) } + } } } @@ -128,6 +150,25 @@ fun ChatScaffold( ) } +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, diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index 7c902ea5..d91f864b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -27,6 +27,7 @@ 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 @@ -59,14 +60,33 @@ 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 voice = rememberVoiceNoteController { path -> - mesh?.let { sendVoiceNote(it, null, path) } + 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( @@ -84,7 +104,7 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { ) }, actionBar = { - ChatActionBar(onKeyboard = onOpenTextInput, voice = voice) + ChatActionBar(onKeyboard = onOpenTextInput, voice = voice, busyTalker = busyTalker) } ) @@ -230,7 +250,10 @@ fun MessageItem( path = message.content.trim(), onOpen = onOpenImage ) - BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim()) + BitchatMessageType.Audio -> VoiceNoteItem( + path = message.content.trim(), + messageID = message.id + ) BitchatMessageType.File -> { val path = message.content.trim() val file = remember(path) { File(path) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt index 05ebf23c..1d179a0b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/DmScreen.kt @@ -60,9 +60,20 @@ fun DmScreen( val myPeerID = mesh?.myPeerID ?: "" val palette = LocalBitchatPalette.current var viewerPath by remember { mutableStateOf(null) } - val voice = rememberVoiceNoteController { path -> - mesh?.let { sendVoiceNote(it, peerID, path) } + 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() @@ -74,9 +85,13 @@ fun DmScreen( } DisposableEffect(peerID) { + liveVoiceManager.showDirectMessage(peerID) WearChatState.openDm(peerID) WearNotificationCoordinator.getInstance(context).clearConversation(peerID) - onDispose { WearChatState.closeDm() } + onDispose { + WearChatState.closeDm() + liveVoiceManager.clearVisibleConversation() + } } LaunchedEffect(peerID) { diff --git a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt index 3687ec3d..3cb86df3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/PeopleScreen.kt @@ -13,6 +13,10 @@ 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 @@ -38,6 +42,7 @@ 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() @@ -45,6 +50,9 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { 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( @@ -92,6 +100,29 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) { 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) diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt index 7080c933..7ec162af 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -29,12 +29,15 @@ private const val LIVE_BARS = 32 class VoiceNoteController( private val context: Context, private val scope: CoroutineScope, + private val recorderFactory: () -> VoiceRecorder, private val onSendVoice: (String) -> Unit ) { - private val recorder = VoiceRecorder(context.applicationContext) + 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)) @@ -45,7 +48,10 @@ class VoiceNoteController( fun start() { if (recording) return - recorder.start() ?: return + val activeRecorder = recorderFactory() + activeRecorder.start() ?: return + recorder = activeRecorder + isLive = activeRecorder.isLive startedAt = System.currentTimeMillis() elapsedMs = 0L liveSamples = FloatArray(LIVE_BARS) @@ -54,7 +60,7 @@ class VoiceNoteController( pollJob = scope.launch { while (true) { delay(AMPLITUDE_POLL_MS) - val amp = normalizeAmplitudeSample(recorder.pollAmplitude()) + val amp = normalizeAmplitudeSample(recorder?.pollAmplitude() ?: 0) liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp val elapsed = System.currentTimeMillis() - startedAt elapsedMs = elapsed @@ -74,7 +80,9 @@ class VoiceNoteController( if (send) WearHaptics.click(context) pollJob?.cancel() pollJob = null - val file = recorder.stop() + 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) @@ -85,8 +93,11 @@ class VoiceNoteController( } @Composable -fun rememberVoiceNoteController(onSendVoice: (String) -> Unit): VoiceNoteController { +fun rememberVoiceNoteController( + recorderFactory: () -> VoiceRecorder, + onSendVoice: (String) -> Unit +): VoiceNoteController { val context = LocalContext.current val scope = rememberCoroutineScope() - return remember { VoiceNoteController(context, scope, onSendVoice) } + return remember { VoiceNoteController(context, scope, recorderFactory, onSendVoice) } } 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 index c2fbf983..e27f132e 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/media/MediaItems.kt @@ -26,6 +26,7 @@ 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 @@ -41,6 +42,7 @@ 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 @@ -124,8 +126,12 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) { * duration/progress. Playback via MediaPlayer. */ @Composable -fun VoiceNoteItem(path: String) { +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) } @@ -175,6 +181,14 @@ fun VoiceNoteItem(path: String) { .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) 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) + } +}