mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-25 07:40:36 +00:00
Merge remote-tracking branch 'origin/main' into ui-dynamic-size-component
This commit is contained in:
commit
ff6a5fc59a
249
.agents/skills/android-readme-screenshot-studio/SKILL.md
Normal file
249
.agents/skills/android-readme-screenshot-studio/SKILL.md
Normal 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.
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
176
AGENTS.md
@ -1,79 +1,127 @@
|
||||
# Bitchat Android - Agent Guide
|
||||
# Repository Guidelines
|
||||
|
||||
This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase.
|
||||
## Privacy & External Disclosure (Mandatory)
|
||||
|
||||
## 1. Project Overview
|
||||
**Bitchat** is a decentralized, off-grid communication application focused on privacy and censorship resistance. It utilizes mesh networking (primarily Bluetooth LE and Tor/Arti) to enable peer-to-peer messaging without centralized servers.
|
||||
Protect the developer's privacy without exception. Treat all data from the
|
||||
developer's computer, accounts, devices, workspace, and communications as
|
||||
private unless the developer explicitly authorizes sharing a specific item
|
||||
with a specific destination.
|
||||
|
||||
**Key Technologies:**
|
||||
- **Language:** Kotlin (JVM Target 1.8)
|
||||
- **UI Framework:** Jetpack Compose (Material 3)
|
||||
- **Asynchronous:** Kotlin Coroutines & Flow
|
||||
- **Networking:** Bluetooth Low Energy (BLE), Tor (Arti Rust bridge), OkHttp
|
||||
- **Architecture:** MVVM with Clean Architecture principles
|
||||
- **Build System:** Gradle (Kotlin DSL)
|
||||
- Never expose, publish, transmit, upload, paste, commit, or otherwise disclose
|
||||
personally identifying or potentially identifying information. This includes
|
||||
names, usernames, email addresses, account handles, home or workplace details,
|
||||
local or absolute paths, drive or volume names, hostnames, device names and
|
||||
serials, IP or MAC addresses, network names, peer IDs, messages, contacts,
|
||||
tokens, keys, credentials, logs, metadata, and unique environment details.
|
||||
- Never post any repository, workspace, device, account, conversation, log, or
|
||||
developer data to GitHub, issue trackers, pull requests, comments, gists,
|
||||
paste sites, analytics services, AI services, remote APIs, chat systems, or
|
||||
any other external destination without the developer's explicit permission
|
||||
for that exact data and destination. A general request to work on the
|
||||
repository is not permission to disclose data.
|
||||
- Keep local filesystem layout and computer information private. Do not include
|
||||
absolute paths, usernames, home-directory names, mounted drives, shell
|
||||
prompts, environment variables, installed-software inventories, hardware
|
||||
details, or similar machine fingerprints in commits, patches, documentation,
|
||||
screenshots, test fixtures, examples, issue text, PR text, or comments.
|
||||
- Before any authorized external action, inspect and sanitize the exact content
|
||||
being sent. Use repository-relative paths and neutral placeholders; remove
|
||||
hidden metadata and redact unrelated or identifying content. When safe
|
||||
sanitization cannot be guaranteed, do not send the data and ask the developer
|
||||
how to proceed.
|
||||
- Use synthetic, non-identifying test data only. Never use real messages,
|
||||
contacts, account data, device identifiers, network information, keys, or
|
||||
production logs in tests, examples, screenshots, fixtures, or bug reports.
|
||||
- For geohash, GPS, map, location, proximity, or geofencing work, use only
|
||||
clearly synthetic coordinates and geohashes that cannot reveal the
|
||||
developer's location or routines. Never request, read, derive, record, use,
|
||||
display, transmit, or retain live or historical location data from any
|
||||
physical phone, watch, emulator image, computer, browser, account, or other
|
||||
device connected to or accessible from the developer's computer. Never infer
|
||||
location from IP addresses, networks, photos, logs, timestamps, nearby peers,
|
||||
device metadata, or test output. Treat all location and movement data as
|
||||
highly sensitive personal information and keep it out of commits, tests,
|
||||
fixtures, screenshots, documentation, issues, pull requests, and external
|
||||
services without exception.
|
||||
- Keep command output, raw logs, crash dumps, screenshots, recordings, build
|
||||
artifacts, and Mesh Lab evidence local. Review any derived summary for
|
||||
identifying details before sharing it, even when external sharing has been
|
||||
authorized.
|
||||
- Do not weaken these protections for convenience, debugging, automation, or
|
||||
collaboration. If another instruction conflicts with this section, stop and
|
||||
obtain explicit developer direction before disclosing anything.
|
||||
|
||||
## 2. Architecture & Directory Structure
|
||||
The application follows a clean architecture pattern, heavily modularized by feature within the `app` module.
|
||||
## Project Structure & Architecture
|
||||
|
||||
**Root Package:** `com.bitchat.android`
|
||||
`app/` is the Kotlin/Compose phone client; its main packages cover UI, services,
|
||||
BLE/Wi-Fi mesh, protocol, Noise/crypto, identity, Nostr, geohash, and media.
|
||||
`wear/` is the Wear OS client. Module resources live in `src/main/`
|
||||
and JVM tests in `src/test/`. Specifications are in `docs/`; tooling is in
|
||||
`tools/`.
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `ui/` | **Presentation Layer**: Jetpack Compose screens, themes, and ViewModels. |
|
||||
| `service/` | **Core Service**: Contains `MeshForegroundService`, managing persistent background connectivity. |
|
||||
| `mesh/` | **Mesh Networking**: Logic for peer discovery, advertising, and message routing. |
|
||||
| `protocol/` | **Wire Protocol**: Definitions of messages exchanged between peers. |
|
||||
| `crypto/` | **Security**: Cryptographic primitives and key management. |
|
||||
| `noise/` | **Encryption**: Implementation of the Noise Protocol Framework for secure channels. |
|
||||
| `identity/` | **User Identity**: Management of user profiles and public/private keys. |
|
||||
| `features/` | **App Features**: Sub-modules for `voice`, `file`, and `media` handling. |
|
||||
| `nostr/` | **Relay Integration**: Logic for Nostr protocol integration and relay management. |
|
||||
| `geohash/` | **Location**: Utilities for location-based features and geohashing. |
|
||||
| `net/` | **Networking**: General network utilities and abstractions. |
|
||||
`app/` is the source of truth for shared mesh/protocol code.
|
||||
`syncSharedAppSources` generates `wear/build/sharedSrc` from the include list
|
||||
in `wear/build.gradle.kts`. Extend that list; never copy shared
|
||||
Kotlin into `wear/src/` or edit generated `build/` content.
|
||||
|
||||
## 3. Key Components
|
||||
## Build, Test & Development Commands
|
||||
|
||||
### UI Layer (Jetpack Compose)
|
||||
- **Activity**: Single-Activity architecture (`MainActivity.kt`).
|
||||
- **Navigation**: Jetpack Compose Navigation.
|
||||
- **State Management**: `ViewModel` exposing `StateFlow` to Composables.
|
||||
- **Theme**: Custom theme definitions in `ui/theme`.
|
||||
Use JDK 21 and the Android SDK versions in `gradle/libs.versions.toml`.
|
||||
|
||||
### Networking & Connectivity
|
||||
- **MeshForegroundService**: The critical component that keeps the mesh network alive. It manages the lifecycle of BLE scanning/advertising and other transport layers.
|
||||
- **BLE Stack**: Located in `mesh/` and `net/`, handles the intricacies of Android Bluetooth interactions.
|
||||
- **Tor/Arti**: Integrated via JNI (`jniLibs`) to provide anonymous internet routing where available.
|
||||
```sh
|
||||
./gradlew :app:assembleDebug :wear:assembleDebug
|
||||
./gradlew testDebugUnitTest lintDebug
|
||||
./gradlew connectedAndroidTest
|
||||
./gradlew clientRewriteContractTest
|
||||
tools/arti-build/verify-checksums.sh
|
||||
```
|
||||
|
||||
## 4. Development Standards
|
||||
CI runs `testDebugUnitTest lintDebug`; instrumented tests require a device.
|
||||
Follow `docs/reproducible-builds.md` and `docs/maintainer-release-guide.md` for
|
||||
dependency and release work.
|
||||
|
||||
### Code Style
|
||||
- **Kotlin**: Adhere to official Kotlin coding conventions.
|
||||
- **Compose**: Use functional components. Hoist state to ViewModels where possible.
|
||||
- **Coroutines**: Use `suspend` functions for all I/O operations. strictly avoid blocking the main thread.
|
||||
- **Naming**: Clear, descriptive names. Follow standard Android naming patterns (e.g., `*ViewModel`, `*Repository`, `*Screen`).
|
||||
## Coding Style & Naming
|
||||
|
||||
### Testing
|
||||
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
|
||||
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
|
||||
- **Device Mesh Tests (ADB test hooks)**: Two-physical-device scenarios driven over ADB, **kept separate from Gradle/CI** — run them manually when changing mesh/crypto/transfer code. A debug-only broadcast receiver (`app/src/debug/java/com/bitchat/android/testhook/`, never in release builds) exposes mesh operations (scan, connect, Noise handshake, DMs, broadcast, files, raw packet injection) via `am broadcast -a com.bitchat.droid.TEST_HOOK`; the host orchestrator is `tools/release_gate/mesh_lab.py`. Full guide: `docs/release-gate-runbook.md` appendix "mesh lab".
|
||||
- Prereqs: `adb` on PATH, Python 3.10+, two devices with USB debugging, **both unlocked with screen on** (locked/dozing → POWER_SAVER → flaky timing).
|
||||
- Setup: `./gradlew assembleDebug && python3 tools/release_gate/mesh_lab.py setup --serial-a <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.
|
||||
|
||||
13
README.md
13
README.md
@ -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.
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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?) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1872,6 +1872,7 @@ fun PrivateChatSheet(
|
||||
onSendFileNote = { peer, channel, path ->
|
||||
viewModel.sendFileNote(peer, channel, path)
|
||||
},
|
||||
recorderFactory = viewModel::createVoiceRecorder,
|
||||
showCommandSuggestions = false,
|
||||
commandSuggestions = emptyList(),
|
||||
showMentionSuggestions = false,
|
||||
|
||||
@ -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() } }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 ->
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user