Merge remote-tracking branch 'origin/main' into ui-dynamic-size-component

This commit is contained in:
spradhan7656 2026-08-01 15:01:55 +05:30
commit ff6a5fc59a
119 changed files with 5831 additions and 540 deletions

View File

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

View File

@ -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."
]
}
]
}

View File

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

View File

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

176
AGENTS.md
View File

@ -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 <s1> --serial-b <s2> --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk`
- Run: `python3 tools/release_gate/mesh_lab.py scenario all --serial-a <s1> --serial-b <s2> --out /tmp/meshlab-evidence`
- Scenarios: `dm`, `broadcast`, `file`, `file_oversize`, `file_private`, `raw`, `session_recovery`, `identity_reset`, `all`. Ad-hoc: `... cmd --serial <s> 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 <device-a> --serial-b <device-b> --apk <debug-apk>
python3 tools/release_gate/mesh_lab.py scenario all \
--serial-a <device-a> --serial-b <device-b> --out /tmp/mesh-evidence
```
For phone-to-watch interop, replace `--serial-b` with `--serial-watch` and add
`--watch-apk`. Keep devices unlocked and awake. Follow the Mesh Lab appendix in
`docs/release-gate-runbook.md`; raw evidence and logcat must remain local.
## Commits, Pull Requests & Privacy
Use short imperative subjects (`Fix stale peer lifecycle cleanup`) or scoped
Conventional Commit subjects (`fix(wifi-aware): ...`). PRs must explain risk,
link the issue, report tests, and include before/after screenshots for visible
phone or watch changes. Do not publish raw device logs.
Use `gh` for GitHub operations. Never override Git author or committer identity.
The mandatory privacy rules above apply to all Git and GitHub activity. Treat a
request to create a commit or pull request as permission only for the sanitized
repository changes and description needed for that action, never for local or
personal metadata. Never commit keystores or secrets.

View File

@ -12,6 +12,19 @@ This is the Android implementation of bitchat, fully protocol-compatible with th
[<img alt="Get it on Google Play" height="60" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/>](https://play.google.com/store/apps/details?id=com.bitchat.droid)
## See it in action
<table>
<tr>
<th>Offline mesh conversation</th>
<th>Geohash globe picker</th>
</tr>
<tr>
<td><img src="docs/screenshots/readme-mesh-chat.png" alt="Active four-peer Bitchat mesh conversation with an image, voice messages, and text messages" width="360"/></td>
<td><img src="docs/screenshots/readme-geohash-globe.png" alt="Bitchat geohash location picker showing the whole Earth and geohash grid" width="360"/></td>
</tr>
</table>
## License
This project is released into the public domain. See the [LICENSE](LICENSE.md) file for details.

View File

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

View File

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

View File

@ -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<ByteArray>(OUTBOUND_QUEUE_CAPACITY)
private val senderRunning = AtomicBoolean(false)
private val packetizer = VoiceBurstPacketizer(burstID)
private var streamStarted = false
private var startedAtMs = 0L
private var totalSamples = 0L
private var audioRecord: AudioRecord? = null
private var codec: MediaCodec? = null
private var muxer: MediaMuxer? = null
private var muxerTrack = -1
private var muxerStarted = false
private var outputFile: File? = null
private var captureThread: Thread? = null
private var senderThread: Thread? = null
@SuppressLint("MissingPermission")
fun start(): File? {
if (running.get()) return outputFile
return try {
outputDirectory.mkdirs()
val burstHex = VoiceBurstPacket.burstIDHex(burstID)
val file = File(outputDirectory, "voice_$burstHex.m4a")
if (file.exists()) file.delete()
outputFile = file
val format = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC,
SAMPLE_RATE,
CHANNEL_COUNT
).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, SAMPLES_PER_AAC_FRAME * 4)
}
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
codec = encoder
val mediaMuxer = MediaMuxer(file.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
muxer = mediaMuxer
val record = if (syntheticPcm) {
null
} else {
val minBuffer = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
if (minBuffer <= 0) error("AudioRecord buffer unavailable: $minBuffer")
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
max(minBuffer * 4, SAMPLES_PER_AAC_FRAME * 16)
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
it.release()
error("AudioRecord did not initialize")
}
it.startRecording()
if (it.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
it.release()
error("AudioRecord did not start")
}
}
}
audioRecord = record
startedAtMs = System.currentTimeMillis()
senderRunning.set(true)
senderThread = Thread(::senderLoop, "bitchat-ptt-sender").also(Thread::start)
running.set(true)
captureThread = Thread(::captureLoop, "bitchat-ptt-capture").also(Thread::start)
file
} catch (error: Exception) {
Log.w(TAG, "Live capture unavailable; caller will fall back to a voice note: ${error.message}")
releaseResources()
outputFile?.delete()
outputFile = null
null
}
}
fun pollAmplitude(): Int = amplitude.get()
fun stats(): LiveVoiceCaptureStats = LiveVoiceCaptureStats(
queuedPcmFrames = queuedPcmFrames.get(),
encodedFrames = encodedFrames.get(),
dataPackets = packetizer.dataPacketCount,
droppedOversizeFrames = packetizer.droppedFrameCount,
outboundPackets = outboundPackets.get(),
deliveredPackets = deliveredPackets.get()
)
fun stop(canceled: Boolean): File? {
val wasRunning = running.getAndSet(false)
if (wasRunning) {
runCatching { audioRecord?.stop() }
runCatching { captureThread?.join(3_000L) }
}
captureThread = null
val elapsedMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
val durationMs = (encodedFrames.get().toLong() * SAMPLES_PER_AAC_FRAME * 1_000L) / SAMPLE_RATE
val valid = !canceled && elapsedMs >= MIN_VALID_DURATION_MS &&
durationMs >= MIN_VALID_DURATION_MS && encodedFrames.get() > 0 &&
(outputFile?.length() ?: 0L) > 0L
packetizer.flush().forEach(::queueOutbound)
val controlKind = if (valid) {
VoiceBurstPacket.Kind.End(packetizer.dataPacketCount, durationMs.coerceAtMost(0xFFFF_FFFFL))
} else {
VoiceBurstPacket.Kind.Canceled
}
VoiceBurstPacket.create(burstID, packetizer.nextSequence, controlKind)
?.encode()
?.let(::queueOutbound)
senderRunning.set(false)
runCatching { senderThread?.join(OUTBOUND_DRAIN_TIMEOUT_MS) }
if (senderThread?.isAlive == true) {
Log.w(TAG, "Live voice sender did not drain before timeout")
senderThread?.interrupt()
}
senderThread = null
val file = outputFile
outputFile = null
if (!valid) {
file?.delete()
return null
}
return file
}
private fun captureLoop() {
val pcm = ShortArray(SAMPLES_PER_AAC_FRAME)
var nextSyntheticFrameNs = System.nanoTime() + AAC_FRAME_DURATION_NS
try {
while (running.get()) {
val read = if (syntheticPcm) {
val waitNs = nextSyntheticFrameNs - System.nanoTime()
if (waitNs > 0L) {
Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt())
}
nextSyntheticFrameNs += AAC_FRAME_DURATION_NS
val firstSample = totalSamples
pcm.indices.forEach { index ->
val phase = 2.0 * Math.PI * SYNTHETIC_TONE_HZ *
(firstSample + index).toDouble() / SAMPLE_RATE.toDouble()
pcm[index] = (sin(phase) * SYNTHETIC_TONE_AMPLITUDE).roundToInt().toShort()
}
pcm.size
} else {
audioRecord?.read(pcm, 0, pcm.size, AudioRecord.READ_BLOCKING) ?: break
}
if (read <= 0) continue
amplitude.set(pcm.take(read).maxOfOrNull { abs(it.toInt()) } ?: 0)
queuePcm(pcm, read, endOfStream = false)
drainEncoder(endOfStream = false)
}
queuePcm(pcm, 0, endOfStream = true)
drainEncoder(endOfStream = true)
} catch (error: Exception) {
Log.w(TAG, "Live capture stopped after codec/audio failure: ${error.message}")
} finally {
releaseResources()
}
}
private fun queuePcm(samples: ShortArray, count: Int, endOfStream: Boolean) {
val encoder = codec ?: return
val deadlineNs = System.nanoTime() + CODEC_INPUT_DEADLINE_MS * 1_000_000L
while (true) {
val index = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
val input = encoder.getInputBuffer(index)
?: error("AAC encoder returned a null input buffer")
input.clear()
input.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, count)
val presentationUs = (totalSamples * 1_000_000L) / SAMPLE_RATE
totalSamples += count
encoder.queueInputBuffer(
index,
0,
count * 2,
presentationUs,
if (endOfStream) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
)
if (!endOfStream) queuedPcmFrames.incrementAndGet()
return
}
// Pull encoded output before retrying so transient codec backpressure cannot discard
// the already-read 64 ms microphone block.
drainEncoder(endOfStream = false)
if (System.nanoTime() >= deadlineNs) {
error("AAC encoder input remained unavailable")
}
}
}
private fun drainEncoder(endOfStream: Boolean) {
val encoder = codec ?: return
val info = MediaCodec.BufferInfo()
var sawEnd = false
var idlePolls = 0
while (!sawEnd && (!endOfStream || idlePolls < 100)) {
when (val index = encoder.dequeueOutputBuffer(info, if (endOfStream) CODEC_TIMEOUT_US else 0L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> {
idlePolls++
if (!endOfStream) return
}
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (muxerStarted) error("AAC output format changed twice")
muxerTrack = muxer?.addTrack(encoder.outputFormat) ?: -1
muxer?.start()
muxerStarted = true
}
else -> if (index >= 0) {
idlePolls = 0
val output = encoder.getOutputBuffer(index)
if (output != null && info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
output.position(info.offset)
output.limit(info.offset + info.size)
if (muxerStarted && muxerTrack >= 0) {
muxer?.writeSampleData(muxerTrack, output.duplicate(), info)
}
val accessUnit = ByteArray(info.size)
output.get(accessUnit)
emitEncodedFrame(accessUnit)
}
sawEnd = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
encoder.releaseOutputBuffer(index, false)
}
}
}
}
private fun emitEncodedFrame(frame: ByteArray) {
if (frame.isEmpty()) return
if (!streamStarted) {
streamStarted = true
VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)?.encode()?.let(::queueOutbound)
}
encodedFrames.incrementAndGet()
packetizer.add(frame).forEach(::queueOutbound)
// At the target bitrate a packet fits one frame; flushing immediately avoids latency.
packetizer.flush().forEach(::queueOutbound)
}
private fun queueOutbound(packet: ByteArray) {
outboundQueue.put(packet.copyOf())
outboundPackets.incrementAndGet()
}
private fun senderLoop() {
try {
while (senderRunning.get() || outboundQueue.isNotEmpty()) {
val packet = outboundQueue.poll(100L, TimeUnit.MILLISECONDS) ?: continue
target.send(packet)
deliveredPackets.incrementAndGet()
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: Exception) {
Log.w(TAG, "Live voice network sender stopped: ${error.message}")
} finally {
senderRunning.set(false)
}
}
private fun releaseResources() {
runCatching { audioRecord?.stop() }
runCatching { audioRecord?.release() }
audioRecord = null
runCatching { codec?.stop() }
runCatching { codec?.release() }
codec = null
if (muxerStarted) runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
muxerStarted = false
muxerTrack = -1
}
}

View File

@ -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<Int, List<ByteArray>>()
var nextSequence = 1
var deliveredFrames = 0
var deliveredPackets = 0
var missingPackets = 0
var receivedBytes = 0
var endTotalPackets: Int? = null
var idleJob: Job? = null
var gapJob: Job? = null
}
private data class FinishedBurst(
val key: AssemblyKey,
val messageID: String,
val nickname: String,
val file: File,
val timestamp: Date,
val expiresAtMs: Long,
val dataPackets: Int,
val frames: Int,
val bytes: Int
)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val assemblies = linkedMapOf<AssemblyKey, Assembly>()
private val finishedBursts = linkedMapOf<AssemblyKey, FinishedBurst>()
private val _liveMessageIDs = MutableStateFlow<Set<String>>(emptySet())
val liveMessageIDs: StateFlow<Set<String>> = _liveMessageIDs.asStateFlow()
private val _activePublicTalker = MutableStateFlow<String?>(null)
val activePublicTalker: StateFlow<String?> = _activePublicTalker.asStateFlow()
private val _events = MutableSharedFlow<LiveVoiceEvent>(extraBufferCapacity = 64)
val events: SharedFlow<LiveVoiceEvent> = _events.asSharedFlow()
@Volatile private var appForeground = false
@Volatile private var visibleScope: LiveVoiceScope? = null
@Volatile private var visiblePeerID: String? = null
private var activePlayer: PttAudioPlayer? = null
init {
liveDirectory().listFiles()
?.filter { it.name.startsWith("voice_live_") }
?.forEach { it.delete() }
}
fun setAppForeground(foreground: Boolean) {
appForeground = foreground
if (!foreground) {
synchronized(this) {
activePlayer?.stop()
activePlayer = null
assemblies.values.forEach { assembly -> assembly.player?.stop() }
}
}
}
fun showPublicMesh() {
visibleScope = LiveVoiceScope.PUBLIC_MESH
visiblePeerID = null
}
fun showDirectMessage(peerID: String) {
visibleScope = LiveVoiceScope.DIRECT_MESSAGE
visiblePeerID = peerID
}
fun clearVisibleConversation() {
visibleScope = null
visiblePeerID = null
}
/** Returns false only when the frame itself violates the live-voice wire/resource contract. */
@Synchronized
fun handleFrame(
peerID: String,
nickname: String,
scope: LiveVoiceScope,
payload: ByteArray,
timestampMs: Long
): Boolean {
val packet = VoiceBurstPacket.decode(payload) ?: return false
if (!LiveVoicePreferences.isEnabled(context)) return true
val burstHex = VoiceBurstPacket.burstIDHex(packet.burstID)
val key = AssemblyKey(peerID, scope, burstHex)
var assembly = assemblies[key]
if (assembly == null) {
if (packet.kind is VoiceBurstPacket.Kind.End || packet.kind == VoiceBurstPacket.Kind.Canceled) {
return true
}
if (assemblies.size >= MAX_CONCURRENT_ASSEMBLIES) return false
assembly = createAssembly(key, nickname, timestampMs) ?: return false
assemblies[key] = assembly
publishLiveState()
_events.tryEmit(LiveVoiceEvent.Started(peerID, burstHex, scope))
}
assembly.receivedBytes += payload.size
val elapsedSeconds = ((System.currentTimeMillis() - assembly.startedAtMs).coerceAtLeast(0L) / 1_000.0) + 2.0
if (
assembly.receivedBytes > MAX_BURST_BYTES ||
assembly.receivedBytes > (INBOUND_BYTES_PER_SECOND * elapsedSeconds).toInt()
) {
Log.w(TAG, "Dropping over-quota live voice burst")
finalizeAssembly(assembly)
return false
}
rescheduleIdle(assembly)
when (val kind = packet.kind) {
is VoiceBurstPacket.Kind.Start -> if (kind.codec != VoiceBurstCodec.AAC_LC_16K_MONO) {
cancelAssembly(assembly)
return false
}
is VoiceBurstPacket.Kind.Frames -> {
if (packet.sequence < assembly.nextSequence || packet.sequence in assembly.buffered) return true
if (assembly.buffered.size >= MAX_BUFFERED_PACKETS) return false
assembly.buffered[packet.sequence] = kind.frames
drainInOrder(assembly)
}
is VoiceBurstPacket.Kind.End -> {
assembly.endTotalPackets = kind.totalDataPackets
drainInOrder(assembly)
finalizeIfComplete(assembly)
}
VoiceBurstPacket.Kind.Canceled -> cancelAssembly(assembly)
}
return true
}
/** Swaps a finalized `voice_<burstID>.m4a` into its existing live row. */
@Synchronized
fun absorbFinalizedVoiceNote(message: BitchatMessage): Boolean {
if (message.type != BitchatMessageType.Audio) return false
val burstID = burstIDFromVoiceFileName(File(message.content).name) ?: return false
val messageScope = if (message.isPrivate) LiveVoiceScope.DIRECT_MESSAGE else LiveVoiceScope.PUBLIC_MESH
val peerID = message.senderPeerID ?: return false
assemblies.entries.firstOrNull {
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
}?.value?.let(::finalizeAssembly)
pruneFinished()
val entry = finishedBursts.entries.firstOrNull {
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
} ?: return false
val finished = entry.value
val replacement = message.copy(
id = finished.messageID,
timestamp = finished.timestamp,
sender = finished.nickname,
senderPeerID = peerID,
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
)
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
} else {
AppStateStore.upsertPublicMessage(replacement)
}
finished.file.delete()
finishedBursts.remove(entry.key)
_events.tryEmit(LiveVoiceEvent.Absorbed(peerID, burstID, messageScope, message.content))
return true
}
@Synchronized
fun reset() {
assemblies.values.toList().forEach(::cancelAssembly)
activePlayer?.stop()
activePlayer = null
finishedBursts.clear()
publishLiveState()
}
private fun createAssembly(key: AssemblyKey, nickname: String, timestampMs: Long): Assembly? {
val file = File(
liveDirectory(),
"voice_live_${key.burstID}_${key.peerID}_${if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
)
file.parentFile?.mkdirs()
file.delete()
val output = runCatching { FileOutputStream(file) }.getOrNull() ?: return null
val message = BitchatMessage(
id = UUID.randomUUID().toString().uppercase(),
sender = nickname,
content = file.absolutePath,
type = BitchatMessageType.Audio,
timestamp = Date(timestampMs),
isPrivate = key.scope == LiveVoiceScope.DIRECT_MESSAGE,
recipientNickname = AppStateStore.nickname.value.takeIf { key.scope == LiveVoiceScope.DIRECT_MESSAGE },
senderPeerID = key.peerID
)
if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.addPrivateMessage(key.peerID, message, isVisible(key.scope, key.peerID))
} else {
AppStateStore.addPublicMessage(message)
}
val player = if (canAutoplay(key)) {
activePlayer?.stop()
PttAudioPlayer().also { activePlayer = it }
} else null
return Assembly(key, nickname, message, file, output, System.currentTimeMillis(), player)
}
private fun drainInOrder(assembly: Assembly) {
while (true) {
val frames = assembly.buffered.remove(assembly.nextSequence)
if (frames != null) {
frames.forEach { frame ->
runCatching { assembly.output.write(AdtsFramer.frame(frame)) }
}
runCatching { assembly.output.flush() }
assembly.deliveredFrames += frames.size
assembly.deliveredPackets++
assembly.player?.enqueue(frames)
assembly.nextSequence = (assembly.nextSequence + 1) and 0xFFFF
assembly.gapJob?.cancel()
assembly.gapJob = null
continue
}
if (assembly.buffered.isNotEmpty() && assembly.gapJob == null) {
val key = assembly.key
assembly.gapJob = scope.launch {
delay(GAP_SKIP_MS)
synchronized(this@LiveVoiceManager) {
val current = assemblies[key] ?: return@synchronized
current.buffered.firstKey()?.let { skipGap(current, it) }
current.gapJob = null
drainInOrder(current)
finalizeIfComplete(current)
}
}
}
return
}
}
private fun finalizeIfComplete(assembly: Assembly) {
val total = assembly.endTotalPackets ?: return
if (assembly.nextSequence > total) finalizeAssembly(assembly)
}
private fun finalizeAssembly(assembly: Assembly) {
if (assemblies.remove(assembly.key) == null) return
assembly.idleJob?.cancel()
assembly.gapJob?.cancel()
while (assembly.buffered.isNotEmpty()) {
skipGap(assembly, assembly.buffered.firstKey())
drainInOrder(assembly)
if (assembly.gapJob != null) {
assembly.gapJob?.cancel()
assembly.gapJob = null
}
}
runCatching { assembly.output.close() }
assembly.player?.finishAfterDrain()
val missingPackets = assembly.endTotalPackets
?.let { total -> (total - assembly.deliveredPackets).coerceAtLeast(assembly.missingPackets) }
?: assembly.missingPackets
if (assembly.deliveredFrames == 0) {
removeBubble(assembly)
assembly.file.delete()
publishLiveState()
return
}
val fallback = File(
assembly.file.parentFile,
"voice_${assembly.key.burstID}_${assembly.key.peerID}_${if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
)
fallback.delete()
if (assembly.file.renameTo(fallback)) assembly.file = fallback
val finalizedMessage = assembly.message.copy(content = assembly.file.absolutePath)
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.upsertPrivateMessage(
assembly.key.peerID,
finalizedMessage,
isVisible(assembly.key.scope, assembly.key.peerID)
)
} else {
AppStateStore.upsertPublicMessage(finalizedMessage)
}
pruneFinished()
finishedBursts[assembly.key] = FinishedBurst(
key = assembly.key,
messageID = assembly.message.id,
nickname = assembly.nickname,
file = assembly.file,
timestamp = assembly.message.timestamp,
expiresAtMs = System.currentTimeMillis() + FINISHED_TTL_MS,
dataPackets = assembly.deliveredPackets,
frames = assembly.deliveredFrames,
bytes = assembly.receivedBytes
)
_events.tryEmit(
LiveVoiceEvent.Finished(
assembly.key.peerID,
assembly.key.burstID,
assembly.key.scope,
assembly.deliveredPackets,
assembly.deliveredFrames,
assembly.receivedBytes,
assembly.endTotalPackets,
missingPackets,
assembly.file.absolutePath
)
)
publishLiveState()
}
private fun cancelAssembly(assembly: Assembly) {
if (assemblies.remove(assembly.key) == null) return
assembly.idleJob?.cancel()
assembly.gapJob?.cancel()
assembly.player?.stop()
runCatching { assembly.output.close() }
removeBubble(assembly)
assembly.file.delete()
_events.tryEmit(
LiveVoiceEvent.Canceled(assembly.key.peerID, assembly.key.burstID, assembly.key.scope)
)
publishLiveState()
}
private fun skipGap(assembly: Assembly, nextAvailableSequence: Int) {
val distance = (nextAvailableSequence - assembly.nextSequence) and 0xFFFF
if (distance in 1..0x7FFF) assembly.missingPackets += distance
assembly.nextSequence = nextAvailableSequence
}
private fun removeBubble(assembly: Assembly) {
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.removePrivateMessage(assembly.message.id)
} else {
AppStateStore.removePublicMessage(assembly.message.id)
}
}
private fun rescheduleIdle(assembly: Assembly) {
assembly.idleJob?.cancel()
val key = assembly.key
assembly.idleJob = scope.launch {
delay(IDLE_TIMEOUT_MS)
synchronized(this@LiveVoiceManager) {
assemblies[key]?.let(::finalizeAssembly)
}
}
}
private fun publishLiveState() {
_liveMessageIDs.value = assemblies.values.mapTo(linkedSetOf()) { it.message.id }
_activePublicTalker.value = assemblies.values
.firstOrNull { it.key.scope == LiveVoiceScope.PUBLIC_MESH }
?.nickname
}
private fun pruneFinished() {
val now = System.currentTimeMillis()
finishedBursts.entries.removeAll { it.value.expiresAtMs <= now }
while (finishedBursts.size >= FINISHED_CAP) {
val oldest = finishedBursts.minByOrNull { it.value.expiresAtMs }?.key ?: break
finishedBursts.remove(oldest)
}
}
private fun canAutoplay(key: AssemblyKey): Boolean =
LiveVoicePreferences.isEnabled(context) && appForeground && isVisible(key.scope, key.peerID)
private fun isVisible(scope: LiveVoiceScope, peerID: String): Boolean =
visibleScope == scope && (scope == LiveVoiceScope.PUBLIC_MESH || visiblePeerID == peerID)
private fun liveDirectory(): File = File(context.cacheDir, "files/incoming").apply { mkdirs() }
}

View File

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

View File

@ -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<ByteArray>(128)
private val stopped = AtomicBoolean(false)
private val finishing = AtomicBoolean(false)
private val startedAt = System.currentTimeMillis()
private val worker = Thread(::playbackLoop, "bitchat-ptt-playback").also(Thread::start)
fun enqueue(accessUnits: List<ByteArray>) {
if (stopped.get()) return
accessUnits.forEach { frames.offer(it.copyOf()) }
}
fun finishAfterDrain() {
finishing.set(true)
}
fun stop() {
stopped.set(true)
worker.interrupt()
}
private fun playbackLoop() {
var decoder: MediaCodec? = null
var track: AudioTrack? = null
try {
while (
!stopped.get() && frames.size < JITTER_FRAMES &&
System.currentTimeMillis() - startedAt < JITTER_DEADLINE_MS &&
!finishing.get()
) {
Thread.sleep(10L)
}
if (stopped.get() || (frames.isEmpty() && finishing.get())) return
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, 1).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, 2)
// AudioSpecificConfig: AAC-LC, 16 kHz (index 8), mono.
setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0x14, 0x08)))
}
decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
configure(format, null, null, 0)
start()
}
val minBuffer = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(4_096)
track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build()
)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(minBuffer)
.build()
track.play()
val info = MediaCodec.BufferInfo()
var presentationUs = 0L
var inputEnded = false
var outputEnded = false
while (!stopped.get() && !outputEnded) {
if (!inputEnded) {
val frame = frames.poll(25L, TimeUnit.MILLISECONDS)
if (frame != null) {
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
decoder.getInputBuffer(index)?.apply {
clear()
put(frame)
}
decoder.queueInputBuffer(index, 0, frame.size, presentationUs, 0)
presentationUs += FRAME_DURATION_US
} else {
frames.offer(frame)
}
} else if (finishing.get()) {
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
decoder.queueInputBuffer(
index,
0,
0,
presentationUs,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
}
}
}
when (val index = decoder.dequeueOutputBuffer(info, CODEC_TIMEOUT_US)) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED,
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
else -> if (index >= 0) {
decoder.getOutputBuffer(index)?.let { output ->
if (info.size > 0) {
output.position(info.offset)
output.limit(info.offset + info.size)
val pcm = ByteArray(info.size)
output.get(pcm)
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
decoder.releaseOutputBuffer(index, false)
}
}
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: Exception) {
Log.w(TAG, "Live playback stopped: ${error.message}")
} finally {
stopped.set(true)
runCatching { track?.stop() }
runCatching { track?.release() }
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
}
}
}

View File

@ -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<ByteArray>) : Kind {
override fun equals(other: Any?): Boolean =
other is Frames && frames.size == other.frames.size &&
frames.indices.all { frames[it].contentEquals(other.frames[it]) }
override fun hashCode(): Int = frames.fold(1) { acc, frame -> 31 * acc + frame.contentHashCode() }
}
data class End(val totalDataPackets: Int, val durationMs: Long) : Kind
data object Canceled : Kind
}
fun encode(): ByteArray {
val output = ByteArrayOutputStream(HEADER_SIZE + 16)
output.write(burstID)
output.write((sequence ushr 8) and 0xFF)
output.write(sequence and 0xFF)
when (val packetKind = kind) {
is Kind.Start -> {
output.write(FLAG_START)
output.write(packetKind.codec.value.toInt())
}
is Kind.Frames -> {
output.write(0)
packetKind.frames.forEach { frame ->
output.write((frame.size ushr 8) and 0xFF)
output.write(frame.size and 0xFF)
output.write(frame)
}
}
is Kind.End -> {
output.write(FLAG_END)
output.write((packetKind.totalDataPackets ushr 8) and 0xFF)
output.write(packetKind.totalDataPackets and 0xFF)
output.write(((packetKind.durationMs ushr 24) and 0xFF).toInt())
output.write(((packetKind.durationMs ushr 16) and 0xFF).toInt())
output.write(((packetKind.durationMs ushr 8) and 0xFF).toInt())
output.write((packetKind.durationMs and 0xFF).toInt())
}
Kind.Canceled -> output.write(FLAG_CANCELED)
}
return output.toByteArray()
}
companion object {
const val BURST_ID_SIZE = 8
const val HEADER_SIZE = BURST_ID_SIZE + 2 + 1
const val MAX_FRAMES_PER_PACKET = 8
const val MAX_CONTENT_BYTES = 210
private const val FLAG_START = 0x01
private const val FLAG_END = 0x02
private const val FLAG_CANCELED = 0x04
private val random = SecureRandom()
fun create(burstID: ByteArray, sequence: Int, kind: Kind): VoiceBurstPacket? {
if (burstID.size != BURST_ID_SIZE || sequence !in 0..0xFFFF) return null
when (kind) {
is Kind.Frames -> if (
kind.frames.isEmpty() ||
kind.frames.size > MAX_FRAMES_PER_PACKET ||
kind.frames.any { it.isEmpty() || it.size > 0xFFFF }
) return null
is Kind.End -> if (
kind.totalDataPackets !in 0..0xFFFF ||
kind.durationMs !in 0..0xFFFF_FFFFL
) return null
else -> Unit
}
return VoiceBurstPacket(burstID.copyOf(), sequence, kind)
}
fun decode(data: ByteArray): VoiceBurstPacket? {
if (data.size < HEADER_SIZE) return null
val burstID = data.copyOfRange(0, BURST_ID_SIZE)
val sequence = ((data[BURST_ID_SIZE].toInt() and 0xFF) shl 8) or
(data[BURST_ID_SIZE + 1].toInt() and 0xFF)
val flags = data[BURST_ID_SIZE + 2].toInt() and 0xFF
val offset = HEADER_SIZE
val kind: Kind = when (flags) {
FLAG_START -> {
if (offset >= data.size) return null
val codec = VoiceBurstCodec.fromValue(data[offset].toUByte()) ?: return null
Kind.Start(codec)
}
FLAG_END -> {
if (data.size - offset < 6) return null
val total = ((data[offset].toInt() and 0xFF) shl 8) or
(data[offset + 1].toInt() and 0xFF)
var duration = 0L
repeat(4) { index ->
duration = (duration shl 8) or (data[offset + 2 + index].toLong() and 0xFF)
}
Kind.End(total, duration)
}
FLAG_CANCELED -> Kind.Canceled
0 -> {
val frames = mutableListOf<ByteArray>()
var cursor = offset
while (cursor < data.size) {
if (data.size - cursor < 2 || frames.size >= MAX_FRAMES_PER_PACKET) return null
val length = ((data[cursor].toInt() and 0xFF) shl 8) or
(data[cursor + 1].toInt() and 0xFF)
cursor += 2
if (length <= 0 || data.size - cursor < length) {
return null
}
frames += data.copyOfRange(cursor, cursor + length)
cursor += length
}
if (frames.isEmpty()) return null
Kind.Frames(frames)
}
else -> return null
}
return create(burstID, sequence, kind)
}
fun makeBurstID(): ByteArray = ByteArray(BURST_ID_SIZE).also(random::nextBytes)
fun burstIDHex(burstID: ByteArray): String =
burstID.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
}
}
/** Greedy packetizer constrained so one Noise-wrapped frame stays out of fragmentation. */
class VoiceBurstPacketizer(
val burstID: ByteArray,
private val budget: Int = VoiceBurstPacket.MAX_CONTENT_BYTES
) {
private val pendingFrames = mutableListOf<ByteArray>()
private var pendingSize = 0
var nextSequence: Int = 1
private set
var dataPacketCount: Int = 0
private set
var droppedFrameCount: Int = 0
private set
fun add(frame: ByteArray): List<ByteArray> {
val frameCost = 2 + frame.size
if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) {
droppedFrameCount++
return emptyList()
}
val output = mutableListOf<ByteArray>()
if (
pendingFrames.isNotEmpty() &&
(VoiceBurstPacket.HEADER_SIZE + pendingSize + frameCost > budget ||
pendingFrames.size >= VoiceBurstPacket.MAX_FRAMES_PER_PACKET)
) {
output += flush()
}
pendingFrames += frame.copyOf()
pendingSize += frameCost
return output
}
fun flush(): List<ByteArray> {
if (pendingFrames.isEmpty()) return emptyList()
val packet = VoiceBurstPacket.create(
burstID,
nextSequence,
VoiceBurstPacket.Kind.Frames(pendingFrames.map(ByteArray::copyOf))
) ?: run {
pendingFrames.clear()
pendingSize = 0
return emptyList()
}
pendingFrames.clear()
pendingSize = 0
nextSequence = (nextSequence + 1) and 0xFFFF
dataPacketCount = (dataPacketCount + 1).coerceAtMost(0xFFFF)
return listOf(packet.encode())
}
}
/** Adds a seven-byte ADTS header to an ADTS-less AAC-LC/16 kHz/mono access unit. */
object AdtsFramer {
fun frame(payload: ByteArray): ByteArray {
val frameLength = payload.size + 7
require(frameLength <= 0x1FFF) { "AAC frame is too large for ADTS" }
return ByteArray(frameLength).also { output ->
output[0] = 0xFF.toByte()
output[1] = 0xF1.toByte()
output[2] = 0x60.toByte() // AAC-LC, 16 kHz frequency index, mono channel config high bit
output[3] = (0x40 or ((frameLength ushr 11) and 0x03)).toByte()
output[4] = ((frameLength ushr 3) and 0xFF).toByte()
output[5] = (((frameLength and 0x07) shl 5) or 0x1F).toByte()
output[6] = 0xFC.toByte()
payload.copyInto(output, destinationOffset = 7)
}
}
}

View File

@ -18,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<Int> = _amplitude.asStateFlow()
private var outFile: File? = null
val isLive: Boolean
get() = liveCapture != null
fun start(): File? {
stop() // ensure previous session closed
if (liveTarget != null) {
val directory = File(context.filesDir, "voicenotes/outgoing")
val capture = LiveVoiceCapture(directory, liveTarget)
val liveFile = capture.start()
if (liveFile != null) {
liveCapture = capture
outFile = liveFile
return liveFile
}
}
return startClassic()
}
private fun startClassic(): File? {
return try {
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
@ -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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PendingSend>()
var pendingBytes = 0
var inFlight = false
var retryScheduled = false
}
private val sendLock = Any()
private val sendStates = mutableMapOf<SendKey, LinkSendState>()
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
private val broadcasterActor = broadcasterScope.actor<BroadcastRequest>(
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()

View File

@ -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<VoiceFrameRequest>(capacity = 128)
private val directPeers = ConcurrentHashMap.newKeySet<String>()
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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String> = runCatching {
ContactDirectory.aliasesForConversation(peerID) +
ContactDirectory.aliasesForConversation(conversationID)
}.getOrDefault(setOf(peerID, conversationID))
fun hasSeenMessage(messageID: String): Boolean = synchronized(this) {
messageID in seenMessageIds
}

View File

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

View File

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

View File

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

View File

@ -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<GeoPerson>,
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)
)
}
}
}

View File

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

View File

@ -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<String>,
@ -73,6 +76,22 @@ class ChatViewModel(
mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun createVoiceRecorder(toPeerIDOrNull: String?, channelOrNull: String?): VoiceRecorder {
val context = getApplication<Application>().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()

View File

@ -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?) {

View File

@ -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<String, PeerIdentity> = 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
}

View File

@ -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<String?>(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

View File

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

View File

@ -1872,6 +1872,7 @@ fun PrivateChatSheet(
onSendFileNote = { peer, channel, path ->
viewModel.sendFileNote(peer, channel, path)
},
recorderFactory = viewModel::createVoiceRecorder,
showCommandSuggestions = false,
commandSuggestions = emptyList(),
showMentionSuggestions = false,

View File

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

View File

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

View File

@ -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<Double, Double, Int>? = 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() {

View File

@ -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<Long>()
val panVec = ArrayDeque<Offset>()
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<List<MutableList<Pair<Float, Float>>>>(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<Float, Float> {
var lo = 0f; var hi = 1f
@ -395,7 +571,10 @@ private fun limbPoint(behind: DiscPt, front: DiscPt): Pair<Float, Float> {
* 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<DiscPt>, closed: Boolean = true): List<MutableList<Pair<Float, Float>>> {
internal fun buildFrontRuns(
pts: List<DiscPt>,
closed: Boolean = true
): List<MutableList<Pair<Float, Float>>> {
if (pts.isEmpty()) return emptyList()
val n = pts.size
val runs = mutableListOf<MutableList<Pair<Float, Float>>>()
@ -432,6 +611,12 @@ private fun buildFrontRuns(pts: List<DiscPt>, closed: Boolean = true): List<Muta
if (closed && runs.isNotEmpty() && pts[0].front && pts[n - 1].front) {
runs[0] = (run + runs[0]).toMutableList()
} else {
// A fully front-facing ring has no limb transition to terminate its run.
// Close it explicitly; cell boundary sampling intentionally omits the
// duplicated final corner.
if (closed && runs.isEmpty() && pts[0].front && pts[n - 1].front) {
run.add(run.first())
}
runs.add(run)
}
}
@ -564,14 +749,22 @@ private fun DrawScope.fillPolygonClipped(
pts: List<DiscPt>,
cx: Float, cy: Float, r: Float,
color: Color,
clip: ClipRect
clip: ClipRect,
invertFill: Boolean = false,
preparedPolygon: List<Pair<Float, Float>>? = 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<Pair<Float, Float>>,
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<Pair<Float, Float>>,
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<MutableList<Pair<Float, Float>>>,
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<DiscPt>(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<LandData.City>,
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<MutableList<Pair<Float, Float>>>? {
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<DiscPt>(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) {

View File

@ -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<Ring>? = 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
}
}

View File

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

View File

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

View File

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

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">يمكن الوصول عبر Nostr</string>
<string name="cd_unread_private_messages">رسائل خاصة غير مقروءة</string>
<string name="cd_teleported">تم الانتقال</string>
<string name="cd_tor_status">حالة Tor</string>
<string name="cd_open_location_channels">فتح إعدادات الموقع والقنوات</string>
<string name="cd_connected_peers">الأقران المتصلون</string>
<string name="cd_geohash_participants">مشاركو geohash</string>
<string name="cd_ready_for_handshake">جاهز للمصافحة</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Nostr এর মাধ্যমে পৌঁছানো যায়</string>
<string name="cd_unread_private_messages">অপঠিত ব্যক্তিগত বার্তা</string>
<string name="cd_teleported">টেলিপোর্ট করা হয়েছে</string>
<string name="cd_tor_status">Tor অবস্থা</string>
<string name="cd_open_location_channels">অবস্থান ও চ্যানেল সেটিংস খুলুন</string>
<string name="cd_connected_peers">সংযুক্ত পিয়ার</string>
<string name="cd_geohash_participants">জিওহ্যাশ অংশগ্রহণকারী</string>
<string name="cd_ready_for_handshake">হ্যান্ডশেকের জন্য প্রস্তুত</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Über Nostr erreichbar</string>
<string name="cd_unread_private_messages">Ungelesene private Nachrichten</string>
<string name="cd_teleported">Teleportiert</string>
<string name="cd_tor_status">Tor‑Status</string>
<string name="cd_open_location_channels">Standort- und Kanaleinstellungen öffnen</string>
<string name="cd_connected_peers">Verbundenen Peers</string>
<string name="cd_geohash_participants">Geohash‑Teilnehmer</string>
<string name="cd_ready_for_handshake">Bereit für Handshake</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Accesible vía Nostr</string>
<string name="cd_unread_private_messages">Mensajes privados sin leer</string>
<string name="cd_teleported">Teletransportado</string>
<string name="cd_tor_status">Estado de Tor</string>
<string name="cd_open_location_channels">Abrir ajustes de ubicación y canales</string>
<string name="cd_connected_peers">Pares conectados</string>
<string name="cd_geohash_participants">Participantes geohash</string>
<string name="cd_ready_for_handshake">Listo para handshake</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">قابل دسترس از طریق Nostr</string>
<string name="cd_unread_private_messages">پیام‌های خصوصی خوانده‌نشده</string>
<string name="cd_teleported">انتقال مکانی</string>
<string name="cd_tor_status">وضعیت Tor</string>
<string name="cd_open_location_channels">باز کردن تنظیمات مکان و کانال‌ها</string>
<string name="cd_connected_peers">همتایان متصل</string>
<string name="cd_geohash_participants">شرکت‌کنندگان geohash</string>
<string name="cd_ready_for_handshake">آمادهٔ دست‌دهی</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Maabot sa Nostr</string>
<string name="cd_unread_private_messages">Hindi nabasang pribadong mensahe</string>
<string name="cd_teleported">Naiteleport</string>
<string name="cd_tor_status">Katayuan ng Tor</string>
<string name="cd_open_location_channels">Buksan ang mga setting ng lokasyon at channel</string>
<string name="cd_connected_peers">Nakakonektang peers</string>
<string name="cd_geohash_participants">Mga kalahok sa geohash</string>
<string name="cd_ready_for_handshake">Handa para sa handshake</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Joignable via Nostr</string>
<string name="cd_unread_private_messages">Messages privés non lus</string>
<string name="cd_teleported">Téléporté</string>
<string name="cd_tor_status">Statut Tor</string>
<string name="cd_open_location_channels">Ouvrir les réglages de localisation et des canaux</string>
<string name="cd_connected_peers">Pairs connectés</string>
<string name="cd_geohash_participants">Participants geohash</string>
<string name="cd_ready_for_handshake">Prêt pour l’échange de clés</string>

View File

@ -122,7 +122,7 @@
<string name="cd_unread_private_messages">הודעות פרטיות שלא נקראו</string>
<string name="cd_location_notes">הערות מיקום</string>
<string name="cd_teleported">טלפורט</string>
<string name="cd_tor_status">סטטוס Tor</string>
<string name="cd_open_location_channels">פתיחת הגדרות מיקום וערוצים</string>
<string name="cd_connected_peers">עמיתים מחוברים</string>
<string name="cd_geohash_participants">משתתפי geohash</string>
<string name="cd_ready_for_handshake">מוכן ללחיצת יד</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Nostr के माध्यम से उपलब्ध</string>
<string name="cd_unread_private_messages">अपठित निजी संदेश</string>
<string name="cd_teleported">टेलीपोर्ट हुआ</string>
<string name="cd_tor_status">Tor स्थिति</string>
<string name="cd_open_location_channels">स्थान और चैनल सेटिंग खोलें</string>
<string name="cd_connected_peers">कनेक्टेड पीयर्स</string>
<string name="cd_geohash_participants">geohash प्रतिभागी</string>
<string name="cd_ready_for_handshake">हैंडशेक के लिए तैयार</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Dapat dijangkau melalui Nostr</string>
<string name="cd_unread_private_messages">Pesan pribadi yang belum dibaca</string>
<string name="cd_teleported">Diteleportasi</string>
<string name="cd_tor_status">Status Tor</string>
<string name="cd_open_location_channels">Buka pengaturan lokasi dan channel</string>
<string name="cd_connected_peers">Peer yang terhubung</string>
<string name="cd_geohash_participants">Peserta geohash</string>
<string name="cd_ready_for_handshake">Siap untuk handshake</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Raggiungibile via Nostr</string>
<string name="cd_unread_private_messages">Messaggi privati non letti</string>
<string name="cd_teleported">Teletrasportato</string>
<string name="cd_tor_status">Stato Tor</string>
<string name="cd_open_location_channels">Apri le impostazioni di posizione e canali</string>
<string name="cd_connected_peers">Peer connessi</string>
<string name="cd_geohash_participants">Partecipanti geohash</string>
<string name="cd_ready_for_handshake">Pronto per l\'handshake</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Nostr 経由で到達可能</string>
<string name="cd_unread_private_messages">未読のプライベートメッセージ</string>
<string name="cd_teleported">テレポート済み</string>
<string name="cd_tor_status">Tor ステータス</string>
<string name="cd_open_location_channels">位置情報とチャンネルの設定を開く</string>
<string name="cd_connected_peers">接続中のピア</string>
<string name="cd_geohash_participants">Geohash 参加者</string>
<string name="cd_ready_for_handshake">ハンドシェイク準備完了</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">მისაწვდომია Nostr-ით</string>
<string name="cd_unread_private_messages">წაუკითხავი პირადი შეტყობინებები</string>
<string name="cd_teleported">ტელეპორტირებული</string>
<string name="cd_tor_status">Tor-ის სტატუსი</string>
<string name="cd_open_location_channels">მდებარეობისა და არხების პარამეტრების გახსნა</string>
<string name="cd_connected_peers">დაკავშირებული პირები</string>
<string name="cd_geohash_participants">geohash მონაწილეები</string>
<string name="cd_ready_for_handshake">მზადაა ხელის ჩამოსართმევად</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Nostr로 연결 가능</string>
<string name="cd_unread_private_messages">읽지 않은 개인 메시지</string>
<string name="cd_teleported">텔레포트됨</string>
<string name="cd_tor_status">Tor 상태</string>
<string name="cd_open_location_channels">위치 및 채널 설정 열기</string>
<string name="cd_connected_peers">연결된 피어</string>
<string name="cd_geohash_participants">geohash 참여자</string>
<string name="cd_ready_for_handshake">핸드셰이크 준비됨</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Azo tratrarina amin\'ny alalan\'ny Nostr</string>
<string name="cd_unread_private_messages">Hafatra manokana tsy voavaky</string>
<string name="cd_teleported">Nafindra toerana</string>
<string name="cd_tor_status">Toe-javatra Tor</string>
<string name="cd_open_location_channels">Sokafy ny fikirana toerana sy fantsona</string>
<string name="cd_connected_peers">Mpiara-miasa mifandray</string>
<string name="cd_geohash_participants">Mpandray anjara Geohash</string>
<string name="cd_ready_for_handshake">Vonona ho amin\'ny fifampiraharahana</string>

View File

@ -76,7 +76,7 @@
<string name="cd_unread_private_messages">Mesej peribadi belum dibaca</string>
<string name="cd_location_notes">Nota lokasi</string>
<string name="cd_teleported">Diteleport</string>
<string name="cd_tor_status">Status Tor</string>
<string name="cd_open_location_channels">Buka tetapan lokasi dan saluran</string>
<string name="cd_connected_peers">Rakan disambungkan</string>
<string name="cd_geohash_participants">Peserta geohash</string>
<string name="cd_ready_for_handshake">Sedia untuk berjabat tangan</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Nostr मार्फत पुग्न सकिने</string>
<string name="cd_unread_private_messages">नपढिएका निजी सन्देश</string>
<string name="cd_teleported">टेलिपोर्ट भयो</string>
<string name="cd_tor_status">Tor स्थिति</string>
<string name="cd_open_location_channels">स्थान र च्यानल सेटिङ खोल्नुहोस्</string>
<string name="cd_connected_peers">जडान भएका साथीहरू</string>
<string name="cd_geohash_participants">Geohash सहभागिहरू</string>
<string name="cd_ready_for_handshake">ह्यान्डशेकका लागि तयार</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Bereikbaar via Nostr</string>
<string name="cd_unread_private_messages">Ongelezen privéberichten</string>
<string name="cd_teleported">Geteleporteerd</string>
<string name="cd_tor_status">Tor-status</string>
<string name="cd_open_location_channels">Locatie- en kanaalinstellingen openen</string>
<string name="cd_connected_peers">Verbonden peers</string>
<string name="cd_geohash_participants">Geohash-deelnemers</string>
<string name="cd_ready_for_handshake">Klaar voor handshake</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Nostr راہین پہنچ ممکن اے</string>
<string name="cd_unread_private_messages">انہ پڑھیا ذاتی پیغام</string>
<string name="cd_teleported">ٹیلپورٹ کیتا گیا</string>
<string name="cd_tor_status">Tor حالت</string>
<string name="cd_open_location_channels">مقام تے چینل دیاں ترتیباں کھولو</string>
<string name="cd_connected_peers">جُڑے پیئر</string>
<string name="cd_geohash_participants">geohash شریک</string>
<string name="cd_ready_for_handshake">ہینڈشیک لئی تیار</string>

View File

@ -120,7 +120,7 @@
<string name="cd_unread_private_messages">Nieprzeczytane wiadomości prywatne</string>
<string name="cd_location_notes">Notatki lokalizacji</string>
<string name="cd_teleported">Teleportowany</string>
<string name="cd_tor_status">Status Tor</string>
<string name="cd_open_location_channels">Otwórz ustawienia lokalizacji i kanałów</string>
<string name="cd_connected_peers">Połączeni użytkownicy</string>
<string name="cd_geohash_participants">Uczestnicy geohash</string>
<string name="cd_ready_for_handshake">Gotowy do uzgadniania</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Acessível via Nostr</string>
<string name="cd_unread_private_messages">Mensagens privadas não lidas</string>
<string name="cd_teleported">Teletransportado</string>
<string name="cd_tor_status">Status do Tor</string>
<string name="cd_open_location_channels">Abrir configurações de localização e canais</string>
<string name="cd_connected_peers">Peers conectados</string>
<string name="cd_geohash_participants">Participantes geohash</string>
<string name="cd_ready_for_handshake">Pronto para handshake</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Alcançável via Nostr</string>
<string name="cd_unread_private_messages">Mensagens privadas não lidas</string>
<string name="cd_teleported">Teletransportado</string>
<string name="cd_tor_status">Estado do Tor</string>
<string name="cd_open_location_channels">Abrir definições de localização e canais</string>
<string name="cd_connected_peers">Pares ligados</string>
<string name="cd_geohash_participants">Participantes geohash</string>
<string name="cd_ready_for_handshake">Pronto para handshake</string>

View File

@ -66,7 +66,7 @@
<string name="cd_nostr_reachable">Доступен через Nostr</string>
<string name="cd_unread_private_messages">Непрочитанные личные</string>
<string name="cd_teleported">Телепортировано</string>
<string name="cd_tor_status">Статус Tor</string>
<string name="cd_open_location_channels">Открыть настройки местоположения и каналов</string>
<string name="cd_connected_peers">Подключённые узлы</string>
<string name="cd_geohash_participants">Участники geohash</string>
<string name="cd_ready_for_handshake">Готов к рукопожатию</string>

View File

@ -66,7 +66,7 @@
<string name="cd_nostr_reachable">Nås via Nostr</string>
<string name="cd_unread_private_messages">Olästa privata meddelanden</string>
<string name="cd_teleported">Teleporterad</string>
<string name="cd_tor_status">Tor‑status</string>
<string name="cd_open_location_channels">Öppna plats- och kanalinställningar</string>
<string name="cd_connected_peers">Anslutna noder</string>
<string name="cd_geohash_participants">Geohash‑deltagare</string>
<string name="cd_ready_for_handshake">Redo för handshake</string>

View File

@ -112,7 +112,7 @@
<string name="cd_unread_private_messages">படிக்காத தனிப்பட்ட செய்திகள்</string>
<string name="cd_location_notes">இருப்பிட குறிப்புகள்</string>
<string name="cd_teleported">டெலிபோர்ட் செய்யப்பட்டது</string>
<string name="cd_tor_status">Tor நிலை</string>
<string name="cd_open_location_channels">இருப்பிடம் மற்றும் சேனல் அமைப்புகளைத் திற</string>
<string name="cd_connected_peers">இணைக்கப்பட்ட பியர்ஸ்</string>
<string name="cd_geohash_participants">ஜியோஹாஷ் பங்கேற்பாளர்கள்</string>
<string name="cd_ready_for_handshake">ஹேண்ட்ஷேக்கிற்கு தயார்</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">ติดต่อได้ผ่าน Nostr</string>
<string name="cd_unread_private_messages">ข้อความส่วนตัวยังไม่ได้อ่าน</string>
<string name="cd_teleported">เทเลพอร์ตแล้ว</string>
<string name="cd_tor_status">สถานะ Tor</string>
<string name="cd_open_location_channels">เปิดการตั้งค่าตำแหน่งและช่อง</string>
<string name="cd_connected_peers">เพียร์ที่เชื่อมต่อ</string>
<string name="cd_geohash_participants">ผู้เข้าร่วม geohash</string>
<string name="cd_ready_for_handshake">พร้อมสำหรับการจับมือ</string>

View File

@ -66,7 +66,7 @@
<string name="cd_nostr_reachable">Nostr üzerinden ulaşılabilir</string>
<string name="cd_unread_private_messages">Okunmamış özel mesaj</string>
<string name="cd_teleported">Teleport edildi</string>
<string name="cd_tor_status">Tor durumu</string>
<string name="cd_open_location_channels">Konum ve kanal ayarlarını aç</string>
<string name="cd_connected_peers">Bağlı eşler</string>
<string name="cd_geohash_participants">Geohash katılımcıları</string>
<string name="cd_ready_for_handshake">El sıkışmaya hazır</string>

View File

@ -105,7 +105,7 @@
<string name="cd_unread_private_messages">Непрочитані приватні повідомлення</string>
<string name="cd_location_notes">Нотатки про місце</string>
<string name="cd_teleported">Телепортовано</string>
<string name="cd_tor_status">Статус Tor</string>
<string name="cd_open_location_channels">Відкрити налаштування розташування та каналів</string>
<string name="cd_connected_peers">Підключені піри</string>
<string name="cd_geohash_participants">Учасники геохешу</string>
<string name="cd_ready_for_handshake">Готовий до рукостискання</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Nostr کے ذریعے رسائی</string>
<string name="cd_unread_private_messages">غیر پڑھے ہوئے نجی پیغامات</string>
<string name="cd_teleported">ٹیلی پورٹ</string>
<string name="cd_tor_status">Tor کی کیفیت</string>
<string name="cd_open_location_channels">مقام اور چینل کی ترتیبات کھولیں</string>
<string name="cd_connected_peers">منسلک پیئرز</string>
<string name="cd_geohash_participants">geohash شرکاء</string>
<string name="cd_ready_for_handshake">ہینڈ شیک کے لیے تیار</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">Có thể tiếp cận qua Nostr</string>
<string name="cd_unread_private_messages">Tin nhắn riêng chưa đọc</string>
<string name="cd_teleported">Đã dịch chuyển</string>
<string name="cd_tor_status">Trạng thái Tor</string>
<string name="cd_open_location_channels">Mở cài đặt vị trí và kênh</string>
<string name="cd_connected_peers">Peers đã kết nối</string>
<string name="cd_geohash_participants">Người tham gia geohash</string>
<string name="cd_ready_for_handshake">Sẵn sàng cho handshake</string>

View File

@ -121,7 +121,6 @@
<string name="cd_unread_private_messages">未读私信</string>
<string name="cd_location_notes">位置笔记</string>
<string name="cd_teleported">已传送</string>
<string name="cd_tor_status">Tor 状态</string>
<string name="cd_connected_peers">已连接的节点</string>
<string name="cd_geohash_participants">Geohash 参与者</string>
<string name="cd_ready_for_handshake">准备握手</string>

View File

@ -121,7 +121,6 @@
<string name="cd_unread_private_messages">未讀私訊</string>
<string name="cd_location_notes">位置筆記</string>
<string name="cd_teleported">已傳送</string>
<string name="cd_tor_status">Tor 狀態</string>
<string name="cd_connected_peers">已連線的節點</string>
<string name="cd_geohash_participants">地理雜湊參與者</string>
<string name="cd_ready_for_handshake">準備好進行握手</string>

View File

@ -74,7 +74,7 @@
<string name="cd_nostr_reachable">可通过 Nostr 联系</string>
<string name="cd_unread_private_messages">未读私信</string>
<string name="cd_teleported">已传送</string>
<string name="cd_tor_status">Tor 状态</string>
<string name="cd_open_location_channels">打开位置和频道设置</string>
<string name="cd_connected_peers">已连接节点</string>
<string name="cd_geohash_participants">Geohash 参与者</string>
<string name="cd_ready_for_handshake">准备握手</string>

View File

@ -123,7 +123,7 @@
<string name="you">You</string>
<string name="cd_location_notes">Location notes</string>
<string name="cd_teleported">Teleported</string>
<string name="cd_tor_status">Tor status</string>
<string name="cd_open_location_channels">Open location and channel settings</string>
<string name="cd_connected_peers">Connected peers</string>
<string name="cd_geohash_participants">Geohash participants</string>
<string name="cd_ready_for_handshake">Ready for handshake</string>
@ -275,6 +275,9 @@
<string name="hotspot_share_via_subtitle">Create Wi-Fi hotspot to share offline</string>
<string name="hotspot_share_other">Share via Quick Share</string>
<string name="hotspot_share_other_subtitle">Use standard Android sharing</string>
<string name="hotspot_disconnect_title">Disconnect current Wi-Fi Direct connection?</string>
<string name="hotspot_disconnect_message">Starting the hotspot will disconnect the current Wi-Fi Direct connection. This may interrupt Cast, Android Auto, Quick Share, or another nearby connection.</string>
<string name="hotspot_disconnect_confirm">Disconnect and start</string>
<!-- APK Installation -->
<string name="install_bitchat_title">Install Received APK</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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