Compare commits

...

43 Commits

Author SHA1 Message Date
Comodore125
aaa70f3f27
Merge a97cc1782acb4706e5309238903fd1c1fe38a935 into 094657efa0aabbb6f71c9050149d1d01aee96400 2026-08-04 11:46:03 +02:00
callebtc
094657efa0
Merge pull request #861 from permissionlesstech/codex/delay-peer-availability-notification
Delay nearby peer notification to collect accurate count
2026-08-03 00:18:52 +02:00
callebtc
1376b55931 Delay nearby peer availability notification 2026-08-02 21:54:03 +02:00
callebtc
d852f3dc3a
Merge pull request #744 from aharshit123456/i18n/backfill-incomplete-locales
i18n: backfill 7 severely incomplete locale translations (he, pl, zh-rCN, zh-rTW, ms, ta, uk)
2026-08-02 21:34:49 +02:00
callebtc
94e46da234 Merge main into i18n backfill branch 2026-08-02 20:36:48 +02:00
callebtc
49753ccb88
Merge pull request #860 from permissionlesstech/feat/bubble-media-messages
feat(ui): wrap image and voice messages in bubble shells
2026-08-02 20:33:10 +02:00
callebtc
bc572cc2ea fix(ui): make media bubble shell long-clickable
Self and grouped voice-note bubbles had no long-press target: the sender
label is hidden and VoiceNotePlayer controls consume touches, so the
message action sheet was unreachable. combinedClickable on the shell
restores long-press for every media bubble.
2026-08-02 20:28:04 +02:00
GitHub Action
c02dda308e Automated update of relay data - Sun Aug 2 06:55:18 UTC 2026 2026-08-02 06:55:19 +00:00
callebtc
a49f38216a
Merge pull request #853 from permissionlesstech/codex/remove-rssi-polling
Remove periodic GATT RSSI polling
2026-08-02 03:12:40 +02:00
callebtc
2325b7c9c3 Remove periodic GATT RSSI polling 2026-08-02 01:42:45 +02:00
callebtc
b8c7470ace
Merge pull request #847 from heyaim/hide-command-suggestions-after-send
Hide the command and mention popups after a message is sent
2026-08-02 00:17:28 +02:00
callebtc
28bd6bdcfd
Merge pull request #852 from permissionlesstech/codex/fix-readme-screenshot-crop-20260801
Fix cropped README screenshot controls
2026-08-02 00:11:06 +02:00
callebtc
dcec0c10ec Test README screenshot crop handling 2026-08-02 00:06:36 +02:00
callebtc
17a173a685 Fix cropped README screenshot controls 2026-08-02 00:03:46 +02:00
callebtc
b43dc1e48f feat(ui): wrap image and voice messages in bubble shells
Bubble mode previously rendered media as flat header-plus-card rows while
text messages got the tinted bubble treatment. Introduce MediaBubbleShell,
which mirrors BubbleTextMessageLayout (author-colour wash and hairline,
speaker-side tail, in-bubble sender name, flush-right timestamp with
delivery ticks for own private messages), and route ImageMessageItem and
AudioMessageItem through it. Matrix mode is unchanged.
2026-08-01 23:48:13 +02:00
callebtc
53cd03886c
Merge pull request #851 from permissionlesstech/codex/refresh-readme-screenshots-20260801
Refresh README feature screenshots
2026-08-01 23:40:19 +02:00
callebtc
5cc181f1e2 Improve README screenshot capture recipe 2026-08-01 23:38:26 +02:00
callebtc
1965d4bd7e Refresh README feature screenshots 2026-08-01 23:25:03 +02:00
callebtc
2e943b2ebc
Merge pull request #846 from permissionlesstech/bitchat-ui-bubbles
feat(chat): add bubbles chat UI mode with peer-color bubbles
2026-08-01 21:31:57 +02:00
callebtc
aad0c4d7f8 feat(settings): combine theme and chat style into one card
System/Light/Dark and the chat style picker now share the single Theme
card in About -> Settings: theme chips on the first row, chat style
chips on the second, ordered Bubbles then Matrix. Bubbles remains the
default for fresh installs.
2026-08-01 21:17:30 +02:00
callebtc
bd5ae8702a feat(chat): never show our own nickname in bubbles mode
The end side and the author-colour wash already attribute own bubbles,
so the @name heading on the first bubble of an own run was redundant.
Received bubbles keep their sender names; matrix mode is unchanged.
2026-08-01 20:45:42 +02:00
callebtc
d85401fb49 fix(chat): never let the bubble meta cluster influence text wrapping
The no-break-space reservation narrowed the text's own wrap width, so
first lines wrapped early and carried dead slack. The cluster (timestamp
plus delivery checks) is now placed from the laid-out text: it rides
flush-right in the last line's slack when there is room and drops below
the text only when there is not. The body wraps at the full bubble
width either way, one-liners keep hugging their content, and matrix
mode is unchanged. AnnotatedClickableText gains an optional onTextLayout
callback to support the measurement.
2026-08-01 20:10:25 +02:00
callebtc
79d2e0328a fix(chat): keep bubble meta flush-right without extra lines or width
The timestamp + checks cluster now overlays the bubble's bottom-end
corner while the body reserves an invisible no-break-space run (capped
by a zero-width word joiner so it is never trimmed) exactly where the
cluster lands. The reservation rides the last text line when there is
room and wraps only when there isn't, so the cluster sits flush-right
on the last line for every message length: no overlap, no forced new
line, no minimum bubble width. Matrix mode is unchanged.
2026-08-01 19:55:07 +02:00
callebtc
6d9dfd970c fix(chat): trail bubble timestamp and checks inline with the body
Replaces the separate bottom meta row, which forced every bubble to at
least the meta row's width and added a line even to one-line messages.
The timestamp + checks cluster now trails the body inline: it rides
the last text line when there is room and wraps only when there isn't,
so bubbles hug their content again. Own private messages keep the
constant-width grey-to-green checks with the colour tween; matrix mode
is unchanged.
2026-08-01 18:04:39 +02:00
callebtc
d0c2a39140
Merge pull request #840 from permissionlesstech/codex/wear-speech-bubble-app-icon
Replace Wear app icon with green BitChat glyph
2026-08-01 18:03:16 +02:00
callebtc
498c35188f feat(chat): right-align bubble timestamps beside the delivery checks
Bubbles now park a bottom meta row at their end edge, like classic
messengers: the timestamp sits right-aligned, followed at a fixed gap
by the delivery checks for own private messages, instead of trailing
the body text mid-line. Matrix mode keeps its inline trailing
timestamps.
2026-08-01 17:34:36 +02:00
callebtc
6eafa5932d feat(chat): anchor delivery checks to the bubble's bottom-end corner
Instead of trailing the timestamp mid-line, the delivery checks now
park at the bubble's bottom-end corner like classic messengers, with
the body text reserving a small end inset so the last line never
collides with them. The checks keep their constant-width grey-to-green
behaviour, colour tween, and scale pop (shared DeliveryStatusIcon).
Matrix mode is unchanged.
2026-08-01 17:07:41 +02:00
callebtc
7247d0ad5b feat(chat): constant-width two-check delivery marker that lights up green
Delivery/read markers previously swapped glyphs as acknowledgements
arrived, reflowing the text around them. Both checks now render from
the start in a disabled grey and simply recolour as the state advances
(delivered lights the first check, read lights both), so nothing ever
pushes message text around. Read receipts use the app's primary green
instead of the blue accent, a quick colour tween lights the checks up,
and the standalone marker adds a snappy scale pop when the state
advances. Applies to both matrix and bubbles modes.
2026-08-01 16:06:37 +02:00
callebtc
af91abab01 feat(chat): pull sender and delivery status into bubbles, thin-space hash suffix
Bubbles mode now reads like a classic messenger thread:

- the sender's name heads the first bubble of each run instead of
  floating above it; continuation bubbles skip it
- the delivery/read marker for own private messages trails the
  timestamp inside the bubble (same glyph mapping as the standalone
  marker); media rows keep the beneath-card marker since they have no
  inline text
- display names and their #abcd disambiguation suffix are now
  separated by a thin space (U+2009) in both matrix and bubbles modes
2026-08-01 15:05:41 +02:00
callebtc
7569fba7b4 feat(chat): align self media rows with bubbles chat UI mode
In bubbles mode, self-authored image, voice-note, and file rows now
align to the end side with the same tail-corner cue as text bubbles,
instead of sitting on the received side where they read as someone
else's content. Grouped self voice notes also keep the run on the
correct side. VoiceNotePlayer gains an optional modifier so the player
can hug the end side at a capped width. Received media and matrix mode
are unchanged.
2026-08-01 13:57:40 +02:00
heyaim
862fd8449d Hide the command and mention popups after a message is sent
When a slash command is sent without tapping the autocomplete list, the
command popup stays on screen with its old suggestions. The send path
clears the input field in code, and the popup is hidden only from the
field's text-change handler, which a code-driven clear does not run, so
the show flag is never turned off. The mention popup has the same cause.

CommandProcessor gains clearSuggestions(), which hides both popups and
empties their lists, the same resets the two select functions already do
inline. ChatScreen's send handler calls it where it already resets the
field.

The private chat sheet called updateMentionSuggestions on every keystroke
while rendering its own popups as hidden, so the only effect was on the
main composer behind it: typing @ in the sheet and then sending or
dismissing left a stale mention popup on the main screen. That call is
removed.

Tests in CommandProcessorTest open each popup and assert clearSuggestions
hides it. The unit tests do not drive Compose, so they cover the function
the send handler calls rather than the handler itself.

Full Android unit suite green; lint and the debug build pass.

Fixes #250.
2026-07-31 23:27:10 -05:00
callebtc
94d5fa3084 feat(chat): add bubbles chat UI mode with peer-color bubbles
Add a toggleable chat transcript style alongside the existing matrix
transcript. Bubbles mode renders text messages as classic messenger
bubbles: own messages on the right, peers on the left, each bubble
washed with the author's stable identity-derived peer colour so the
speaker stays identifiable without changing any theme, surface, or
background colours.

- ChatUiMode preference (Matrix / Bubbles, default Bubbles) persisted
  via ChatUiModeManager, mirroring ThemePreferenceManager
- BubbleTextMessageLayout: rounded bubble with a subtle tail on the
  speaker's side, width capped at 80% so long messages wrap; sender
  labels, grouping, timestamps, mentions, links, long-press, and the
  existing spring entry/placement animations are unchanged
- Delivery status for own private messages moves beneath the bubble in
  bubbles mode so it never overlaps the tail
- Chat style picker in About -> Settings reusing the ThemeChip pattern
2026-08-01 01:41:36 +02:00
callebtc
e07a38f634
Merge pull request #844 from permissionlesstech/codex/fix-wear-chat-auto-scroll
Fix Wear chat auto-scroll timing
2026-08-01 01:34:05 +02:00
callebtc
e907773b5f
Merge pull request #843 from permissionlesstech/codex/issue-753-live-ptt
feat: add live push-to-talk on Android and Wear OS
2026-08-01 01:33:53 +02:00
callebtc
e9bc8e0f31 docs(protocol): specify live voice v1 2026-08-01 00:42:32 +02:00
callebtc
44b2b8cb79 Fix first Wear message auto-scroll 2026-08-01 00:32:20 +02:00
callebtc
efeb9b54b5 Fix Wear chat auto-scroll timing 2026-07-31 17:51:16 +02:00
callebtc
cbc59aaf8b feat(voice): add live push-to-talk 2026-07-31 17:40:37 +02:00
callebtc
e62e3bd1ea Replace Wear launcher wordmark with green BitChat glyph
Use the About-button pixel speech bubble in app primary green with BIT inside, on a white circular plate so the watch icon matches in-app branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 03:32:22 +02:00
aharshit123456
6cd5d1687e i18n: backfill 7 severely incomplete locale translations
Hebrew, Polish, Simplified Chinese, Traditional Chinese, Malay, Tamil,
and Ukrainian were only 10-12% translated (38-48 of 395 string keys),
falling back to English for nearly everything. Backfill each to 100%
(395/395 keys), including the two shared plurals (notification_and_more,
people_count) with locale-correct CLDR plural categories.

Also:
- Fix values-zh-rTW: ~27 keys in the verify_*/fingerprint_* block were
  Simplified Chinese pasted into the Traditional Chinese file (e.g. 验证
  instead of 驗證); corrected to proper Traditional Chinese script.
- Fix a leftover English verify_*/fingerprint_* block (~30 keys) present
  in pl, ms, ta, uk that predated this change and wasn't part of the
  originally-missing-key set.
- Fix values-pl version_prefix, mistranslated as "w%1$s" instead of
  preserving the literal version-string prefix "v%1$s".
- Fix a duplicate-key bug in values-ms where a stale untranslated
  <string name="notification_and_more"> coexisted with the new
  <plurals name="notification_and_more">.

All translations are machine-translated (flagged as such via an
in-file comment: "pending native-speaker review") and should be
reviewed by fluent speakers before being considered final. Verified:
well-formed XML, exactly 395/395 keys with no duplicates in all 7
files, zero placeholder (%1$s/%2$d/etc.) mismatches against the
English source, and a clean `./gradlew :app:processDebugResources`
resource compile.

Relates to #737 (multilingual support request) — this addresses the
"languages already added but barely translated" half of that issue;
an in-app language switcher / android:localeConfig and a proper
community-translation pipeline (e.g. Weblate) are separate follow-ups.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:39:53 +05:30
Comodore125
a97cc1782a
Rename values-cz to values-cs
fixed filesystem naming
2025-07-12 23:37:08 +02:00
Comodore125
7af6f35e4a
Update values-cz
Improved for czech natives as me
2025-07-12 23:34:07 +02:00
Comodore125
cbbf0423e2
New translation for Czech language: Create values-cz
Will review in couple of minutes before marking as final from my side.
2025-07-12 23:30:15 +02:00
95 changed files with 4685 additions and 911 deletions

View File

@ -1,6 +1,6 @@
---
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.
description: Create or refresh polished, high-resolution screenshots of the Bitchat Android app for README and repository showcase use. Use this skill whenever a user asks for README screenshots, app-store-like repository images, a populated mesh-chat showcase, voice-note or media conversation captures, a geohash globe image, higher-resolution Android emulator captures, or a PR that adds or replaces documentation screenshots. It owns the complete workflow from latest-main isolation and deterministic synthetic fixtures through real app rendering, visual inspection, system-chrome cropping, README asset updates, clean builds, and an optional PR. Do not use it for before/after UI regression evidence, which belongs to android-ui-visual-review, or for physical mesh behavior, which belongs to mesh-lab.
compatibility: Requires git, gh, the Android SDK and emulator, adb, Java/Gradle, Python 3, and image inspection support. FFmpeg is useful for capture-only media preparation.
---
@ -34,7 +34,11 @@ 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.
the verified current-pair fast path, and the final acceptance checklist. When
the request matches the existing README pair and the production UI has not
materially changed, try that fast path first and then validate every visible
result. Fall back to tracing the current implementation when an entry point,
state model, or composition has changed.
When a populated screen requires a debug fixture, also read
[../android-ui-visual-review/references/fixture-recipes.md](../android-ui-visual-review/references/fixture-recipes.md).
@ -62,7 +66,7 @@ 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;
- emulator profile class, portrait orientation, theme, and locale;
- crop policy and final asset dimensions;
- existing asset path and README reference;
- behaviors the static screenshot does not prove.
@ -71,19 +75,13 @@ 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
## Use a high-resolution Android 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.
Prefer the newest stable Android runtime installed locally and a large,
high-density portrait emulator profile. Reuse a previously validated capture
profile when it remains available, but verify the guest properties and keep
the resulting profile facts in the local capture contract rather than the
repository or GitHub text.
After boot, record the guest values with an explicit emulator selector:
@ -127,7 +125,10 @@ 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.
and remove every capture-only hook before committing. Compose image rows may
remember a decoded bitmap by file path. After replacing the bytes at an
unchanged path, relaunch the app or use a new destination path before judging
the revised crop.
## Capture from the real app
@ -144,9 +145,17 @@ Inspect the full screenshot immediately. Check message count and order,
nickname ownership, peer count, waveform variety, image visibility, globe
center, grid precision, clipping, and composer placement.
Inject a complete timeline in one operation and allow at least two seconds of
quiet UI time after the fixture reports success. This avoids capturing entry
animations, incomplete placement, or media that has not finished decoding.
Crop only Android system chrome. Preserve Bitchat's app header, translucent
overlap, content, and composer. Derive the crop from the observed status and
navigation insets; do not blindly reuse pixel offsets from a different profile.
Because the app renders edge-to-edge, app controls may extend into the reported
navigation inset. Place the bottom crop after the final control outline and
shadow but before the system gesture affordance; removing the entire inset can
clip the app itself.
Keep every final README screenshot in a matched portrait size.
Use image inspection after the crop. File dimensions and a successful ADB
@ -222,8 +231,10 @@ GitHub writes require user authorization. When authorized:
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.
5. Describe the exact capture sequence, synthetic fixture disclosure,
repository-safe validation commands, and limitations. Keep emulator,
runtime, hardware, and local-environment facts out of GitHub text whenever
repository privacy rules classify them as machine identifiers.
6. Verify the PR head and checks with `gh pr view` and `gh pr checks`.
7. Merge only when the user explicitly requested it and required checks allow
it. Prefer the repository's normal merge strategy and use `gh`.

View File

@ -3,12 +3,14 @@
"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.",
"prompt": "Refresh the Bitchat README with two polished screenshots from the latest main UI: a populated mesh chat and the geohash globe centered on the Middle East without zooming. Use a large high-resolution Android emulator, open a PR, and merge it after checks pass.",
"expected_output": "The agent works in a fresh latest-main worktree, tries the documented current-pair fast path before rediscovering the fixture, captures the real app on a verified large portrait emulator profile, uses deterministic synthetic state, centers the globe without changing whole-Earth scale, removes capture hooks, validates the assets, opens a PR, waits for checks, and merges only because the prompt explicitly authorizes it.",
"files": [],
"expectations": [
"Uses a fresh worktree rooted at current origin/main and leaves the original checkout untouched.",
"Captures from the real Android UI on a verified high-resolution large Pixel profile.",
"Captures from the real Android UI on a verified high-resolution portrait emulator profile.",
"Uses the verified nine-row chat fixture and synthetic thky globe seed as fast starting points when the current UI still matches, while visually validating the result.",
"Waits for fixture completion and quiet UI time, and invalidates the media path cache when replacing image bytes at the same destination.",
"Keeps the requested Middle East focus while preserving whole-Earth zoom.",
"Removes temporary fixtures and passes a clean debug build before committing.",
"Uses gh for the authorized PR and merge without publishing machine identifiers."
@ -38,6 +40,20 @@
"Uses the PR merge-base and paired before/after capture workflow.",
"Publishes only the requested PR comment rather than opening an unrelated screenshot PR."
]
},
{
"id": 4,
"prompt": "The bottom of one or both README showcase screenshots is cropped. Correct the images without changing their content, update the screenshot skill so this does not recur, and open a new PR.",
"expected_output": "The agent inspects both merged assets at full size, identifies every affected screen, recaptures from the real app with the existing deterministic fixtures, and treats the navigation inset as an inspection region rather than an automatic crop. The final crop preserves complete app controls while excluding the system gesture affordance, keeps the pair matched, adds a reusable edge-to-edge guardrail to the skill, validates and visually inspects both images, and opens a sanitized PR.",
"files": [],
"expectations": [
"Inspects both screenshots and corrects every affected asset rather than assuming only one is cropped.",
"Recaptures the real app with the established deterministic synthetic content instead of padding or compositing the merged PNGs.",
"Uses accessibility bounds and full-size pixel inspection to place the bottom crop after complete app controls and before system gesture chrome.",
"Verifies the entire chat composer border and bottom padding plus every globe action-button corner and shadow are visible.",
"Keeps paired screenshots at identical dimensions and passes the screenshot validator and clean debug build.",
"Updates the skill with a general edge-to-edge crop rule and publishes no device or machine identifiers."
]
}
]
}

View File

@ -4,6 +4,99 @@ Use these recipes as composition guidance, then adapt them to the user's exact
request and the current UI. The requested chronology and framing always win over
the examples.
## Verified current-pair fast path
Use this baseline first when refreshing the existing README mesh-chat and
geohash-globe pair without a material production UI change. It records a
known-good capture, not a permanent UI contract: verify entry points, visible
state, insets, and output dimensions on every run.
### One-pass app preparation
Build and install the ABI-matching debug APK. A generic `app-debug.apk` may not
exist when the project emits ABI splits, so resolve the installed emulator ABI
and select the matching output before searching for alternate build tasks.
Use normal onboarding or a temporary debug preparation command to:
- mark onboarding complete;
- set the synthetic nickname `trailhead`;
- select the production `ChatUiMode.Bubbles` presentation;
- grant only the runtime permissions needed to reach the surface; and
- keep BLE and Wi-Fi Aware debug transport disabled during deterministic
rendering.
Launch the target Activity before injecting process-local state. A preparation
command may use the existing `PermissionManager` and `AppStateStore` APIs, but
must remain capture-only and be removed before the clean build.
### Exact chat fixture used for the current pair
Clear the in-memory showcase state, add four synthetic peers, and use the real
local mesh peer ID only to mark self-authored rows. Use `solace` for the remote
sender and insert these nine rows at one-minute intervals in one operation:
| Order | Sender | Content |
|---|---|---|
| 1 | `trailhead` | mountain image |
| 2 | `solace` | `That view is unreal.` |
| 3 | `trailhead` | `Worth the climb.` |
| 4 | `solace` | `How's the signal up there?` |
| 5 | `trailhead` | `Still holding strong.` |
| 6 | `trailhead` | voice note |
| 7 | `solace` | voice note |
| 8 | `trailhead` | voice note |
| 9 | `solace` | `👍` |
A fixed epoch such as `1767258000000` keeps ordering stable. The displayed
clock text is locale- and time-zone-dependent, so validate consistency rather
than promising a specific rendered hour.
Reuse the existing rights-safe mountain subject when the brief has not changed.
A near-square source crop around 840×800 produced enough image height while
leaving the reaction above the composer. If the crop changes, relaunch the app
or change the cache destination path; the image row can retain the previous
bitmap when the path is reused.
Generate three local, rights-safe speech clips with visibly different cadence,
then transcode them to the app's normal M4A/AAC path. This known-good synthetic
set used roughly 170, 220, and 145 words per minute:
1. `The trail is clear. I can hear you.`
2. `Copy that. Sending one back now.`
3. `Perfect. The mesh is still holding strong.`
Run each file through `AudioWaveformExtractor` and cache its 120-bin result via
`VoiceWaveformCache`. Wait for the fixture's structured success result, then
allow at least two additional seconds for Compose placement and media decoding
before capture.
### Exact globe path used for the current pair
Start the production, non-exported `GeohashPickerActivity` through a temporary
in-app debug command. Seed it with the explicitly synthetic geohash `thky`,
wait about three seconds for the camera to settle, then invoke the production
minus control three times with about one second between changes. The verified
result was precision 1 with label `#t`, the whole Earth visible, and the Arabian
Peninsula/Persian Gulf region beneath the center crosshair.
Treat `thky` as a fast starting point, not a substitute for inspection. Reject
the result if geography, camera distance, grid, label, or controls differ from
the brief. Never source the seed from device location, IP-derived location, or
account data.
### Capture and crop baseline
Capture the full screen only after the UI has been still for at least two
seconds. Re-observe the status and navigation insets, then remove only those
bands while preserving all app UI. Reuse prior offsets only when the local
profile and measured insets still match.
Keep both outputs at identical dimensions. Run the screenshot validator and
inspect both images at full size. Keep raw captures, generated audio, fixture
media, profile facts, and device output local; only the final PNG assets belong
in the repository.
## Mesh-chat showcase
### Visual goal
@ -129,9 +222,14 @@ Reject the capture when:
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.
Treat the navigation inset as an inspection region, not an automatic crop
amount. Bitchat uses edge-to-edge layout, so the composer's border, globe-button
corners, or their shadows may occupy part of that region. Use accessibility
bounds plus full-size pixel inspection to place the crop below every app
control and above the system gesture affordance.
Treat previously observed crop offsets as local evidence, not as a universal
rule or repository documentation. Re-measure when the profile changes.
Every paired README image should:
@ -152,6 +250,7 @@ Every paired README image should:
- [ ] Waveforms were extracted from real speech audio and look distinct.
- [ ] Final reaction is visible above the composer.
- [ ] Header peer count and nickname are synthetic and intentional.
- [ ] The complete composer border and bottom padding are visible.
### Globe
@ -160,6 +259,7 @@ Every paired README image should:
- [ ] Whole Earth and grid are visible.
- [ ] Selected cell and crosshair are legible.
- [ ] Hint and precision controls are unobstructed.
- [ ] Every action-button corner and shadow is fully visible.
### Repository
@ -169,4 +269,3 @@ Every paired README image should:
- [ ] Screenshot validator passes.
- [ ] PR text contains no machine or personal identifiers.
- [ ] Static-capture limitations are disclosed.

View File

@ -0,0 +1,142 @@
package com.bitchat.android.testhook
import android.media.MediaCodec
import android.media.MediaExtractor
import java.io.File
import java.nio.ByteOrder
import kotlin.math.sqrt
internal data class PttTestAudioAnalysis(
val decodedSamples: Long,
val rms: Double,
val silentBlockFraction: Double,
val longestSilentBlockRun: Int,
val zeroCrossingsPerSecond: Double
)
/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */
internal object PttTestAudioAnalyzer {
private const val BLOCK_SAMPLES = 1_024
private const val SILENT_BLOCK_RMS = 0.015
private const val SAMPLE_RATE = 16_000.0
fun analyze(file: File): PttTestAudioAnalysis {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
try {
extractor.setDataSource(file.absolutePath)
val track = (0 until extractor.trackCount).firstOrNull { index ->
extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true
} ?: error("received live stream has no audio track")
extractor.selectTrack(track)
val format = extractor.getTrackFormat(track)
val mime = format.getString("mime") ?: error("received live stream has no audio MIME")
val activeDecoder = MediaCodec.createDecoderByType(mime).apply {
configure(format, null, null, 0)
start()
}
decoder = activeDecoder
val info = MediaCodec.BufferInfo()
var inputEnded = false
var outputEnded = false
var idlePolls = 0
var decodedSamples = 0L
var sumSquares = 0.0
var zeroCrossings = 0L
var previousSample: Short? = null
var blockSquares = 0.0
var blockSamples = 0
var blocks = 0
var silentBlocks = 0
var silentRun = 0
var longestSilentRun = 0
while (!outputEnded && idlePolls < 500) {
if (!inputEnded) {
val inputIndex = activeDecoder.dequeueInputBuffer(10_000L)
if (inputIndex >= 0) {
val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input")
input.clear()
val size = extractor.readSampleData(input, 0)
if (size < 0) {
activeDecoder.queueInputBuffer(
inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
} else {
activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0
else -> if (outputIndex >= 0) {
idlePolls = 0
activeDecoder.getOutputBuffer(outputIndex)?.let { output ->
output.position(info.offset)
output.limit(info.offset + info.size)
output.order(ByteOrder.LITTLE_ENDIAN)
val pcm = output.asShortBuffer()
while (pcm.hasRemaining()) {
val sample = pcm.get()
val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble()
val square = normalized * normalized
sumSquares += square
blockSquares += square
decodedSamples++
blockSamples++
previousSample?.let { previous ->
if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) {
zeroCrossings++
}
}
previousSample = sample
if (blockSamples == BLOCK_SAMPLES) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
} else {
silentRun = 0
}
blockSquares = 0.0
blockSamples = 0
}
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
activeDecoder.releaseOutputBuffer(outputIndex, false)
}
}
}
if (!outputEnded) error("received live stream decoder did not finish")
if (decodedSamples == 0L) error("received live stream decoded to no PCM")
if (blockSamples > 0) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
}
}
return PttTestAudioAnalysis(
decodedSamples = decodedSamples,
rms = sqrt(sumSquares / decodedSamples),
silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks,
longestSilentBlockRun = longestSilentRun,
zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples
)
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
extractor.release()
}
}
}

View File

@ -5,6 +5,12 @@ import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.features.voice.LiveVoiceEvent
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceScope
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.LiveVoiceCapture
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
@ -80,6 +86,8 @@ object TestHookDriver {
"file_send" -> fileSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
"ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(context, intent)
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
@ -491,6 +499,131 @@ object TestHookDriver {
return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled)
}
// MARK: - Live push-to-talk
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
val requestedPeer = intent.getStringExtra("peer")
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val mesh = mesh(context)
LiveVoicePreferences.setEnabled(context, true)
val recipient = requestedPeer?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)
?: return err("ptt_send", "no active mesh route for private conversation")
}
if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) {
val handshake = handshake(context, recipient.meshPeerID, intent)
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
}
val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) }
val recorder = LiveVoiceCapture(
File(context.filesDir, "voicenotes/outgoing"),
target,
syntheticPcm = true
)
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
delay(durationMs)
val finalFile = recorder.stop(canceled = false)
?: return err("ptt_send", "capture did not produce a finalized note")
val captureStats = recorder.stats()
if (finalFile != pendingFile || !finalFile.isFile) {
return err("ptt_send", "finalized note is unavailable")
}
val content = withContext(Dispatchers.IO) { finalFile.readBytes() }
val packet = BitchatFilePacket(
fileName = finalFile.name,
fileSize = content.size.toLong(),
mimeType = "audio/mp4",
content = content
)
val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note")
val transferId = sha256Hex(encoded)
return coroutineScope {
val completion = async(Dispatchers.Default) {
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
}
delay(50)
val sendError = dispatchFileSend(
context,
intent,
mesh,
recipient?.meshPeerID,
packet,
transferId
)
if (sendError != null) {
completion.cancel()
return@coroutineScope sendError.put("cmd", "ptt_send")
}
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer")
if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed")
ok("ptt_send")
.put("live", true)
.put("scope", if (recipient == null) "public" else "dm")
.put("duration_ms", durationMs)
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
.put("bytes", content.size)
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
.put("encoded_frames", captureStats.encodedFrames)
.put("data_packets", captureStats.dataPackets)
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
.put("outbound_packets", captureStats.outboundPackets)
.put("delivered_packets", captureStats.deliveredPackets)
}
}
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val expectedScope = if (intent.getStringExtra("scope") == "public") {
LiveVoiceScope.PUBLIC_MESH
} else {
LiveVoiceScope.DIRECT_MESSAGE
}
LiveVoicePreferences.setEnabled(context, true)
var finished: LiveVoiceEvent.Finished? = null
var liveSnapshot: File? = null
val absorbed = withTimeoutOrNull(timeoutMs) {
LiveVoiceManager.getInstance(context).events.first { event ->
val matches = event.scope == expectedScope &&
(fromPeer == null || event.peerID == fromPeer)
if (matches && event is LiveVoiceEvent.Finished) {
finished = event
liveSnapshot = runCatching {
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
snapshot.parentFile?.mkdirs()
File(event.path).copyTo(snapshot, overwrite = true)
}
}.getOrNull()
}
matches && event is LiveVoiceEvent.Absorbed
} as LiveVoiceEvent.Absorbed
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
val analysis = liveSnapshot?.let { snapshot ->
try {
withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) }
} finally {
snapshot.delete()
}
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
return ok("ptt_recv")
.put("live_observed", finished != null)
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
.put("from", absorbed.peerID)
.put("burst_id", absorbed.burstID)
.put("frames", finished?.frames ?: 0)
.put("data_packets", finished?.dataPackets ?: 0)
.put("bytes", finished?.bytes ?: 0)
.put("expected_packets", finished?.expectedPackets ?: 0)
.put("missing_packets", finished?.missingPackets ?: 0)
.put("decoded_samples", analysis.decodedSamples)
.put("rms", analysis.rms)
.put("silent_block_fraction", analysis.silentBlockFraction)
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
}
// MARK: - Raw packet injection
private fun rawSend(context: Context, intent: Intent): JSONObject {

View File

@ -1,442 +1,417 @@
Relay URL,Latitude,Longitude
bitchat.nostr1.com,40.7057,-74.0136
relay.fundstr.me,42.3601,-71.0589
nostr.2b9t.xyz,34.0549,-118.243
armada.sharegap.net,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
ribo.eu.nostria.app:443,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
relay.nostu.be,40.4167,-3.70329
nostr.whitenode45.ddns.net,40.55,-74.4758
nostr.carroarmato0.be:443,50.914,3.21378
cdn.satellite.earth,40.8302,-74.1299
relay2.veganostr.com,60.1699,24.9384
relay.layer.systems:443,49.0291,8.35695
relay0.gfcom.info,13.7653,100.647
relay.mmwaves.de:443,48.8575,2.35138
offchain.pub,39.1585,-94.5728
bcast.girino.org,43.6532,-79.3832
staging.yabu.me,35.6092,139.73
nostr.overpay.com,29.7449,-95.5343
bridge.tagomago.me,42.3601,-71.0589
nostr-01.yakihonne.com,1.32123,103.695
strfry.bonsai.com,39.0438,-77.4874
relay.sharegap.net,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
dm-test-strfry-generic.samt.st,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
relay-rpi.edufeed.org:443,49.4521,11.0767
vault.iris.to:443,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
nostr.thalheim.io:443,60.1699,24.9384
soloco.nl,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
nostr-relay.zimage.com,34.0549,-118.243
public.crostr.com:443,43.6532,-79.3832
nostr.sathoarder.com:443,48.5734,7.75211
relay.angor.io,48.1046,11.6002
relay.wellorder.net,45.5201,-122.99
relay.mwaters.net,50.9871,2.12554
relay.staging.commonshub.brussels,49.4543,11.0746
nostr-verified.wellorder.net,45.5201,-122.99
nostr-pub.wellorder.net,45.5201,-122.99
nostr-2.21crypto.ch,47.5356,8.73209
relay.kaleidoswap.com,50.8476,4.35717
relay.libernet.app:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
relay.manneken.brussels,49.4543,11.0746
nostr.spicyz.io:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
ribo.us.nostria.app:443,43.6532,-79.3832
relay.loveisbitcoin.com,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
relay02.lnfi.network,35.6764,139.65
relay.cosmicbolt.net:443,37.3986,-121.964
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.endfiat.money:443,59.3327,18.0656
relay.paulstephenborile.com,49.4543,11.0746
rele.speyhard.fi,51.5072,-0.127586
relay.froth.zone,60.1699,24.9384
relay.nostr.blockhenge.com,39.0438,-77.4874
nrl.ceskar.xyz,50.5145,16.0119
rilo.nostria.app,43.6532,-79.3832
nostr.overmind.lol:443,43.6532,-79.3832
nostr.snowbla.de:443,50.4754,12.3683
nostrrelay.taylorperron.com,45.5029,-73.5723
chorus.pjv.me,45.5201,-122.99
relay.nostr.place,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
nostr.girino.org:443,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
nostr.thalheim.io,60.1699,24.9384
ec2.f7z.io,60.1699,24.9384
relay.trotters.cc,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714
relay.momostr.pink,43.6532,-79.3832
relay.nostr.net,43.6532,-79.3832
conduitl2.fly.dev,37.7648,-122.432
chat-relay.zap-work.com,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
relay.minibolt.info:443,43.6532,-79.3832
adre.su,59.9311,30.3609
bitcoinostr.duckdns.org,41.1976,1.11167
nostr.computingcache.com:443,34.0356,-118.442
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.hekster.org:443,37.3986,-121.964
nostr.88mph.life,52.1941,-2.21905
wot.dergigi.com,64.1476,-21.9392
nostr.planix.org,43.6532,-79.3832
relay.satsmarkt.club,52.6907,4.8181
nostrcity-club.fly.dev:443,37.7648,-122.432
aeon.libretechsystems.xyz,55.486,9.86577
testnet.samt.st,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683
wot.sudocarlos.com,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
shu01.shugur.net,21.4902,39.2246
relay.gulugulu.moe:443,43.6532,-79.3832
relay2.angor.io:443,48.1046,11.6002
relay.libernet.app,43.6532,-79.3832
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
relay.zone667.com,60.1699,24.9384
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.nostr.com,50.1109,8.68213
nostr.iskarion.ddns.net,43.3076,-2.95421
relay-dev.satlantis.io,39.0438,-77.4874
relay.sovereignresonance.org,48.9006,2.25929
relay.nostrian-conquest.com,41.223,-111.974
relay.aidatanorge.no,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.klabo.world,47.2343,-119.853
nostr.data.haus:443,50.4754,12.3683
testr.nymble.world,40.8054,-74.0241
relay.inforsupports.com,43.6532,-79.3832
relay.nostrmap.net:443,60.1699,24.9384
nostr.stakey.net:443,52.3676,4.90414
dev-relay.nostreon.com,60.1699,24.9384
nostr.islandarea.net:443,35.4669,-97.6473
nostr.rtvslawenia.com,49.4543,11.0746
relay.bowlafterbowl.com,32.9483,-96.7299
nostr.quali.chat:443,60.1699,24.9384
relay.plebeian.market,50.1109,8.68213
relay-rpi.edufeed.org,49.4521,11.0767
r.0kb.io,32.789,-96.7989
nostr.notribe.net:443,40.8302,-74.1299
relay.getsafebox.app:443,43.6532,-79.3832
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostrelites.org,34.9582,-81.9907
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.thebiglake.org,32.71,-96.6745
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.nostriot.com,41.5695,-83.9786
nostr.christiansass.de,51.7634,7.8887
relay.btcforplebs.com,43.6532,-79.3832
nostr.tagomago.me,42.3601,-71.0589
relayone.geektank.ai,39.0997,-94.5786
relay.dreamith.to:443,43.6532,-79.3832
nostr.liberty.fans,36.8767,-89.5879
wot.makenomistakes.ca,43.7064,-79.3986
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
relay.paulstephenborile.com:443,49.4543,11.0746
relay.ohstr.com,43.6532,-79.3832
nostr-relay.xbytez.io:443,50.6924,3.20113
nostr.ac,38.958,-77.3592
ribo.us.nostria.app,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209
relay.chorus.community:443,48.5333,10.7
relay.cypherflow.ai,48.8575,2.35138
relay.agorist.space:443,52.3734,4.89406
relay.nostrian-conquest.com:443,41.223,-111.974
relay.keykeeper.world,40.7824,-74.0711
relay.getvia.xyz,60.1699,24.9384
relay.nuts.cash,52.3676,4.90414
kotukonostr.onrender.com,37.7775,-122.397
relay.minibolt.info,43.6532,-79.3832
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.fountain.fm:443,43.6532,-79.3832
relay.fountain.fm,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
relay.lanavault.space:443,60.1699,24.9384
nostr.carroarmato0.be,50.914,3.21378
nexus.libernet.app:443,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
blossom.gnostr.cloud,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
relay.earthly.city,34.1749,-118.54
nostr.hifish.org,47.4244,8.57658
offchain.pub:443,39.1585,-94.5728
relay.bullishbounty.com:443,43.6532,-79.3832
strfry.openhoofd.nl:443,51.5717,3.70417
cs-relay.nostrdev.com:443,50.4754,12.3683
strfry.ymir.cloud,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
nostr2.girino.org,43.6532,-79.3832
relay.sigit.io:443,50.4754,12.3683
relay.getsafebox.app,43.6532,-79.3832
antiprimal.net,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
inbox.scuba323.com,40.8218,-74.45
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
nostr.tac.lol,47.4748,-122.273
nostr.davenov.com,50.1109,8.68213
relay.trotters.cc:443,43.6532,-79.3832
nostr.plantroon.com:443,50.1013,8.62643
relay.nostreon.com,60.1699,24.9384
nostr.easycryptosend.it,43.6532,-79.3832
nostr-01.yakihonne.com:443,1.32123,103.695
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.purpura.cloud,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
nostr.mifen.me,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
nostr.2b9t.xyz:443,34.0549,-118.243
relay.wavlake.com:443,41.2619,-95.8608
relay.wisp.talk:443,49.4543,11.0746
relay-dev.satlantis.io:443,39.0438,-77.4874
relay.satlantis.io,39.0438,-77.4874
relay.staging.plebeian.market,51.5072,-0.127586
relay.openfarmtools.org,60.1699,24.9384
relay.nostrhub.fr,48.1045,11.6004
nostr-relay.xbytez.io,50.6924,3.20113
relay.binaryrobot.com:443,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
relay.illuminodes.com,43.6532,-79.3832
relay.liberbitworld.org,43.6532,-79.3832
relay.olas.app:443,60.1699,24.9384
no.str.cr,8.96171,-83.5246
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
wot.rejecttheframe.xyz,43.6532,-79.3832
relay.nostriot.com:443,41.5695,-83.9786
nostr.plantroon.com,50.1013,8.62643
nostr-01.uid.ovh,50.9871,2.12554
relay.openresist.com:443,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
nostr.myshosholoza.co.za:443,52.3676,4.90414
nostr.pbfs.io:443,50.4754,12.3683
21milionidinostr.duckdns.org,41.8967,12.4822
nostr.4rs.nl,49.0291,8.35696
relay.lanavault.space,60.1699,24.9384
relay.mostr.pub,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.agentry.com,42.8864,-78.8784
relay2.angor.io,48.1046,11.6002
slick.mjex.me,39.0418,-77.4744
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.vrtmrz.net:443,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.underorion.se,50.1109,8.68213
relay.mitchelltribe.com,39.0438,-77.4874
relay.qstr.app,51.5072,-0.127586
relay.cyberguy.fyi,52.6907,4.8181
strfry.bonsai.com:443,39.0438,-77.4874
relayone.soundhsa.com:443,39.0997,-94.5786
relay.sigit.io,50.4754,12.3683
relay.npubhaus.com,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.44billion.net,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
articles.layer3.news:443,37.3387,-121.885
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
0x-nostr-relay.fly.dev,37.7648,-122.432
relay.ohstr.com:443,43.6532,-79.3832
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
nostr-relay.cbrx.io,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
purplerelay.com:443,43.6532,-79.3832
nostr-pr02.redscrypt.org,52.3676,4.90414
fanfares.nostr1.com:443,40.7057,-74.0136
kasztanowa.bieda.it,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.typedcypher.com,51.5072,-0.127586
nostr.bond,50.1109,8.68213
nostr.azzamo.net,52.2633,21.0283
nexus.libernet.app,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
schnorr.me,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.chorus.community,48.5333,10.7
blossom.gnostr.cloud:443,43.6532,-79.3832
syb.lol:443,34.0549,-118.243
relay.dyne.org,49.0291,8.35705
btc.klendazu.com,41.2861,1.24993
wot.nostr.place,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
rilo.nostria.app:443,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.mostr.pub:443,43.6532,-79.3832
relay.edufeed.org:443,49.4521,11.0767
nostr.debate.report,50.1109,8.68213
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.artx.market:443,43.6548,-79.3885
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
relay.nostr-check.me,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr.oxtr.dev,50.4754,12.3683
relay.fckstate.net,59.3293,18.0686
relay.vrtmrz.net,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
relay.guggero.org,46.5971,9.59652
relay01.lnfi.network,35.6764,139.65
wot.shaving.kiwi,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
relay.edufeed.org,49.4521,11.0767
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.satmaxt.xyz,43.6532,-79.3832
nostr.hifish.org:443,47.4244,8.57658
relay.cypherflow.ai:443,48.8575,2.35138
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
nostr.na.social:443,43.6532,-79.3832
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.mypathtofire.de,42.8864,-78.8784
public.crostr.com,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.agora.social,50.7383,15.0648
ribo.nostria.app,43.6532,-79.3832
relay.lab.rytswd.com,49.4543,11.0746
relay.ditto.pub:443,43.6532,-79.3832
porchlight.social,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.endfiat.money,59.3327,18.0656
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nearhood.co.uk,51.5134,-0.0890675
relay.degmods.com,50.4754,12.3683
nostr.novacisko.cz,52.2026,20.9397
prl.plus,55.7628,37.5983
bruh.samt.st,43.6532,-79.3832
strfry.openhoofd.nl,51.5717,3.70417
nostr.spicyz.io,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
nip85.nosfabrica.com,39.0997,-94.5786
premium.primal.net,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.scuba323.com,40.8218,-74.45
nostr2.girino.org:443,43.6532,-79.3832
relay.mmwaves.de,48.8575,2.35138
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
strfry.shock.network:443,39.0438,-77.4874
nostr.snowbla.de,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
wot.utxo.one,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
mostro-p2p.tech,50.1109,8.68213
basspistol.org,49.0291,8.35696
ribo.nostria.app:443,43.6532,-79.3832
chorus.mikedilger.com:444,-36.8906,174.794
nostr.oxtr.dev:443,50.4754,12.3683
nostr.nodesmap.com,59.3327,18.0656
offchain.bostr.online,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.wavefunc.live,41.8781,-87.6298
relay.dreamith.to,43.6532,-79.3832
bendernostur.duckdns.org:8443,50.1109,8.68213
relay.nmail.li,50.9871,2.12554
nostr-relay.corb.net,39.6478,-104.988
relay.staging.plebeian.market:443,51.5072,-0.127586
spamspamspamspam.rest,43.6532,-79.3832
relay1.gfcom.info,13.9215,100.538
schnorr.me:443,43.6532,-79.3832
relay.lab.rytswd.com:443,49.4543,11.0746
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384
nostr.relay.hedwig.sh,60.1699,24.9384
relay.veganostr.com:443,60.1699,24.9384
relay.wavefunc.live:443,41.8781,-87.6298
nostr.mikoshi.de,52.52,13.405
syb.lol,34.0549,-118.243
relay1.nostrchat.io,60.1699,24.9384
nostr.wecsats.io:443,43.6532,-79.3832
nostr.chaima.info:443,51.5072,-0.127586
nostr.azzamo.net:443,52.2633,21.0283
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relayone.soundhsa.com,39.0997,-94.5786
x.kojira.io,43.6532,-79.3832
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
nostr.overmind.lol,43.6532,-79.3832
relay.inforsupports.com,43.6532,-79.3832
rele.speyhard.fi,51.5072,-0.127586
memlay.v0l.io,53.3498,-6.26031
relay.nostriches.club,43.6532,-79.3832
nostr.oxtr.dev,50.4754,12.3683
auth.nostr1.com,40.7057,-74.0136
relay.getsafebox.app,43.6532,-79.3832
relay.keykeeper.world,40.7824,-74.0711
relay.nuts.cash,52.3676,4.90414
nostr.planix.org,43.6532,-79.3832
nostr.janx.com,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
relay.primal.net,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
relay.kalcafe.xyz,37.3986,-121.964
relay.gulugulu.moe,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
relay.kilombino.com,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
nostr.myshosholoza.co.za:443,52.3676,4.90414
relay.staging.plebeian.market:443,51.5072,-0.127586
relay-dev.satlantis.io:443,39.0438,-77.4874
ribo.us.nostria.app:443,43.6532,-79.3832
nostr.christiansass.de,48.2804,11.5768
relay.nostriot.com:443,41.5695,-83.9786
nos.lol,50.4754,12.3683
relay.nostr.place:443,43.6532,-79.3832
cache.trustr.ing,43.6548,-79.3885
relay.internationalright-wing.org:443,-22.5022,-48.7114
ribo.us.nostria.app,43.6532,-79.3832
relay.44billion.net,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
relay.mmwaves.de:443,48.8575,2.35138
nostr.robosats.org,64.1476,-21.9392
nostr-01.yakihonne.com,1.32123,103.695
relay.wavefunc.live,41.8781,-87.6298
nostr.azzamo.net:443,52.2633,21.0283
chorus.pjv.me,45.5201,-122.99
relay.laantungir.net,-19.4692,-42.5315
relay.lightning.pub:443,39.0438,-77.4874
nostr.stakey.net,52.3676,4.90414
articles.layer3.news,37.3387,-121.885
relay.wisp.talk,49.4543,11.0746
relay.pyramid.li,47.4093,8.46503
relay.typedcypher.com:443,51.5072,-0.127586
dev.relay.stream,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.openresist.com:443,43.6532,-79.3832
relay.olas.app:443,60.1699,24.9384
basspistol.org,49.0291,8.35696
relay.openfarmtools.org,60.1699,24.9384
relay.lacrypta.ar,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
nostr.wecsats.io,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
relay.bullishbounty.com,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.plebeian.market:443,50.1109,8.68213
nostr.azzamo.net,52.2633,21.0283
relay.mwaters.net,50.9871,2.12554
relay.nostr.blockhenge.com,39.0438,-77.4874
nostr-01.yakihonne.com:443,1.32123,103.695
rilo.nostria.app,43.6532,-79.3832
nostr.chaima.info:443,51.5072,-0.127586
nostr.hekster.org,37.3986,-121.964
nostrcity-club.fly.dev,37.7648,-122.432
nostr.vulpem.com,49.4543,11.0746
relay-dev.gulugulu.moe:443,43.6532,-79.3832
weboftrust.libretechsystems.xyz,55.4724,9.87335
nostr-relay.corb.net:443,39.6478,-104.988
wheat.happytavern.co,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.bitmacro.cloud,43.6532,-79.3832
dev.relay.edufeed.org,49.4521,11.0767
myvoiceourstory.org,37.3598,-121.981
relay.stickeroo.is-cool.dev,37.3387,-121.885
relay.agorist.space,52.3734,4.89406
freelay.sovbit.host,60.1699,24.9384
nostr-dev.wellorder.net,45.5201,-122.99
nostr.middling.mydns.jp,35.8099,140.12
cs-relay.nostrdev.com,50.4754,12.3683
x.kojira.io:443,43.6532,-79.3832
nostrelay.circum.space:443,52.6907,4.8181
nostr.janx.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
espelho.girino.org,43.6532,-79.3832
hol.is,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nostr.yutakobayashi.com,43.6532,-79.3832
strfry.ymir.cloud,43.6532,-79.3832
nostr.data.haus:443,50.4754,12.3683
relay.fundstr.me,42.3601,-71.0589
dev-relay.nostreon.com,60.1699,24.9384
conduitl2.fly.dev,37.7648,-122.432
relayrs.notoshi.win,43.6532,-79.3832
relay.piazza.today,45.5019,-73.5674
relay.mostro.network,40.8302,-74.1299
communities.nos.social,40.8302,-74.1299
relay.solife.me,43.6532,-79.3832
nostr.carroarmato0.be,50.914,3.21378
relay.nostr.place,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
relay.favillakey.io,-27.4705,153.026
relay.artx.market:443,43.6548,-79.3885
relay.klabo.world,47.2343,-119.853
nostr.spicyz.io:443,43.6532,-79.3832
relay.nostreon.com,60.1699,24.9384
nostr-relay.cbrx.io,43.6532,-79.3832
nostr.stakey.net,52.3676,4.90414
relay.agorist.space:443,52.3734,4.89406
nostr.rtvslawenia.com,49.4543,11.0746
relay.novospes.com,43.6532,-79.3832
relay.nostrhub.fr,48.1045,11.6004
yabu.me,35.6092,139.73
relay.islandbitcoin.com,12.8498,77.6545
nostr.wecsats.io,43.6532,-79.3832
nostr.tac.lol:443,47.4748,-122.273
relay.arx-ccn.com,50.4754,12.3683
nostrride.io,37.3986,-121.964
r.0kb.io:443,32.789,-96.7989
herbstmeister.com,34.0549,-118.243
relay.artx.market,43.6548,-79.3885
vault.iris.to,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
temp.iris.to,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002
nostr-dev.wellorder.net,45.5201,-122.99
x.kojira.io:443,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
nostr.islandarea.net,35.4669,-97.6473
cs-relay.nostrdev.com:443,50.4754,12.3683
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr.overmind.lol:443,43.6532,-79.3832
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr-check.me,43.6532,-79.3832
relay.otrta.me,51.5072,-0.127586
nostr.rtvslawenia.com:443,49.4543,11.0746
nexus.libernet.app:443,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
relay.fountain.fm,43.6532,-79.3832
relay.mostr.pub,43.6532,-79.3832
relay2.fiatdenier.com,50.1013,8.62643
nostrcheck.me,43.6532,-79.3832
relay.nostrmap.net:443,60.1699,24.9384
relay.getvia.xyz,60.1699,24.9384
relay.getsafebox.app:443,43.6532,-79.3832
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.layer.systems,49.0291,8.35695
nostr-relay.nextblockvending.com,47.2343,-119.853
wot.codingarena.top,50.4754,12.3683
relay.sincensura.org,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
relay.satsmarkt.club,52.6907,4.8181
relay.openresist.com,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
nostr.bond,50.1109,8.68213
cs-relay.nostrdev.com,50.4754,12.3683
nostr.4rs.nl,49.0291,8.35696
relayone.soundhsa.com,39.0997,-94.5786
nostr.sathoarder.com,48.5734,7.75211
relay.fiatdenier.com,43.7787,-79.5393
strfry.shock.network,39.0438,-77.4874
nostr.purpura.cloud,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384
nostr.tagomago.me,42.3601,-71.0589
relay-dev.gulugulu.moe:443,43.6532,-79.3832
kotukonostr.onrender.com,37.7775,-122.397
relay.staging.plebeian.market,51.5072,-0.127586
buzz.cashu.space,50.1109,8.68213
relay.mmwaves.de,48.8575,2.35138
relay.trotters.cc:443,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
relay.nostriot.com,41.5695,-83.9786
relayone.geektank.ai,39.0997,-94.5786
relay.libernet.app,43.6532,-79.3832
relay.loveisbitcoin.com,43.6532,-79.3832
bendernostur.duckdns.org:8443,50.1109,8.68213
nostr-relay.corb.net:443,39.6478,-104.988
bcast.girino.org,43.6532,-79.3832
blossom.gnostr.cloud:443,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
nostrelay.circum.space,52.2245,8.826
relay.wavlake.com:443,41.2619,-95.8608
vault.iris.to,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714
relay.paulstephenborile.com:443,49.4543,11.0746
relay.wisp.talk:443,49.4543,11.0746
nostrrelay.taylorperron.com,45.5029,-73.5723
wisp.solife.me,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
relay.btcforplebs.com,43.6532,-79.3832
relay.decentralia.fr,48.122,11.589
porchlight.social,43.6532,-79.3832
nostr.computingcache.com:443,34.0356,-118.442
nostr.thalheim.io:443,60.1699,24.9384
nostr.thalheim.io,60.1699,24.9384
relay-rpi.edufeed.org,49.4521,11.0767
nostr.l484.com,30.2672,-97.7431
relay.wavlake.com,41.2619,-95.8608
no.str.cr:443,8.96171,-83.5246
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
cdn.satellite.earth,40.8302,-74.1299
nostr2.girino.org:443,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
nostrbtc.com,43.6532,-79.3832
chat-relay.zap-work.com,43.6532,-79.3832
syb.lol,34.0549,-118.243
social.amanah.eblessing.co,48.1046,11.6002
public.crostr.com:443,43.6532,-79.3832
kasztanowa.bieda.it,43.6532,-79.3832
soloco.nl,43.6532,-79.3832
testr.nymble.world,40.8054,-74.0241
nostr.tac.lol:443,47.4748,-122.273
relay.beginningend.com,35.2227,-97.4786
nostr.ac,38.958,-77.3592
relay.layer.systems:443,49.0291,8.35695
relay2.angor.io:443,48.1046,11.6002
nostr.easycryptosend.it,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr.relay.hedwig.sh,60.1699,24.9384
relay.kalcafe.xyz,37.3986,-121.964
relay.sigit.io:443,50.4754,12.3683
relay.islandbitcoin.com,12.8498,77.6545
schnorr.me:443,43.6532,-79.3832
relay.bullishbounty.com:443,43.6532,-79.3832
nostrride.io,37.3986,-121.964
relay.froth.zone,60.1699,24.9384
nostr.snowbla.de:443,50.4754,12.3683
nostr.oxtr.dev:443,50.4754,12.3683
relay.nostar.org,43.6532,-79.3832
nostr-relay.zimage.com,34.0549,-118.243
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.minibolt.info:443,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
nostr.hekster.org:443,37.3986,-121.964
ribo.nostria.app:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
nostr.davenov.com,50.1109,8.68213
nostr-02.uid.ovh,50.9871,2.12554
nostr.yutakobayashi.com,43.6532,-79.3832
relay.cosmicbolt.net:443,37.3986,-121.964
relay.vrtmrz.net,43.6532,-79.3832
relay.fckstate.net,59.3293,18.0686
bridge.tagomago.me,42.3601,-71.0589
nostr.pbfs.io,50.4754,12.3683
relay.nearhood.co.uk,51.5134,-0.0890675
chat-relay.zap-work.com:443,43.6532,-79.3832
offchain.pub,39.1585,-94.5728
relay.guggero.org,46.5971,9.59652
relay.solife.me,43.6532,-79.3832
relay.mitchelltribe.com,39.0438,-77.4874
nostr.debate.report,50.1109,8.68213
purplerelay.com,43.6532,-79.3832
relay.endfiat.money:443,59.3327,18.0656
relay.nostrian-conquest.com,41.223,-111.974
nostr.bitcoiner.social:443,40.7608,-111.891
nostr.unkn0wn.world,46.8499,9.53287
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.mom:443,50.4754,12.3683
nostr.islandarea.net:443,35.4669,-97.6473
relay1.nostrchat.io,60.1699,24.9384
nostr.stakey.net:443,52.3676,4.90414
relay.sovereignresonance.org,48.9006,2.25929
nostr.dlcdevkit.com:443,40.0992,-83.1141
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
relay.dreamith.to,43.6532,-79.3832
relay.binaryrobot.com:443,43.6532,-79.3832
nostr.whitenode45.ddns.net,40.55,-74.4758
relay-rpi.edufeed.org:443,49.4521,11.0767
vault.iris.to:443,43.6532,-79.3832
nostr.wild-vibes.ts.net,48.8566,2.35222
prl.plus,55.7628,37.5983
nostr.notribe.net:443,40.8302,-74.1299
node.kommonzenze.de,49.4521,11.0767
nexus.libernet.app,43.6532,-79.3832
nostr.88mph.life,52.1941,-2.21905
strfry.bonsai.com:443,39.0438,-77.4874
nostr.twinkle.lol,51.902,7.6657
nostr.21crypto.ch,47.5356,8.73209
relay.nostr.com,50.1109,8.68213
relay.mccormick.cx,52.3563,4.95714
0x-nostr-relay.fly.dev,37.7648,-122.432
strfry.bonsai.com,39.0438,-77.4874
relay.degmods.com,50.4754,12.3683
relay.cypherflow.ai,48.8575,2.35138
nostr.carroarmato0.be:443,50.914,3.21378
bruh.samt.st,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
budabit.nostr1.com,40.7057,-74.0136
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.nostrops.com,32.71,-96.6745
relay.kilombino.com,43.6532,-79.3832
relay.lightning.pub:443,39.0438,-77.4874
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.flashapp.me,43.6548,-79.3885
relay.mostr.pub:443,43.6532,-79.3832
nostr.mas-family.eu,59.3327,18.0656
nostr-pub.wellorder.net,45.5201,-122.99
nostr-02.yakihonne.com,1.32123,103.695
relay.gulugulu.moe,43.6532,-79.3832
staging.yabu.me,35.6092,139.73
adre.su,59.9311,30.3609
relay.artx.market,43.6548,-79.3885
relay.lanacoin-eternity.com:443,40.8302,-74.1299
relay.plebeian.market,50.1109,8.68213
nostr.vulpem.com,49.4543,11.0746
relay.pyramid.li,47.4093,8.46503
relay.binaryrobot.com,43.6532,-79.3832
relay.nostrcheck.me,43.6532,-79.3832
relay.indiesats.com,59.2839,11.1096
ribo.nostria.app,43.6532,-79.3832
relay.nmail.li,50.9871,2.12554
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
relay.ordoplay.com,51.5072,-0.127586
public.crostr.com,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
inbox.scuba323.com,40.8218,-74.45
relay-dev.satlantis.io,39.0438,-77.4874
relay.nostx.io,43.6532,-79.3832
relay.chorus.community:443,48.5333,10.7
bread.nostrsms.com,40.8218,-74.45
reraw.pbla2fish.cc,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.directsponsor.net,42.8864,-78.8784
relay.scuba323.com,40.8218,-74.45
relay.minibolt.info,43.6532,-79.3832
ribo.eu.nostria.app:443,43.6532,-79.3832
relay.chatbett.de,40.7128,-74.006
myvoiceourstory.org,37.3598,-121.981
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
relay.cyberguy.fyi,52.6907,4.8181
relay.satlantis.io,39.0438,-77.4874
relay.endfiat.money,59.3327,18.0656
relay.edufeed.org:443,49.4521,11.0767
testnet.samt.st,43.6532,-79.3832
relay.illuminodes.com,43.6532,-79.3832
relay.mypathtofire.de,42.8864,-78.8784
relay.qstr.app,51.5072,-0.127586
nostr.red5d.dev,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
nostr.data.haus,50.4754,12.3683
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683
relay.typedcypher.com:443,51.5072,-0.127586
slick.mjex.me,39.0418,-77.4744
nostr.mikoshi.de,51.2829,6.7827
relayone.geektank.ai:443,39.0997,-94.5786
relay.wisp.talk,49.4543,11.0746
x.kojira.io,43.6532,-79.3832
relay.0xchat.com,43.6532,-79.3832
relay.sigit.io,50.4754,12.3683
nostr.liberty.fans,36.8767,-89.5879
relay.manneken.brussels,49.4543,11.0746
nostr.n7ekb.net,47.4941,-122.294
nostr.dlcdevkit.com,40.0992,-83.1141
relay01.lnfi.network,35.6764,139.65
relay.staging.commonshub.brussels,49.4543,11.0746
fanfares.nostr1.com:443,40.7057,-74.0136
relay.libernet.app:443,43.6532,-79.3832
relay2.angor.io,48.1046,11.6002
nostr.2b9t.xyz,34.0549,-118.243
nos.lol:443,50.4754,12.3683
relay.fountain.fm:443,43.6532,-79.3832
strfry.shock.network:443,39.0438,-77.4874
relay.sincensura.org,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832
relayone.soundhsa.com:443,39.0997,-94.5786
spamspamspamspam.rest,43.6532,-79.3832
no.str.cr,8.96171,-83.5246
nostrelay.circum.space:443,52.2245,8.826
relayrs.notoshi.win:443,43.6532,-79.3832
relay.littlebitstudios.com,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384
relay.veganostr.com:443,60.1699,24.9384
ec2.f7z.io,60.1699,24.9384
nostr-relay.corb.net,39.6478,-104.988
offchain.bostr.online,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
relay.nostrian-conquest.com:443,41.223,-111.974
top.testrelay.top,43.6532,-79.3832
wheat.happytavern.co,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
dev.relay.stream,43.6532,-79.3832
relay.aidatanorge.no,43.6532,-79.3832
relay.vrtmrz.net:443,43.6532,-79.3832
blossom.gnostr.cloud,43.6532,-79.3832
nip85.nosfabrica.com,39.0997,-94.5786
portal-relay.pareto.space,49.4521,11.0767
relay.wellorder.net,45.5201,-122.99
relay.sharegap.net,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
freelay.sovbit.host,60.1699,24.9384
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
fanfares.nostr1.com,40.7057,-74.0136
relay.nostr.place:443,43.6532,-79.3832
relay.kaleidoswap.com,50.8476,4.35717
relay.dyne.org,49.0291,8.35705
relay.ohstr.com,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
herbstmeister.com,34.0549,-118.243
relay.snotr.nl:49999,51.9758,4.31389
dm-test-strfry-generic.samt.st,43.6532,-79.3832
nostr-01.uid.ovh,50.9871,2.12554
nostr-verified.wellorder.net,45.5201,-122.99
relay.cosmicbolt.net,37.3986,-121.964
nostr.iskarion.ddns.net,43.3076,-2.95421
nostr-relay.xbytez.io,50.6924,3.20113
nostr.spaceshell.xyz,43.6532,-79.3832
relay.gulugulu.moe:443,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
relay.typedcypher.com,51.5072,-0.127586
nostrcity-club.fly.dev:443,37.7648,-122.432
relay.chorus.community,48.5333,10.7
nostr.myshosholoza.co.za,52.3676,4.90414
articles.layer3.news:443,37.3387,-121.885
cache.trustr.ing,43.6548,-79.3885
relay.agilesolutionlabs.com,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
relay.earthly.city,34.1749,-118.54
relay.nostu.be,40.4167,-3.70329
relay.underorion.se,50.1109,8.68213
nostr.notribe.net,40.8302,-74.1299
relay.agorist.space,52.3734,4.89406
nostr.mom,50.4754,12.3683
schnorr.me,43.6532,-79.3832
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.bornheimer.app,51.5072,-0.127586
chorus.bonsai.com,39.0438,-77.4874
nostr.2b9t.xyz:443,34.0549,-118.243
relay.ohstr.com:443,43.6532,-79.3832
relay.nostr-check.me,43.6532,-79.3832
purplerelay.com:443,43.6532,-79.3832
nostr.bitcoiner.social,40.7608,-111.891
nostr.novacisko.cz,52.2026,20.9397
nostr.nodesmap.com,59.3327,18.0656
relay2.veganostr.com,60.1699,24.9384
relay.mrmave.work,43.6532,-79.3832
nostr.girino.org:443,43.6532,-79.3832
mostro-p2p.tech,50.1109,8.68213
rilo.nostria.app:443,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
testnet-relay.samt.st:443,40.8302,-74.1299
relay.hivetalk.org,40.8302,-74.1299
nostr.sathoarder.com:443,48.5734,7.75211
relay.bitmacro.cloud,43.6532,-79.3832
offchain.pub:443,39.1585,-94.5728
bucket.coracle.social,37.7775,-122.397
relay-dev.gulugulu.moe,43.6532,-79.3832
nostr.snowbla.de,50.4754,12.3683
bitcoiner.social,40.7608,-111.891
relay.nostr.net,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.dreamith.to:443,43.6532,-79.3832
relay.wavefunc.live:443,41.8781,-87.6298
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
bitchat.nostr1.com,40.7057,-74.0136
syb.lol:443,34.0549,-118.243
vm-1734.lnvps.cloud,53.3498,-6.26031
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
relay.paulstephenborile.com,49.4543,11.0746

1 Relay URL Latitude Longitude
bitchat.nostr1.com 40.7057 -74.0136
relay.fundstr.me 42.3601 -71.0589
nostr.2b9t.xyz 34.0549 -118.243
armada.sharegap.net 43.6532 -79.3832
nostr.chaima.info 51.5072 -0.127586
nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
ribo.eu.nostria.app:443 43.6532 -79.3832
relay.lightning.pub 39.0438 -77.4874
relay.nostu.be 40.4167 -3.70329
nostr.whitenode45.ddns.net 40.55 -74.4758
nostr.carroarmato0.be:443 50.914 3.21378
cdn.satellite.earth 40.8302 -74.1299
relay2.veganostr.com 60.1699 24.9384
relay.layer.systems:443 49.0291 8.35695
relay0.gfcom.info 13.7653 100.647
relay.mmwaves.de:443 48.8575 2.35138
offchain.pub 39.1585 -94.5728
bcast.girino.org 43.6532 -79.3832
staging.yabu.me 35.6092 139.73
nostr.overpay.com 29.7449 -95.5343
bridge.tagomago.me 42.3601 -71.0589
nostr-01.yakihonne.com 1.32123 103.695
strfry.bonsai.com 39.0438 -77.4874
relay.sharegap.net 43.6532 -79.3832
nostr.islandarea.net 35.4669 -97.6473
dm-test-strfry-generic.samt.st 43.6532 -79.3832
treuzkas.branruz.com 48.8575 2.35138
relay-rpi.edufeed.org:443 49.4521 11.0767
vault.iris.to:443 43.6532 -79.3832
node.kommonzenze.de 49.4521 11.0767
nostr.thalheim.io:443 60.1699 24.9384
soloco.nl 43.6532 -79.3832
strfry.shock.network 39.0438 -77.4874
nostr-relay.zimage.com 34.0549 -118.243
public.crostr.com:443 43.6532 -79.3832
nostr.sathoarder.com:443 48.5734 7.75211
relay.angor.io 48.1046 11.6002
relay.wellorder.net 45.5201 -122.99
relay.mwaters.net 50.9871 2.12554
relay.staging.commonshub.brussels 49.4543 11.0746
nostr-verified.wellorder.net 45.5201 -122.99
nostr-pub.wellorder.net 45.5201 -122.99
nostr-2.21crypto.ch 47.5356 8.73209
relay.kaleidoswap.com 50.8476 4.35717
relay.libernet.app:443 43.6532 -79.3832
relay.homeinhk.xyz 35.694 139.754
relay.manneken.brussels 49.4543 11.0746
nostr.spicyz.io:443 43.6532 -79.3832
relay.lanacoin-eternity.com:443 40.8302 -74.1299
ribo.us.nostria.app:443 43.6532 -79.3832
relay.loveisbitcoin.com 43.6532 -79.3832
relay.angor.io:443 48.1046 11.6002
relay02.lnfi.network 35.6764 139.65
relay.cosmicbolt.net:443 37.3986 -121.964
nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
nrs-01.darkcloudarcade.com 39.0997 -94.5786
relay.endfiat.money:443 59.3327 18.0656
relay.paulstephenborile.com 49.4543 11.0746
rele.speyhard.fi 51.5072 -0.127586
relay.froth.zone 60.1699 24.9384
relay.nostr.blockhenge.com 39.0438 -77.4874
nrl.ceskar.xyz 50.5145 16.0119
rilo.nostria.app 43.6532 -79.3832
nostr.overmind.lol:443 43.6532 -79.3832
nostr.snowbla.de:443 50.4754 12.3683
nostrrelay.taylorperron.com 45.5029 -73.5723
chorus.pjv.me 45.5201 -122.99
relay.nostr.place 43.6532 -79.3832
bucket.coracle.social 37.7775 -122.397
nostr.girino.org:443 43.6532 -79.3832
relay.aarpia.com 37.3986 -121.964
nostr.thalheim.io 60.1699 24.9384
ec2.f7z.io 60.1699 24.9384
relay.trotters.cc 43.6532 -79.3832
relay.mccormick.cx:443 52.3563 4.95714
relay.momostr.pink 43.6532 -79.3832
relay.nostr.net 43.6532 -79.3832
conduitl2.fly.dev 37.7648 -122.432
chat-relay.zap-work.com 43.6532 -79.3832
relay.ditto.pub 43.6532 -79.3832
relay.veganostr.com 60.1699 24.9384
relay.minibolt.info:443 43.6532 -79.3832
adre.su 59.9311 30.3609
bitcoinostr.duckdns.org 41.1976 1.11167
nostr.computingcache.com:443 34.0356 -118.442
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
nostr.hekster.org:443 37.3986 -121.964
nostr.88mph.life 52.1941 -2.21905
wot.dergigi.com 64.1476 -21.9392
nostr.planix.org 43.6532 -79.3832
relay.satsmarkt.club 52.6907 4.8181
nostrcity-club.fly.dev:443 37.7648 -122.432
aeon.libretechsystems.xyz 55.486 9.86577
testnet.samt.st 43.6532 -79.3832
nostr.data.haus 50.4754 12.3683
wot.sudocarlos.com 43.6532 -79.3832
relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
shu01.shugur.net 21.4902 39.2246
relay.gulugulu.moe:443 43.6532 -79.3832
relay2.angor.io:443 48.1046 11.6002
relay.libernet.app 43.6532 -79.3832
directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
wot.nostr.party 36.1659 -86.7844
relay.zone667.com 60.1699 24.9384
nostr.wild-vibes.ts.net 48.8566 2.35222
relay.nostr.com 50.1109 8.68213
nostr.iskarion.ddns.net 43.3076 -2.95421
relay-dev.satlantis.io 39.0438 -77.4874
relay.sovereignresonance.org 48.9006 2.25929
relay.nostrian-conquest.com 41.223 -111.974
relay.aidatanorge.no 43.6532 -79.3832
strfry.apps3.slidestr.net 40.4167 -3.70329
relay.klabo.world 47.2343 -119.853
nostr.data.haus:443 50.4754 12.3683
testr.nymble.world 40.8054 -74.0241
relay.inforsupports.com 43.6532 -79.3832
relay.nostrmap.net:443 60.1699 24.9384
nostr.stakey.net:443 52.3676 4.90414
dev-relay.nostreon.com 60.1699 24.9384
nostr.islandarea.net:443 35.4669 -97.6473
nostr.rtvslawenia.com 49.4543 11.0746
relay.bowlafterbowl.com 32.9483 -96.7299
nostr.quali.chat:443 60.1699 24.9384
relay.plebeian.market 50.1109 8.68213
relay-rpi.edufeed.org 49.4521 11.0767
r.0kb.io 32.789 -96.7989
nostr.notribe.net:443 40.8302 -74.1299
relay.getsafebox.app:443 43.6532 -79.3832
nostr.dlcdevkit.com:443 40.0992 -83.1141
nostrelites.org 34.9582 -81.9907
nostr.hoppe-relay.it.com 42.8864 -78.8784
nostr.thebiglake.org 32.71 -96.6745
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
relay.nostriot.com 41.5695 -83.9786
nostr.christiansass.de 51.7634 7.8887
relay.btcforplebs.com 43.6532 -79.3832
nostr.tagomago.me 42.3601 -71.0589
relayone.geektank.ai 39.0997 -94.5786
relay.dreamith.to:443 43.6532 -79.3832
nostr.liberty.fans 36.8767 -89.5879
wot.makenomistakes.ca 43.7064 -79.3986
relay.goodmorningbitcoin.com 43.6532 -79.3832
relay.layer.systems 49.0291 8.35695
relay.paulstephenborile.com:443 49.4543 11.0746
relay.ohstr.com 43.6532 -79.3832
nostr-relay.xbytez.io:443 50.6924 3.20113
nostr.ac 38.958 -77.3592
ribo.us.nostria.app 43.6532 -79.3832
nostr.21crypto.ch 47.5356 8.73209
relay.chorus.community:443 48.5333 10.7
relay.cypherflow.ai 48.8575 2.35138
relay.agorist.space:443 52.3734 4.89406
relay.nostrian-conquest.com:443 41.223 -111.974
relay.keykeeper.world 40.7824 -74.0711
relay.getvia.xyz 60.1699 24.9384
relay.nuts.cash 52.3676 4.90414
kotukonostr.onrender.com 37.7775 -122.397
relay.minibolt.info 43.6532 -79.3832
relay.dwadziesciajeden.pl 52.2297 21.0122
relay.fountain.fm:443 43.6532 -79.3832
relay.fountain.fm 43.6532 -79.3832
nostr-02.uid.ovh 50.9871 2.12554
relay.lanavault.space:443 60.1699 24.9384
nostr.carroarmato0.be 50.914 3.21378
nexus.libernet.app:443 43.6532 -79.3832
relay.artio.inf.unibe.ch 46.9501 7.43678
blossom.gnostr.cloud 43.6532 -79.3832
relay.binaryrobot.com 43.6532 -79.3832
relay.earthly.city 34.1749 -118.54
nostr.hifish.org 47.4244 8.57658
offchain.pub:443 39.1585 -94.5728
relay.bullishbounty.com:443 43.6532 -79.3832
strfry.openhoofd.nl:443 51.5717 3.70417
cs-relay.nostrdev.com:443 50.4754 12.3683
strfry.ymir.cloud 43.6532 -79.3832
nostrbtc.com 43.6532 -79.3832
relay.directsponsor.net 42.8864 -78.8784
nostr2.girino.org 43.6532 -79.3832
relay.sigit.io:443 50.4754 12.3683
relay.getsafebox.app 43.6532 -79.3832
antiprimal.net 43.6532 -79.3832
nostr.sathoarder.com 48.5734 7.75211
inbox.scuba323.com 40.8218 -74.45
nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
nostr.tac.lol 47.4748 -122.273
nostr.davenov.com 50.1109 8.68213
relay.trotters.cc:443 43.6532 -79.3832
nostr.plantroon.com:443 50.1013 8.62643
relay.nostreon.com 60.1699 24.9384
nostr.easycryptosend.it 43.6532 -79.3832
nostr-01.yakihonne.com:443 1.32123 103.695
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr.purpura.cloud 43.6532 -79.3832
insta-relay.apps3.slidestr.net 40.4167 -3.70329
nostr.mifen.me 43.6532 -79.3832
testnet-relay.samt.st:443 40.8302 -74.1299
nostr.2b9t.xyz:443 34.0549 -118.243
relay.wavlake.com:443 41.2619 -95.8608
relay.wisp.talk:443 49.4543 11.0746
relay-dev.satlantis.io:443 39.0438 -77.4874
relay.satlantis.io 39.0438 -77.4874
relay.staging.plebeian.market 51.5072 -0.127586
relay.openfarmtools.org 60.1699 24.9384
relay.nostrhub.fr 48.1045 11.6004
nostr-relay.xbytez.io 50.6924 3.20113
relay.binaryrobot.com:443 43.6532 -79.3832
relay.samt.st 40.8302 -74.1299
relay.illuminodes.com 43.6532 -79.3832
relay.liberbitworld.org 43.6532 -79.3832
relay.olas.app:443 60.1699 24.9384
no.str.cr 8.96171 -83.5246
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
wot.rejecttheframe.xyz 43.6532 -79.3832
relay.nostriot.com:443 41.5695 -83.9786
nostr.plantroon.com 50.1013 8.62643
nostr-01.uid.ovh 50.9871 2.12554
relay.openresist.com:443 43.6532 -79.3832
nostr.overmind.lol 43.6532 -79.3832
relay.internationalright-wing.org -22.5022 -48.7114
nostr.myshosholoza.co.za:443 52.3676 4.90414
nostr.pbfs.io:443 50.4754 12.3683
21milionidinostr.duckdns.org 41.8967 12.4822
nostr.4rs.nl 49.0291 8.35696
relay.lanavault.space 60.1699 24.9384
relay.mostr.pub 43.6532 -79.3832
relay.nostar.org 43.6532 -79.3832
nostr.mom 50.4754 12.3683
relay.decentralia.fr 48.122 11.589
relay.agentry.com 42.8864 -78.8784
relay2.angor.io 48.1046 11.6002
slick.mjex.me 39.0418 -77.4744
relay-us.zombi.cloudrodion.com 40.7862 -74.0743
relay.vrtmrz.net:443 43.6532 -79.3832
relay.beginningend.com 35.2227 -97.4786
chat-relay.zap-work.com:443 43.6532 -79.3832
relay.underorion.se 50.1109 8.68213
relay.mitchelltribe.com 39.0438 -77.4874
relay.qstr.app 51.5072 -0.127586
relay.cyberguy.fyi 52.6907 4.8181
strfry.bonsai.com:443 39.0438 -77.4874
relayone.soundhsa.com:443 39.0997 -94.5786
relay.sigit.io 50.4754 12.3683
relay.npubhaus.com 43.6532 -79.3832
relayrs.notoshi.win 43.6532 -79.3832
relay.mitchelltribe.com:443 39.0438 -77.4874
relay.44billion.net 43.6532 -79.3832
reraw.pbla2fish.cc 43.6532 -79.3832
articles.layer3.news:443 37.3387 -121.885
nostr.sovereignservices.xyz 43.6532 -79.3832
relay.nostx.io 43.6532 -79.3832
nostr-relay.amethyst.name 39.0067 -77.4291
0x-nostr-relay.fly.dev 37.7648 -122.432
relay.ohstr.com:443 43.6532 -79.3832
00f2e774.relay.dev.thunderegg.us 39.0438 -77.4874
nostr-relay.cbrx.io 43.6532 -79.3832
relay.wavlake.com 41.2619 -95.8608
purplerelay.com:443 43.6532 -79.3832
nostr-pr02.redscrypt.org 52.3676 4.90414
fanfares.nostr1.com:443 40.7057 -74.0136
kasztanowa.bieda.it 43.6532 -79.3832
relay.flashapp.me 43.6548 -79.3885
relay.typedcypher.com 51.5072 -0.127586
nostr.bond 50.1109 8.68213
nostr.azzamo.net 52.2633 21.0283
nexus.libernet.app 43.6532 -79.3832
relay.cosmicbolt.net 37.3986 -121.964
schnorr.me 43.6532 -79.3832
relay.mostro.network:443 40.8302 -74.1299
relay-arg.zombi.cloudrodion.com 1.35208 103.82
relay.chorus.community 48.5333 10.7
blossom.gnostr.cloud:443 43.6532 -79.3832
syb.lol:443 34.0549 -118.243
relay.dyne.org 49.0291 8.35705
btc.klendazu.com 41.2861 1.24993
wot.nostr.place 43.6532 -79.3832
relay.openresist.com 43.6532 -79.3832
rilo.nostria.app:443 43.6532 -79.3832
no.str.cr:443 8.96171 -83.5246
relay.mostr.pub:443 43.6532 -79.3832
relay.edufeed.org:443 49.4521 11.0767
nostr.debate.report 50.1109 8.68213
relay.satmaxt.xyz:443 43.6532 -79.3832
relay.artx.market:443 43.6548 -79.3885
relay-dev.gulugulu.moe 43.6532 -79.3832
relay.novospes.com 43.6532 -79.3832
relay.nostr-check.me 43.6532 -79.3832
nostr.computingcache.com 34.0356 -118.442
nostr.oxtr.dev 50.4754 12.3683
relay.fckstate.net 59.3293 18.0686
relay.vrtmrz.net 43.6532 -79.3832
relay.bornheimer.app 51.5072 -0.127586
relay.guggero.org 46.5971 9.59652
relay01.lnfi.network 35.6764 139.65
wot.shaving.kiwi 43.6532 -79.3832
nostr.twinkle.lol 51.902 7.6657
relay.edufeed.org 49.4521 11.0767
relay.lanacoin-eternity.com 40.8302 -74.1299
relay.satmaxt.xyz 43.6532 -79.3832
nostr.hifish.org:443 47.4244 8.57658
relay.cypherflow.ai:443 48.8575 2.35138
2 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
nostr.na.social:443 43.6532 -79.3832
nostr.rtvslawenia.com:443 49.4543 11.0746
relay.mypathtofire.de 42.8864 -78.8784
public.crostr.com 43.6532 -79.3832
relay.olas.app 60.1699 24.9384
relay.agora.social 50.7383 15.0648
ribo.nostria.app 43.6532 -79.3832
relay.lab.rytswd.com 49.4543 11.0746
relay.ditto.pub:443 43.6532 -79.3832
porchlight.social 43.6532 -79.3832
nostr.notribe.net 40.8302 -74.1299
relay.endfiat.money 59.3327 18.0656
nostr.myshosholoza.co.za 52.3676 4.90414
relay.nearhood.co.uk 51.5134 -0.0890675
relay.degmods.com 50.4754 12.3683
nostr.novacisko.cz 52.2026 20.9397
prl.plus 55.7628 37.5983
bruh.samt.st 43.6532 -79.3832
strfry.openhoofd.nl 51.5717 3.70417
nostr.spicyz.io 43.6532 -79.3832
nostr.na.social 43.6532 -79.3832
nip85.nosfabrica.com 39.0997 -94.5786
premium.primal.net 43.6532 -79.3832
fanfares.nostr1.com 40.7057 -74.0136
relay.scuba323.com 40.8218 -74.45
nostr2.girino.org:443 43.6532 -79.3832
relay.mmwaves.de 48.8575 2.35138
3 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
4 strfry.shock.network:443 nostr.overmind.lol 39.0438 43.6532 -77.4874 -79.3832
5 nostr.snowbla.de relay.inforsupports.com 50.4754 43.6532 12.3683 -79.3832
6 nostr.spaceshell.xyz rele.speyhard.fi 43.6532 51.5072 -79.3832 -0.127586
7 nostr.quali.chat memlay.v0l.io 60.1699 53.3498 24.9384 -6.26031
8 wot.utxo.one relay.nostriches.club 43.6532 -79.3832
9 relay.mccormick.cx nostr.oxtr.dev 52.3563 50.4754 4.95714 12.3683
10 mostro-p2p.tech auth.nostr1.com 50.1109 40.7057 8.68213 -74.0136
11 basspistol.org relay.getsafebox.app 49.0291 43.6532 8.35696 -79.3832
12 ribo.nostria.app:443 relay.keykeeper.world 43.6532 40.7824 -79.3832 -74.0711
13 chorus.mikedilger.com:444 relay.nuts.cash -36.8906 52.3676 174.794 4.90414
14 nostr.oxtr.dev:443 nostr.planix.org 50.4754 43.6532 12.3683 -79.3832
15 nostr.nodesmap.com nostr.janx.com 59.3327 43.6532 18.0656 -79.3832
16 offchain.bostr.online nostr.tac.lol 43.6532 47.4748 -79.3832 -122.273
purplerelay.com 43.6532 -79.3832
relayrs.notoshi.win:443 43.6532 -79.3832
relay.wavefunc.live 41.8781 -87.6298
relay.dreamith.to 43.6532 -79.3832
bendernostur.duckdns.org:8443 50.1109 8.68213
relay.nmail.li 50.9871 2.12554
nostr-relay.corb.net 39.6478 -104.988
relay.staging.plebeian.market:443 51.5072 -0.127586
spamspamspamspam.rest 43.6532 -79.3832
relay1.gfcom.info 13.9215 100.538
schnorr.me:443 43.6532 -79.3832
relay.lab.rytswd.com:443 49.4543 11.0746
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay.nostrmap.net 60.1699 24.9384
nostr.relay.hedwig.sh 60.1699 24.9384
relay.veganostr.com:443 60.1699 24.9384
relay.wavefunc.live:443 41.8781 -87.6298
nostr.mikoshi.de 52.52 13.405
syb.lol 34.0549 -118.243
relay1.nostrchat.io 60.1699 24.9384
nostr.wecsats.io:443 43.6532 -79.3832
nostr.chaima.info:443 51.5072 -0.127586
nostr.azzamo.net:443 52.2633 21.0283
relay-can.zombi.cloudrodion.com 43.6532 -79.3832
nostr.unkn0wn.world 46.8499 9.53287
relayone.soundhsa.com 39.0997 -94.5786
x.kojira.io 43.6532 -79.3832
dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
nostrelay.circum.space 52.6907 4.8181
17 relay.primal.net 43.6532 -79.3832
18 nostr.girino.org nostr.myshosholoza.co.za:443 43.6532 52.3676 -79.3832 4.90414
19 nostr.pbfs.io relay.staging.plebeian.market:443 50.4754 51.5072 12.3683 -0.127586
20 relay.kalcafe.xyz relay-dev.satlantis.io:443 37.3986 39.0438 -121.964 -77.4874
21 relay.gulugulu.moe ribo.us.nostria.app:443 43.6532 -79.3832
22 top.testrelay.top nostr.christiansass.de 43.6532 48.2804 -79.3832 11.5768
23 relay.kilombino.com relay.nostriot.com:443 43.6532 41.5695 -79.3832 -83.9786
nos.lol:443 50.4754 12.3683
24 nos.lol 50.4754 12.3683
25 relay.nostr.place:443 ribo.us.nostria.app 43.6532 -79.3832
26 cache.trustr.ing relay.44billion.net 43.6548 43.6532 -79.3885 -79.3832
27 relay.internationalright-wing.org:443 nostr.wecsats.io:443 -22.5022 43.6532 -48.7114 -79.3832
28 relay.mmwaves.de:443 48.8575 2.35138
29 nostr.robosats.org 64.1476 -21.9392
30 nostr-01.yakihonne.com 1.32123 103.695
31 relay.wavefunc.live 41.8781 -87.6298
32 nostr.azzamo.net:443 52.2633 21.0283
33 chorus.pjv.me 45.5201 -122.99
34 relay.laantungir.net -19.4692 -42.5315
35 relay.lightning.pub:443 testnet-relay.samt.st 39.0438 40.8302 -77.4874 -74.1299
36 nostr.stakey.net relay.openresist.com:443 52.3676 43.6532 4.90414 -79.3832
37 articles.layer3.news relay.olas.app:443 37.3387 60.1699 -121.885 24.9384
38 relay.wisp.talk basspistol.org 49.4543 49.0291 11.0746 8.35696
39 relay.pyramid.li relay.openfarmtools.org 47.4093 60.1699 8.46503 24.9384
40 relay.typedcypher.com:443 relay.lacrypta.ar 51.5072 43.6532 -0.127586 -79.3832
41 dev.relay.stream relay5.bitransfer.org 43.6532 -79.3832
42 nostr.girino.org 43.6532 -79.3832
43 temp.iris.to 43.6532 -79.3832
44 nostr.wecsats.io 43.6532 -79.3832
45 nostr.chaima.info 51.5072 -0.127586
46 relay.bullishbounty.com 43.6532 -79.3832
47 nostr.mom:443 nostr.azzamo.net 50.4754 52.2633 12.3683 21.0283
48 relay.plebeian.market:443 relay.mwaters.net 50.1109 50.9871 8.68213 2.12554
49 relay.nostr.blockhenge.com 39.0438 -77.4874
50 nostr-01.yakihonne.com:443 1.32123 103.695
51 rilo.nostria.app 43.6532 -79.3832
52 nostr.chaima.info:443 51.5072 -0.127586
53 nostr.hekster.org 37.3986 -121.964
54 nostrcity-club.fly.dev 37.7648 -122.432
55 nostr.vulpem.com strfry.ymir.cloud 49.4543 43.6532 11.0746 -79.3832
56 relay-dev.gulugulu.moe:443 nostr.data.haus:443 43.6532 50.4754 -79.3832 12.3683
57 weboftrust.libretechsystems.xyz relay.fundstr.me 55.4724 42.3601 9.87335 -71.0589
58 nostr-relay.corb.net:443 dev-relay.nostreon.com 39.6478 60.1699 -104.988 24.9384
59 wheat.happytavern.co conduitl2.fly.dev 43.6532 37.7648 -79.3832 -122.432
60 relay.mappingbitcoin.com relayrs.notoshi.win 43.6532 -79.3832
61 testnet-relay.samt.st relay.piazza.today 40.8302 45.5019 -74.1299 -73.5674
relay.bitmacro.cloud 43.6532 -79.3832
dev.relay.edufeed.org 49.4521 11.0767
myvoiceourstory.org 37.3598 -121.981
relay.stickeroo.is-cool.dev 37.3387 -121.885
relay.agorist.space 52.3734 4.89406
freelay.sovbit.host 60.1699 24.9384
nostr-dev.wellorder.net 45.5201 -122.99
nostr.middling.mydns.jp 35.8099 140.12
cs-relay.nostrdev.com 50.4754 12.3683
x.kojira.io:443 43.6532 -79.3832
nostrelay.circum.space:443 52.6907 4.8181
nostr.janx.com 43.6532 -79.3832
relay.mrmave.work 43.6532 -79.3832
espelho.girino.org 43.6532 -79.3832
hol.is 43.6532 -79.3832
ribo.eu.nostria.app 43.6532 -79.3832
nostr.yutakobayashi.com 43.6532 -79.3832
62 relay.mostro.network 40.8302 -74.1299
63 communities.nos.social nostr.carroarmato0.be 40.8302 50.914 -74.1299 3.21378
64 relay.solife.me relay.nostr.place 43.6532 -79.3832
65 relay.agentry.com 42.8864 -78.8784
66 relay.favillakey.io -27.4705 153.026
67 relay.artx.market:443 43.6548 -79.3885
68 relay.klabo.world 47.2343 -119.853
69 nostr.spicyz.io:443 43.6532 -79.3832
70 relay.nostreon.com 60.1699 24.9384
71 nostr-relay.cbrx.io 43.6532 -79.3832
72 nostr.stakey.net 52.3676 4.90414
73 relay.agorist.space:443 52.3734 4.89406
74 nostr.rtvslawenia.com 49.4543 11.0746
75 relay.novospes.com 43.6532 -79.3832
76 relay.nostrhub.fr 48.1045 11.6004
77 yabu.me 35.6092 139.73
78 relay.islandbitcoin.com nostr-dev.wellorder.net 12.8498 45.5201 77.6545 -122.99
79 nostr.wecsats.io x.kojira.io:443 43.6532 -79.3832
80 nostr.tac.lol:443 relay.angor.io:443 47.4748 48.1046 -122.273 11.6002
81 relay.arx-ccn.com nostr.islandarea.net 50.4754 35.4669 12.3683 -97.6473
82 nostrride.io cs-relay.nostrdev.com:443 37.3986 50.4754 -121.964 12.3683
83 r.0kb.io:443 nostr-rs-relay.dev.fedibtc.com 32.789 39.0438 -96.7989 -77.4874
84 herbstmeister.com nostr.overmind.lol:443 34.0549 43.6532 -118.243 -79.3832
85 relay.artx.market relay.lanacoin-eternity.com 43.6548 40.8302 -79.3885 -74.1299
86 vault.iris.to nostr-check.me 43.6532 -79.3832
87 relay.ru.ac.th relay.otrta.me 13.7607 51.5072 100.627 -0.127586
88 temp.iris.to nostr.rtvslawenia.com:443 43.6532 49.4543 -79.3832 11.0746
89 social.amanah.eblessing.co nexus.libernet.app:443 48.1046 43.6532 11.6002 -79.3832
90 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
91 relay.fountain.fm 43.6532 -79.3832
92 relay.mostr.pub 43.6532 -79.3832
93 relay2.fiatdenier.com 50.1013 8.62643
94 nostrcheck.me 43.6532 -79.3832
95 relay.nostrmap.net:443 60.1699 24.9384
96 relay.getvia.xyz 60.1699 24.9384
97 relay.getsafebox.app:443 43.6532 -79.3832
98 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
99 relay.layer.systems 49.0291 8.35695
100 nostr-relay.nextblockvending.com 47.2343 -119.853
101 wot.codingarena.top nostr.pbfs.io:443 50.4754 12.3683
102 relay.sincensura.org relay.satsmarkt.club 43.6532 52.6907 -79.3832 4.8181
103 relay.openresist.com 43.6532 -79.3832
104 relay.artio.inf.unibe.ch 46.9501 7.43678
105 nostr.bond 50.1109 8.68213
106 cs-relay.nostrdev.com 50.4754 12.3683
107 nostr.4rs.nl 49.0291 8.35696
108 relayone.soundhsa.com 39.0997 -94.5786
109 nostr.sathoarder.com 48.5734 7.75211
110 relay.fiatdenier.com 43.7787 -79.5393
111 strfry.shock.network 39.0438 -77.4874
112 nostr.purpura.cloud 43.6532 -79.3832
113 relay.zone667.com 60.1699 24.9384
114 nostr.tagomago.me 42.3601 -71.0589
115 relay-dev.gulugulu.moe:443 43.6532 -79.3832
116 kotukonostr.onrender.com 37.7775 -122.397
117 relay.staging.plebeian.market 51.5072 -0.127586
118 buzz.cashu.space 50.1109 8.68213
119 relay.mmwaves.de 48.8575 2.35138
120 relay.trotters.cc:443 43.6532 -79.3832
121 articles.layer3.news 37.3387 -121.885
122 relay.nostriot.com 41.5695 -83.9786
123 relayone.geektank.ai 39.0997 -94.5786
124 relay.libernet.app 43.6532 -79.3832
125 relay.loveisbitcoin.com 43.6532 -79.3832
126 bendernostur.duckdns.org:8443 50.1109 8.68213
127 nostr-relay.corb.net:443 39.6478 -104.988
128 bcast.girino.org 43.6532 -79.3832
129 blossom.gnostr.cloud:443 43.6532 -79.3832
130 nostr.spicyz.io 43.6532 -79.3832
131 nostrelay.circum.space 52.2245 8.826
132 relay.wavlake.com:443 41.2619 -95.8608
133 vault.iris.to 43.6532 -79.3832
134 relay.mccormick.cx:443 52.3563 4.95714
135 relay.paulstephenborile.com:443 49.4543 11.0746
136 relay.wisp.talk:443 49.4543 11.0746
137 nostrrelay.taylorperron.com 45.5029 -73.5723
138 wisp.solife.me 43.6532 -79.3832
139 relay.internationalright-wing.org -22.5022 -48.7114
140 relay.btcforplebs.com 43.6532 -79.3832
141 relay.decentralia.fr 48.122 11.589
142 porchlight.social 43.6532 -79.3832
143 nostr.computingcache.com:443 34.0356 -118.442
144 nostr.thalheim.io:443 60.1699 24.9384
145 nostr.thalheim.io 60.1699 24.9384
146 relay-rpi.edufeed.org 49.4521 11.0767
147 nostr.l484.com 30.2672 -97.7431
148 relay.wavlake.com 41.2619 -95.8608
149 no.str.cr:443 8.96171 -83.5246
150 nostr.sovereignservices.xyz 43.6532 -79.3832
151 relay.trotters.cc 43.6532 -79.3832
152 cdn.satellite.earth 40.8302 -74.1299
153 nostr2.girino.org:443 43.6532 -79.3832
154 relay.satmaxt.xyz 43.6532 -79.3832
155 relay.lightning.pub 39.0438 -77.4874
156 nostrbtc.com 43.6532 -79.3832
157 chat-relay.zap-work.com 43.6532 -79.3832
158 syb.lol 34.0549 -118.243
159 social.amanah.eblessing.co 48.1046 11.6002
160 public.crostr.com:443 43.6532 -79.3832
161 kasztanowa.bieda.it 43.6532 -79.3832
162 soloco.nl 43.6532 -79.3832
163 testr.nymble.world 40.8054 -74.0241
164 nostr.tac.lol:443 47.4748 -122.273
165 relay.beginningend.com 35.2227 -97.4786
166 nostr.ac 38.958 -77.3592
167 relay.layer.systems:443 49.0291 8.35695
168 relay2.angor.io:443 48.1046 11.6002
169 nostr.easycryptosend.it 43.6532 -79.3832
170 ribo.eu.nostria.app 43.6532 -79.3832
171 nostr.computingcache.com 34.0356 -118.442
172 nostr.relay.hedwig.sh 60.1699 24.9384
173 relay.kalcafe.xyz 37.3986 -121.964
174 relay.sigit.io:443 50.4754 12.3683
175 relay.islandbitcoin.com 12.8498 77.6545
176 schnorr.me:443 43.6532 -79.3832
177 relay.bullishbounty.com:443 43.6532 -79.3832
178 nostrride.io 37.3986 -121.964
179 relay.froth.zone 60.1699 24.9384
180 nostr.snowbla.de:443 50.4754 12.3683
181 nostr.oxtr.dev:443 50.4754 12.3683
182 relay.nostar.org 43.6532 -79.3832
183 nostr-relay.zimage.com 34.0549 -118.243
184 relay.internationalright-wing.org:443 -22.5022 -48.7114
185 relay.minibolt.info:443 43.6532 -79.3832
186 relay.mitchelltribe.com:443 39.0438 -77.4874
187 nostr.hekster.org:443 37.3986 -121.964
188 ribo.nostria.app:443 43.6532 -79.3832
189 relay.homeinhk.xyz 35.694 139.754
190 nostr.davenov.com 50.1109 8.68213
191 nostr-02.uid.ovh 50.9871 2.12554
192 nostr.yutakobayashi.com 43.6532 -79.3832
193 relay.cosmicbolt.net:443 37.3986 -121.964
194 relay.vrtmrz.net 43.6532 -79.3832
195 relay.fckstate.net 59.3293 18.0686
196 bridge.tagomago.me 42.3601 -71.0589
197 nostr.pbfs.io 50.4754 12.3683
198 relay.nearhood.co.uk 51.5134 -0.0890675
199 chat-relay.zap-work.com:443 43.6532 -79.3832
200 offchain.pub 39.1585 -94.5728
201 relay.guggero.org 46.5971 9.59652
202 relay.solife.me 43.6532 -79.3832
203 relay.mitchelltribe.com 39.0438 -77.4874
204 nostr.debate.report 50.1109 8.68213
205 purplerelay.com 43.6532 -79.3832
206 relay.endfiat.money:443 59.3327 18.0656
207 relay.nostrian-conquest.com 41.223 -111.974
208 nostr.bitcoiner.social:443 40.7608 -111.891
209 nostr.unkn0wn.world 46.8499 9.53287
210 nostr.hoppe-relay.it.com 42.8864 -78.8784
211 nostr.mom:443 50.4754 12.3683
212 nostr.islandarea.net:443 35.4669 -97.6473
213 relay1.nostrchat.io 60.1699 24.9384
214 nostr.stakey.net:443 52.3676 4.90414
215 relay.sovereignresonance.org 48.9006 2.25929
216 nostr.dlcdevkit.com:443 40.0992 -83.1141
217 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
218 relay.mappingbitcoin.com 43.6532 -79.3832
219 relay.dreamith.to 43.6532 -79.3832
220 relay.binaryrobot.com:443 43.6532 -79.3832
221 nostr.whitenode45.ddns.net 40.55 -74.4758
222 relay-rpi.edufeed.org:443 49.4521 11.0767
223 vault.iris.to:443 43.6532 -79.3832
224 nostr.wild-vibes.ts.net 48.8566 2.35222
225 prl.plus 55.7628 37.5983
226 nostr.notribe.net:443 40.8302 -74.1299
227 node.kommonzenze.de 49.4521 11.0767
228 nexus.libernet.app 43.6532 -79.3832
229 nostr.88mph.life 52.1941 -2.21905
230 strfry.bonsai.com:443 39.0438 -77.4874
231 nostr.twinkle.lol 51.902 7.6657
232 nostr.21crypto.ch 47.5356 8.73209
233 relay.nostr.com 50.1109 8.68213
234 relay.mccormick.cx 52.3563 4.95714
235 0x-nostr-relay.fly.dev 37.7648 -122.432
236 strfry.bonsai.com 39.0438 -77.4874
237 relay.degmods.com 50.4754 12.3683
238 relay.cypherflow.ai 48.8575 2.35138
239 nostr.carroarmato0.be:443 50.914 3.21378
240 bruh.samt.st 43.6532 -79.3832
241 strfry.apps3.slidestr.net 40.4167 -3.70329
242 budabit.nostr1.com 40.7057 -74.0136
243 nrs-01.darkcloudarcade.com 39.0997 -94.5786
244 relay.nostrops.com 32.71 -96.6745
245 relay.kilombino.com 43.6532 -79.3832
246 relay.lightning.pub:443 39.0438 -77.4874
247 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
248 relay.flashapp.me 43.6548 -79.3885
249 relay.mostr.pub:443 43.6532 -79.3832
250 nostr.mas-family.eu 59.3327 18.0656
251 nostr-pub.wellorder.net 45.5201 -122.99
252 nostr-02.yakihonne.com 1.32123 103.695
253 relay.gulugulu.moe 43.6532 -79.3832
254 staging.yabu.me 35.6092 139.73
255 adre.su 59.9311 30.3609
256 relay.artx.market 43.6548 -79.3885
257 relay.lanacoin-eternity.com:443 40.8302 -74.1299
258 relay.plebeian.market 50.1109 8.68213
259 nostr.vulpem.com 49.4543 11.0746
260 relay.pyramid.li 47.4093 8.46503
261 relay.binaryrobot.com 43.6532 -79.3832
262 relay.nostrcheck.me 43.6532 -79.3832
263 relay.indiesats.com 59.2839 11.1096
264 ribo.nostria.app 43.6532 -79.3832
265 relay.nmail.li 50.9871 2.12554
266 tribune-panel-growing-noon.trycloudflare.com 43.6532 -79.3832
267 relay.ordoplay.com 51.5072 -0.127586
268 public.crostr.com 43.6532 -79.3832
269 nostr2.girino.org 43.6532 -79.3832
270 inbox.scuba323.com 40.8218 -74.45
271 relay-dev.satlantis.io 39.0438 -77.4874
272 relay.nostx.io 43.6532 -79.3832
273 relay.chorus.community:443 48.5333 10.7
274 bread.nostrsms.com 40.8218 -74.45
275 reraw.pbla2fish.cc 43.6532 -79.3832
276 relay.olas.app 60.1699 24.9384
277 relay.directsponsor.net 42.8864 -78.8784
278 relay.scuba323.com 40.8218 -74.45
279 relay.minibolt.info 43.6532 -79.3832
280 ribo.eu.nostria.app:443 43.6532 -79.3832
281 relay.chatbett.de 40.7128 -74.006
282 myvoiceourstory.org 37.3598 -121.981
283 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
284 relay.cyberguy.fyi 52.6907 4.8181
285 relay.satlantis.io 39.0438 -77.4874
286 relay.endfiat.money 59.3327 18.0656
287 relay.edufeed.org:443 49.4521 11.0767
288 testnet.samt.st 43.6532 -79.3832
289 relay.illuminodes.com 43.6532 -79.3832
290 relay.mypathtofire.de 42.8864 -78.8784
291 relay.qstr.app 51.5072 -0.127586
292 nostr.red5d.dev 43.6532 -79.3832
293 relay.edufeed.org 49.4521 11.0767
294 nostr.data.haus 50.4754 12.3683
295 relay.satmaxt.xyz:443 43.6532 -79.3832
296 relay.arx-ccn.com 50.4754 12.3683
297 relay.typedcypher.com:443 51.5072 -0.127586
298 slick.mjex.me 39.0418 -77.4744
299 nostr.mikoshi.de 51.2829 6.7827
300 relayone.geektank.ai:443 39.0997 -94.5786
301 relay.wisp.talk 49.4543 11.0746
302 x.kojira.io 43.6532 -79.3832
303 relay.0xchat.com 43.6532 -79.3832
304 relay.sigit.io 50.4754 12.3683
305 nostr.liberty.fans 36.8767 -89.5879
306 relay.manneken.brussels 49.4543 11.0746
307 nostr.n7ekb.net 47.4941 -122.294
308 nostr.dlcdevkit.com 40.0992 -83.1141
309 relay01.lnfi.network 35.6764 139.65
310 relay.staging.commonshub.brussels 49.4543 11.0746
311 fanfares.nostr1.com:443 40.7057 -74.0136
312 relay.libernet.app:443 43.6532 -79.3832
313 relay2.angor.io 48.1046 11.6002
314 nostr.2b9t.xyz 34.0549 -118.243
315 nos.lol:443 50.4754 12.3683
316 relay.fountain.fm:443 43.6532 -79.3832
317 strfry.shock.network:443 39.0438 -77.4874
318 relay.sincensura.org 43.6532 -79.3832
319 relay.ditto.pub:443 43.6532 -79.3832
320 relayone.soundhsa.com:443 39.0997 -94.5786
321 spamspamspamspam.rest 43.6532 -79.3832
322 no.str.cr 8.96171 -83.5246
323 nostrelay.circum.space:443 52.2245 8.826
324 relayrs.notoshi.win:443 43.6532 -79.3832
325 relay.littlebitstudios.com 43.6532 -79.3832
326 relay.nostrmap.net 60.1699 24.9384
327 relay.veganostr.com:443 60.1699 24.9384
328 ec2.f7z.io 60.1699 24.9384
329 nostr-relay.corb.net 39.6478 -104.988
330 offchain.bostr.online 43.6532 -79.3832
331 relay.aarpia.com 37.3986 -121.964
332 relay.nostrian-conquest.com:443 41.223 -111.974
333 top.testrelay.top 43.6532 -79.3832
334 wheat.happytavern.co 43.6532 -79.3832
335 relay.samt.st 40.8302 -74.1299
336 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
337 relay.angor.io 48.1046 11.6002
338 dev.relay.stream 43.6532 -79.3832
339 relay.aidatanorge.no 43.6532 -79.3832
340 relay.vrtmrz.net:443 43.6532 -79.3832
341 blossom.gnostr.cloud 43.6532 -79.3832
342 nip85.nosfabrica.com 39.0997 -94.5786
343 portal-relay.pareto.space 49.4521 11.0767
344 relay.wellorder.net 45.5201 -122.99
345 relay.sharegap.net 43.6532 -79.3832
346 relay.ru.ac.th 13.7607 100.627
347 freelay.sovbit.host 60.1699 24.9384
348 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
349 fanfares.nostr1.com 40.7057 -74.0136
350 relay.nostr.place:443 43.6532 -79.3832
351 relay.kaleidoswap.com 50.8476 4.35717
352 relay.dyne.org 49.0291 8.35705
353 relay.ohstr.com 43.6532 -79.3832
354 relay.ditto.pub 43.6532 -79.3832
355 herbstmeister.com 34.0549 -118.243
356 relay.snotr.nl:49999 51.9758 4.31389
357 dm-test-strfry-generic.samt.st 43.6532 -79.3832
358 nostr-01.uid.ovh 50.9871 2.12554
359 nostr-verified.wellorder.net 45.5201 -122.99
360 relay.cosmicbolt.net 37.3986 -121.964
361 nostr.iskarion.ddns.net 43.3076 -2.95421
362 nostr-relay.xbytez.io 50.6924 3.20113
363 nostr.spaceshell.xyz 43.6532 -79.3832
364 relay.gulugulu.moe:443 43.6532 -79.3832
365 insta-relay.apps3.slidestr.net 40.4167 -3.70329
366 relay.typedcypher.com 51.5072 -0.127586
367 nostrcity-club.fly.dev:443 37.7648 -122.432
368 relay.chorus.community 48.5333 10.7
369 nostr.myshosholoza.co.za 52.3676 4.90414
370 articles.layer3.news:443 37.3387 -121.885
371 cache.trustr.ing 43.6548 -79.3885
372 relay.agilesolutionlabs.com 43.6532 -79.3832
373 relay.veganostr.com 60.1699 24.9384
374 relay.earthly.city 34.1749 -118.54
375 relay.nostu.be 40.4167 -3.70329
376 relay.underorion.se 50.1109 8.68213
377 nostr.notribe.net 40.8302 -74.1299
378 relay.agorist.space 52.3734 4.89406
379 nostr.mom 50.4754 12.3683
380 schnorr.me 43.6532 -79.3832
381 nostr-relay.xbytez.io:443 50.6924 3.20113
382 relay.bornheimer.app 51.5072 -0.127586
383 chorus.bonsai.com 39.0438 -77.4874
384 nostr.2b9t.xyz:443 34.0549 -118.243
385 relay.ohstr.com:443 43.6532 -79.3832
386 relay.nostr-check.me 43.6532 -79.3832
387 purplerelay.com:443 43.6532 -79.3832
388 nostr.bitcoiner.social 40.7608 -111.891
389 nostr.novacisko.cz 52.2026 20.9397
390 nostr.nodesmap.com 59.3327 18.0656
391 relay2.veganostr.com 60.1699 24.9384
392 relay.mrmave.work 43.6532 -79.3832
393 nostr.girino.org:443 43.6532 -79.3832
394 mostro-p2p.tech 50.1109 8.68213
395 rilo.nostria.app:443 43.6532 -79.3832
396 relay.mostro.network:443 40.8302 -74.1299
397 testnet-relay.samt.st:443 40.8302 -74.1299
398 relay.hivetalk.org 40.8302 -74.1299
399 nostr.sathoarder.com:443 48.5734 7.75211
400 relay.bitmacro.cloud 43.6532 -79.3832
401 offchain.pub:443 39.1585 -94.5728
402 bucket.coracle.social 37.7775 -122.397
403 relay-dev.gulugulu.moe 43.6532 -79.3832
404 nostr.snowbla.de 50.4754 12.3683
405 bitcoiner.social 40.7608 -111.891
406 relay.nostr.net 43.6532 -79.3832
407 nostr.thebiglake.org 32.71 -96.6745
408 relay-testnet.k8s.layer3.news 37.3387 -121.885
409 relay.dreamith.to:443 43.6532 -79.3832
410 relay.wavefunc.live:443 41.8781 -87.6298
411 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
412 bitchat.nostr1.com 40.7057 -74.0136
413 syb.lol:443 34.0549 -118.243
414 vm-1734.lnvps.cloud 53.3498 -6.26031
415 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
416 treuzkas.branruz.com 48.8575 2.35138
417 relay.paulstephenborile.com 49.4543 11.0746

View File

@ -48,6 +48,9 @@ class BitchatApplication : Application() {
// Initialize theme preference
ThemePreferenceManager.init(this)
// Initialize chat UI mode (matrix transcript vs bubbles)
com.bitchat.android.ui.theme.ChatUiModeManager.init(this)
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }

View File

@ -52,6 +52,7 @@ fun AnnotatedClickableText(
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
style: TextStyle = LocalTextStyle.current,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick)
@ -91,6 +92,9 @@ fun AnnotatedClickableText(
softWrap = softWrap,
overflow = overflow,
style = style,
onTextLayout = { layoutResult = it },
onTextLayout = { result ->
layoutResult = result
onTextLayout?.invoke(result)
},
)
}

View File

@ -0,0 +1,362 @@
package com.bitchat.android.features.voice
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.media.MediaRecorder
import android.util.Log
import java.io.File
import java.nio.ByteOrder
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin
/** Destination selected for one hold gesture. Calls must be safe from a capture thread. */
fun interface LiveVoiceTarget {
fun send(packet: ByteArray)
}
internal data class LiveVoiceCaptureStats(
val queuedPcmFrames: Int,
val encodedFrames: Int,
val dataPackets: Int,
val droppedOversizeFrames: Int,
val outboundPackets: Int,
val deliveredPackets: Int
)
/**
* AudioRecord + MediaCodec capture used only when a live mesh route is available.
*
* Encoded access units are streamed as iOS-compatible burst packets while the same units are
* muxed into an ordinary `.m4a`, which the existing voice-note path sends on release.
*/
internal class LiveVoiceCapture(
private val outputDirectory: File,
private val target: LiveVoiceTarget,
private val burstID: ByteArray = VoiceBurstPacket.makeBurstID(),
/** Debug Mesh Lab uses a deterministic tone so physical tests never capture ambient audio. */
private val syntheticPcm: Boolean = false
) {
companion object {
private const val TAG = "LiveVoiceCapture"
private const val SAMPLE_RATE = 16_000
private const val CHANNEL_COUNT = 1
private const val BIT_RATE = 16_000
private const val SAMPLES_PER_AAC_FRAME = 1_024
private const val MIN_VALID_DURATION_MS = 600L
private const val CODEC_TIMEOUT_US = 10_000L
private const val CODEC_INPUT_DEADLINE_MS = 1_000L
private const val SYNTHETIC_TONE_HZ = 440.0
private const val SYNTHETIC_TONE_AMPLITUDE = 8_000
private const val AAC_FRAME_DURATION_NS = 64_000_000L
private const val OUTBOUND_QUEUE_CAPACITY = 256
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 10_000L
}
private val running = AtomicBoolean(false)
private val amplitude = AtomicInteger(0)
private val queuedPcmFrames = AtomicInteger(0)
private val encodedFrames = AtomicInteger(0)
private val outboundPackets = AtomicInteger(0)
private val deliveredPackets = AtomicInteger(0)
private val outboundQueue = LinkedBlockingQueue<ByteArray>(OUTBOUND_QUEUE_CAPACITY)
private val senderRunning = AtomicBoolean(false)
private val packetizer = VoiceBurstPacketizer(burstID)
private var streamStarted = false
private var startedAtMs = 0L
private var totalSamples = 0L
private var audioRecord: AudioRecord? = null
private var codec: MediaCodec? = null
private var muxer: MediaMuxer? = null
private var muxerTrack = -1
private var muxerStarted = false
private var outputFile: File? = null
private var captureThread: Thread? = null
private var senderThread: Thread? = null
@SuppressLint("MissingPermission")
fun start(): File? {
if (running.get()) return outputFile
return try {
outputDirectory.mkdirs()
val burstHex = VoiceBurstPacket.burstIDHex(burstID)
val file = File(outputDirectory, "voice_$burstHex.m4a")
if (file.exists()) file.delete()
outputFile = file
val format = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC,
SAMPLE_RATE,
CHANNEL_COUNT
).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, SAMPLES_PER_AAC_FRAME * 4)
}
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
codec = encoder
val mediaMuxer = MediaMuxer(file.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
muxer = mediaMuxer
val record = if (syntheticPcm) {
null
} else {
val minBuffer = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
if (minBuffer <= 0) error("AudioRecord buffer unavailable: $minBuffer")
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
max(minBuffer * 4, SAMPLES_PER_AAC_FRAME * 16)
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
it.release()
error("AudioRecord did not initialize")
}
it.startRecording()
if (it.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
it.release()
error("AudioRecord did not start")
}
}
}
audioRecord = record
startedAtMs = System.currentTimeMillis()
senderRunning.set(true)
senderThread = Thread(::senderLoop, "bitchat-ptt-sender").also(Thread::start)
running.set(true)
captureThread = Thread(::captureLoop, "bitchat-ptt-capture").also(Thread::start)
file
} catch (error: Exception) {
Log.w(TAG, "Live capture unavailable; caller will fall back to a voice note: ${error.message}")
releaseResources()
outputFile?.delete()
outputFile = null
null
}
}
fun pollAmplitude(): Int = amplitude.get()
fun stats(): LiveVoiceCaptureStats = LiveVoiceCaptureStats(
queuedPcmFrames = queuedPcmFrames.get(),
encodedFrames = encodedFrames.get(),
dataPackets = packetizer.dataPacketCount,
droppedOversizeFrames = packetizer.droppedFrameCount,
outboundPackets = outboundPackets.get(),
deliveredPackets = deliveredPackets.get()
)
fun stop(canceled: Boolean): File? {
val wasRunning = running.getAndSet(false)
if (wasRunning) {
runCatching { audioRecord?.stop() }
runCatching { captureThread?.join(3_000L) }
}
captureThread = null
val elapsedMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
val durationMs = (encodedFrames.get().toLong() * SAMPLES_PER_AAC_FRAME * 1_000L) / SAMPLE_RATE
val valid = !canceled && elapsedMs >= MIN_VALID_DURATION_MS &&
durationMs >= MIN_VALID_DURATION_MS && encodedFrames.get() > 0 &&
(outputFile?.length() ?: 0L) > 0L
packetizer.flush().forEach(::queueOutbound)
val controlKind = if (valid) {
VoiceBurstPacket.Kind.End(packetizer.dataPacketCount, durationMs.coerceAtMost(0xFFFF_FFFFL))
} else {
VoiceBurstPacket.Kind.Canceled
}
VoiceBurstPacket.create(burstID, packetizer.nextSequence, controlKind)
?.encode()
?.let(::queueOutbound)
senderRunning.set(false)
runCatching { senderThread?.join(OUTBOUND_DRAIN_TIMEOUT_MS) }
if (senderThread?.isAlive == true) {
Log.w(TAG, "Live voice sender did not drain before timeout")
senderThread?.interrupt()
}
senderThread = null
val file = outputFile
outputFile = null
if (!valid) {
file?.delete()
return null
}
return file
}
private fun captureLoop() {
val pcm = ShortArray(SAMPLES_PER_AAC_FRAME)
var nextSyntheticFrameNs = System.nanoTime() + AAC_FRAME_DURATION_NS
try {
while (running.get()) {
val read = if (syntheticPcm) {
val waitNs = nextSyntheticFrameNs - System.nanoTime()
if (waitNs > 0L) {
Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt())
}
nextSyntheticFrameNs += AAC_FRAME_DURATION_NS
val firstSample = totalSamples
pcm.indices.forEach { index ->
val phase = 2.0 * Math.PI * SYNTHETIC_TONE_HZ *
(firstSample + index).toDouble() / SAMPLE_RATE.toDouble()
pcm[index] = (sin(phase) * SYNTHETIC_TONE_AMPLITUDE).roundToInt().toShort()
}
pcm.size
} else {
audioRecord?.read(pcm, 0, pcm.size, AudioRecord.READ_BLOCKING) ?: break
}
if (read <= 0) continue
amplitude.set(pcm.take(read).maxOfOrNull { abs(it.toInt()) } ?: 0)
queuePcm(pcm, read, endOfStream = false)
drainEncoder(endOfStream = false)
}
queuePcm(pcm, 0, endOfStream = true)
drainEncoder(endOfStream = true)
} catch (error: Exception) {
Log.w(TAG, "Live capture stopped after codec/audio failure: ${error.message}")
} finally {
releaseResources()
}
}
private fun queuePcm(samples: ShortArray, count: Int, endOfStream: Boolean) {
val encoder = codec ?: return
val deadlineNs = System.nanoTime() + CODEC_INPUT_DEADLINE_MS * 1_000_000L
while (true) {
val index = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
val input = encoder.getInputBuffer(index)
?: error("AAC encoder returned a null input buffer")
input.clear()
input.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, count)
val presentationUs = (totalSamples * 1_000_000L) / SAMPLE_RATE
totalSamples += count
encoder.queueInputBuffer(
index,
0,
count * 2,
presentationUs,
if (endOfStream) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
)
if (!endOfStream) queuedPcmFrames.incrementAndGet()
return
}
// Pull encoded output before retrying so transient codec backpressure cannot discard
// the already-read 64 ms microphone block.
drainEncoder(endOfStream = false)
if (System.nanoTime() >= deadlineNs) {
error("AAC encoder input remained unavailable")
}
}
}
private fun drainEncoder(endOfStream: Boolean) {
val encoder = codec ?: return
val info = MediaCodec.BufferInfo()
var sawEnd = false
var idlePolls = 0
while (!sawEnd && (!endOfStream || idlePolls < 100)) {
when (val index = encoder.dequeueOutputBuffer(info, if (endOfStream) CODEC_TIMEOUT_US else 0L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> {
idlePolls++
if (!endOfStream) return
}
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (muxerStarted) error("AAC output format changed twice")
muxerTrack = muxer?.addTrack(encoder.outputFormat) ?: -1
muxer?.start()
muxerStarted = true
}
else -> if (index >= 0) {
idlePolls = 0
val output = encoder.getOutputBuffer(index)
if (output != null && info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
output.position(info.offset)
output.limit(info.offset + info.size)
if (muxerStarted && muxerTrack >= 0) {
muxer?.writeSampleData(muxerTrack, output.duplicate(), info)
}
val accessUnit = ByteArray(info.size)
output.get(accessUnit)
emitEncodedFrame(accessUnit)
}
sawEnd = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
encoder.releaseOutputBuffer(index, false)
}
}
}
}
private fun emitEncodedFrame(frame: ByteArray) {
if (frame.isEmpty()) return
if (!streamStarted) {
streamStarted = true
VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)?.encode()?.let(::queueOutbound)
}
encodedFrames.incrementAndGet()
packetizer.add(frame).forEach(::queueOutbound)
// At the target bitrate a packet fits one frame; flushing immediately avoids latency.
packetizer.flush().forEach(::queueOutbound)
}
private fun queueOutbound(packet: ByteArray) {
outboundQueue.put(packet.copyOf())
outboundPackets.incrementAndGet()
}
private fun senderLoop() {
try {
while (senderRunning.get() || outboundQueue.isNotEmpty()) {
val packet = outboundQueue.poll(100L, TimeUnit.MILLISECONDS) ?: continue
target.send(packet)
deliveredPackets.incrementAndGet()
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: Exception) {
Log.w(TAG, "Live voice network sender stopped: ${error.message}")
} finally {
senderRunning.set(false)
}
}
private fun releaseResources() {
runCatching { audioRecord?.stop() }
runCatching { audioRecord?.release() }
audioRecord = null
runCatching { codec?.stop() }
runCatching { codec?.release() }
codec = null
if (muxerStarted) runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
muxerStarted = false
muxerTrack = -1
}
}

View File

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

View File

@ -0,0 +1,22 @@
package com.bitchat.android.features.voice
import android.content.Context
/** One preference gates live PTT sending and playback; finalized voice notes remain available. */
object LiveVoicePreferences {
private const val PREFERENCES = "bitchat_settings"
private const val ENABLED = "ptt.liveVoiceEnabled"
fun isEnabled(context: Context): Boolean =
context.applicationContext
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getBoolean(ENABLED, true)
fun setEnabled(context: Context, enabled: Boolean) {
context.applicationContext
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putBoolean(ENABLED, enabled)
.apply()
}
}

View File

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

View File

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

View File

@ -18,17 +18,38 @@ import java.util.Locale
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
* The resulting file has MIME audio/mp4.
*/
class VoiceRecorder(private val context: Context) {
class VoiceRecorder(
private val context: Context,
private val liveTarget: LiveVoiceTarget? = null
) {
companion object { private const val TAG = "VoiceRecorder" }
private var recorder: MediaRecorder? = null
private var liveCapture: LiveVoiceCapture? = null
private val _amplitude = MutableStateFlow(0)
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
private var outFile: File? = null
val isLive: Boolean
get() = liveCapture != null
fun start(): File? {
stop() // ensure previous session closed
if (liveTarget != null) {
val directory = File(context.filesDir, "voicenotes/outgoing")
val capture = LiveVoiceCapture(directory, liveTarget)
val liveFile = capture.start()
if (liveFile != null) {
liveCapture = capture
outFile = liveFile
return liveFile
}
}
return startClassic()
}
private fun startClassic(): File? {
return try {
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
@ -56,13 +77,19 @@ class VoiceRecorder(private val context: Context) {
fun pollAmplitude(): Int {
return try {
val amp = recorder?.maxAmplitude ?: 0
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
_amplitude.value = amp
amp
} catch (_: Exception) { 0 }
}
fun stop(): File? {
fun stop(canceled: Boolean = false): File? {
liveCapture?.let { capture ->
val file = capture.stop(canceled)
liveCapture = null
outFile = null
return file
}
try {
recorder?.apply {
try { stop() } catch (_: Exception) {}
@ -73,6 +100,10 @@ class VoiceRecorder(private val context: Context) {
val f = outFile
recorder = null
outFile = null
if (canceled) {
f?.delete()
return null
}
return f
}
}

View File

@ -68,8 +68,17 @@ class BluetoothConnectionManager(
}
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) {
packetBroadcaster.onLinkDisconnected(device.address, linkID)
delegate?.onDeviceDisconnected(device, linkID, peerID)
}
override fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
packetBroadcaster.onGattClientWriteComplete(deviceAddress, linkID, status)
}
override fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
packetBroadcaster.onGattServerNotificationComplete(deviceAddress, linkID, status)
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
delegate?.onRSSIUpdated(deviceAddress, rssi)
@ -485,4 +494,6 @@ interface BluetoothConnectionManagerDelegate {
fun onDeviceConnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?)
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) = Unit
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) = Unit
}

View File

@ -95,9 +95,6 @@ class BluetoothGattClientManager(
private var scanWatchdogJob: Job? = null
private var scanDutyCycleJob: Job? = null
// RSSI monitoring state
private var rssiMonitoringJob: Job? = null
// State management
private var isActive = false
@ -133,8 +130,6 @@ class BluetoothGattClientManager(
connectionScope.launch {
applyPowerProfile(powerManager.profile.value)
// Start RSSI monitoring
startRSSIMonitoring()
}
return true
@ -151,7 +146,6 @@ class BluetoothGattClientManager(
if (!isActive) {
// Idempotent stop
stopScanning()
stopRSSIMonitoring()
return
}
@ -167,7 +161,6 @@ class BluetoothGattClientManager(
} catch (_: Exception) { }
stopScanning()
stopRSSIMonitoring()
Log.i(TAG, "GATT client manager stopped")
}
}
@ -185,40 +178,6 @@ class BluetoothGattClientManager(
}
}
/**
* Start periodic RSSI monitoring for all client connections
*/
private fun startRSSIMonitoring() {
rssiMonitoringJob?.cancel()
rssiMonitoringJob = connectionScope.launch {
while (isActive) {
try {
// Request RSSI from all client connections
val connectedDevices = connectionTracker.getConnectedDevices()
connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn ->
try {
deviceConn.gatt?.readRemoteRssi()
} catch (e: Exception) {
Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
}
}
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
} catch (e: Exception) {
Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
}
}
}
}
/**
* Stop RSSI monitoring
*/
private fun stopRSSIMonitoring() {
rssiMonitoringJob?.cancel()
rssiMonitoringJob = null
}
/**
* Start scanning with rate limiting
*/
@ -616,18 +575,17 @@ class BluetoothGattClientManager(
Log.d(TAG, "Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
}
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
val deviceAddress = gatt.device.address
if (status == BluetoothGatt.GATT_SUCCESS) {
// Update the connection tracker with new RSSI value
connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) {
it.copy(rssi = rssi)
}
} else {
Log.d(TAG, "Failed to read RSSI for $deviceAddress, status: $status")
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
delegate?.onGattClientWriteComplete(gatt.device.address, linkID, status)
}
}
}
try {

View File

@ -297,6 +297,14 @@ class BluetoothGattServerManager(
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
delegate?.onGattServerNotificationComplete(
device.address,
serverLinkIDs[device.address],
status
)
}
}
// Proper cleanup sequencing to prevent race conditions

View File

@ -20,6 +20,7 @@ import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.util.*
import kotlin.math.sign
import kotlin.random.Random
@ -111,6 +112,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
private lateinit var gossipSyncManager: GossipSyncManager
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
@ -129,6 +132,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
private var terminated = false
init {
serviceScope.launch {
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
}
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
VerificationService.configure(encryptionService)
setupDelegates()
@ -607,6 +613,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
} catch (_: Exception) { }
}
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleLeave(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleLeave(routed) }
@ -949,6 +958,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
if (payload.isEmpty()) return
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
}
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
try {
val recipientPeerID = request.recipientPeerID
val packet = if (recipientPeerID == null) {
BitchatPacket(
version = 1u,
type = MessageType.VOICE_FRAME.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = request.payload,
ttl = MAX_TTL
)
} else {
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = MAX_TTL
)
}
broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet)))
} catch (e: Exception) {
Log.w(TAG, "Live voice frame send failed: ${e.message}")
}
}
fun prepareFilePrivate(
recipientPeerID: String,
file: com.bitchat.android.model.BitchatFilePacket,

View File

@ -1,10 +1,13 @@
package com.bitchat.android.mesh
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothStatusCodes
import android.os.Build
import android.util.Log
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket
@ -19,8 +22,8 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.actor
import java.util.ArrayDeque
/**
* Handles packet broadcasting to connected devices using actor pattern for serialization
@ -48,7 +51,10 @@ class BluetoothPacketBroadcaster(
companion object {
private const val TAG = "BluetoothPacketBroadcaster"
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
private const val MAX_PENDING_SENDS_PER_LINK = 256
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
private const val SEND_RETRY_DELAY_MS = 15L
private const val MAX_CALLBACK_RETRIES = 3
}
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
@ -119,11 +125,38 @@ class BluetoothPacketBroadcaster(
// Actor scope for the broadcaster
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
private enum class SendDirection { CLIENT_WRITE, SERVER_NOTIFICATION }
private data class SendKey(
val deviceAddress: String,
val linkID: String,
val direction: SendDirection
)
private data class PendingSend(
val data: ByteArray,
val device: BluetoothDevice,
val gatt: BluetoothGatt? = null,
val gattServer: BluetoothGattServer? = null,
val characteristic: BluetoothGattCharacteristic,
var callbackFailures: Int = 0
)
private class LinkSendState {
val pending = ArrayDeque<PendingSend>()
var pendingBytes = 0
var inFlight = false
var retryScheduled = false
}
private val sendLock = Any()
private val sendStates = mutableMapOf<SendKey, LinkSendState>()
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
private val broadcasterActor = broadcasterScope.actor<BroadcastRequest>(
capacity = Channel.UNLIMITED
capacity = 256
) {
for (request in channel) {
val accepted = try {
@ -443,21 +476,16 @@ class BluetoothPacketBroadcaster(
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
return try {
characteristic?.let { char ->
char.value = data
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
connectionTracker.removeSubscribedDevice(device)
connectionTracker.addressPeerMap.remove(device.address)
}
false
}
val server = gattServer ?: return false
val char = characteristic ?: return false
val linkID = connectionTracker.getDeviceConnection(device.address)
?.takeIf { !it.isClient }
?.linkID
?: return false
return enqueueSend(
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
)
}
/**
@ -467,20 +495,160 @@ class BluetoothPacketBroadcaster(
deviceConn: BluetoothConnectionTracker.DeviceConnection,
data: ByteArray
): Boolean {
return try {
deviceConn.characteristic?.let { char ->
char.value = data
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
connectionTracker.cleanupDeviceConnection(deviceConn.device.address)
val gatt = deviceConn.gatt ?: return false
val char = deviceConn.characteristic ?: return false
return enqueueSend(
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
)
}
/**
* Android permits only one outstanding GATT operation per link. Queueing here mirrors the
* readiness-driven iOS transport and prevents later voice frames from overwriting an operation
* that the controller has not completed yet.
*/
private fun enqueueSend(key: SendKey, request: PendingSend): Boolean {
val startNow = synchronized(sendLock) {
val state = sendStates.getOrPut(key, ::LinkSendState)
if (
state.pending.size >= MAX_PENDING_SENDS_PER_LINK ||
state.pendingBytes + request.data.size > MAX_PENDING_BYTES_PER_LINK
) {
Log.w(TAG, "BLE send queue full for ${key.direction}; rejecting ${request.data.size} bytes")
return false
}
state.pending.addLast(request)
state.pendingBytes += request.data.size
if (!state.inFlight && !state.retryScheduled) {
state.inFlight = true
true
} else {
false
}
}
if (startNow) startHead(key)
return true
}
@Suppress("DEPRECATION")
@SuppressLint("MissingPermission", "ObsoleteSdkInt")
private fun startHead(key: SendKey) {
val request = synchronized(sendLock) { sendStates[key]?.pending?.peekFirst() } ?: return
val accepted = try {
when (key.direction) {
SendDirection.CLIENT_WRITE -> {
val gatt = request.gatt
if (gatt == null) {
false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(
request.characteristic,
request.data,
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
) == BluetoothStatusCodes.SUCCESS
} else {
request.characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
request.characteristic.value = request.data
gatt.writeCharacteristic(request.characteristic)
}
}
SendDirection.SERVER_NOTIFICATION -> {
val server = request.gattServer
if (server == null) {
false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
server.notifyCharacteristicChanged(
request.device,
request.characteristic,
false,
request.data
) == BluetoothStatusCodes.SUCCESS
} else {
request.characteristic.value = request.data
server.notifyCharacteristicChanged(request.device, request.characteristic, false)
}
}
}
} catch (error: Exception) {
Log.w(TAG, "BLE ${key.direction} failed to start: ${error.message}")
false
}
if (!accepted) rejectStart(key)
}
private fun rejectStart(key: SendKey): Boolean {
val schedule = synchronized(sendLock) {
val state = sendStates[key] ?: return false
state.inFlight = false
if (state.retryScheduled || state.pending.isEmpty()) false else {
state.retryScheduled = true
true
}
}
if (schedule) {
connectionScope.launch {
delay(SEND_RETRY_DELAY_MS)
val retry = synchronized(sendLock) {
val state = sendStates[key] ?: return@synchronized false
state.retryScheduled = false
if (!state.inFlight && state.pending.isNotEmpty()) {
state.inFlight = true
true
} else false
}
if (retry) startHead(key)
}
}
return false
}
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
completeSend(SendKey(deviceAddress, linkID, SendDirection.CLIENT_WRITE), status)
}
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
if (linkID == null) return
completeSend(SendKey(deviceAddress, linkID, SendDirection.SERVER_NOTIFICATION), status)
}
private fun completeSend(key: SendKey, status: Int) {
var retry = false
val startNext = synchronized(sendLock) {
val state = sendStates[key] ?: return
val head = state.pending.peekFirst() ?: run {
sendStates.remove(key)
return
}
state.inFlight = false
if (status != BluetoothGatt.GATT_SUCCESS && head.callbackFailures < MAX_CALLBACK_RETRIES) {
head.callbackFailures++
retry = true
false
} else {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "BLE ${key.direction} failed with status $status after retries")
}
state.pending.removeFirst()
state.pendingBytes -= head.data.size
if (state.pending.isEmpty()) {
sendStates.remove(key)
false
} else {
state.inFlight = true
true
}
}
}
if (retry) rejectStart(key) else if (startNext) startHead(key)
}
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
synchronized(sendLock) {
sendStates.keys.removeAll { key ->
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
}
}
}
/**
@ -499,6 +667,7 @@ class BluetoothPacketBroadcaster(
* Shutdown the broadcaster actor gracefully
*/
fun shutdown() {
synchronized(sendLock) { sendStates.clear() }
// Close the actor gracefully
broadcasterActor.close()

View File

@ -20,6 +20,7 @@ import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -111,6 +112,8 @@ class MeshCore(
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
private val packetProcessor = PacketProcessor(myPeerID)
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
private val directPeers = ConcurrentHashMap.newKeySet<String>()
val gossipSyncManager: GossipSyncManager =
@ -122,6 +125,9 @@ class MeshCore(
private var isActive = false
init {
scope.launch {
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
}
messageHandler.packetProcessor = packetProcessor
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
setupDelegates()
@ -481,6 +487,9 @@ class MeshCore(
} catch (_: Exception) { }
}
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleLeave(routed: RoutedPacket) {
scope.launch { messageHandler.handleLeave(routed) }
}
@ -611,6 +620,44 @@ class MeshCore(
}
}
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
if (payload.isEmpty()) return
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
}
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
try {
val recipientPeerID = request.recipientPeerID
val packet = if (recipientPeerID == null) {
BitchatPacket(
version = 1u,
type = MessageType.VOICE_FRAME.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = request.payload,
ttl = maxTtl
)
} else {
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = maxTtl
)
}
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
} catch (e: Exception) {
Log.w("MeshCore", "Live voice frame send failed: ${e.message}")
}
}
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -21,6 +21,7 @@ interface MeshService {
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -10,6 +10,8 @@ import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.util.toHexString
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoiceScope
import kotlinx.coroutines.*
import java.util.*
@ -140,7 +142,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
senderPeerID = peerID
)
delegate?.onMessageReceived(message)
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
delegate?.onMessageReceived(message)
}
// Send delivery ACK with generated message ID
sendDeliveryAck(uniqueMsgId, peerID)
@ -149,6 +153,16 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
}
com.bitchat.android.model.NoisePayloadType.VOICE_FRAME -> {
return LiveVoiceManager.getInstance(appContext).handleFrame(
peerID = peerID,
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
scope = LiveVoiceScope.DIRECT_MESSAGE,
payload = noisePayload.data,
timestampMs = packet.timestamp.toLong()
)
}
com.bitchat.android.model.NoisePayloadType.PEER_STATE -> {
val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data)
if (authenticatedState == null) {
@ -411,6 +425,27 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
// Message relay is now handled by centralized PacketRelayManager
}
/** Validate and ingest an ephemeral public push-to-talk frame. */
fun handlePublicVoiceFrame(routed: RoutedPacket): Boolean {
val packet = routed.packet
val peerID = routed.peerID ?: return false
if (peerID == myPeerID) return true
val recipient = packet.recipientID
if (recipient != null && !recipient.contentEquals(delegate?.getBroadcastRecipient())) return false
if (packet.timestamp > Long.MAX_VALUE.toULong()) return false
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
if (ageMs !in -30_000L..30_000L) return false
val peerInfo = delegate?.getPeerInfo(peerID)
if (peerInfo == null || !peerInfo.isVerifiedNickname) return false
return LiveVoiceManager.getInstance(appContext).handleFrame(
peerID = peerID,
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
scope = LiveVoiceScope.PUBLIC_MESH,
payload = packet.payload,
timestampMs = packet.timestamp.toLong()
)
}
/**
* Handle broadcast message with verification enforcement
@ -441,7 +476,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
delegate?.onMessageReceived(message)
}
return
} else if (isFileTransfer) {
Log.w(TAG, "FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)}")
@ -501,7 +538,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
recipientNickname = delegate?.getMyNickname()
)
Log.d(TAG, "📄 Saved incoming file to $savedPath")
delegate?.onMessageReceived(message)
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
delegate?.onMessageReceived(message)
}
return
} else if (isFileTransfer) {
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}")

View File

@ -136,6 +136,7 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
@ -296,6 +297,7 @@ interface PacketProcessorDelegate {
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleRequestSync(routed: RoutedPacket)

View File

@ -62,8 +62,16 @@ class PacketRelayManager(private val myPeerID: String) {
}
// Decrement TTL by 1
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
val networkSize = delegate?.getNetworkSize() ?: 1
val decrementedTtl = (packet.ttl - 1u).toUByte()
val voiceTtl = if (
MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME && networkSize > 6
) minOf(decrementedTtl, 5u.toUByte()) else decrementedTtl
val relayPacket = packet.copy(ttl = voiceTtl)
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
if (MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME) {
delay(Random.nextLong(8L, 26L))
}
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
val route = relayPacket.route

View File

@ -46,7 +46,6 @@ class PowerManager private constructor(context: Context) : LifecycleEventObserve
val scanOnMs: Long,
val scanOffMs: Long,
val continuousScan: Boolean,
val rssiPollIntervalMs: Long,
val maxConnections: Int = 8
)
@ -318,24 +317,22 @@ internal object PowerProfileResolver {
PowerManager.BleSchedule(
scanOnMs = 1_000L,
scanOffMs = 29_000L,
continuousScan = false,
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
continuousScan = false
)
isBackground ->
PowerManager.BleSchedule(
scanOnMs = 1_000L,
scanOffMs = 59_000L,
continuousScan = false,
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
continuousScan = false
)
mode == PowerManager.PowerMode.PERFORMANCE ->
PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L)
PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true)
mode == PowerManager.PowerMode.BALANCED ->
PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L)
PowerManager.BleSchedule(8_000L, 2_000L, false)
mode == PowerManager.PowerMode.POWER_SAVER ->
PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L)
PowerManager.BleSchedule(2_000L, 28_000L, false)
else ->
PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L)
PowerManager.BleSchedule(1_000L, 29_000L, false)
}
val announcementInterval = when {
@ -407,10 +404,4 @@ internal object PowerProfileResolver {
discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L,
discoveryStaleMs = staleMinutes * 60_000L
)
private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) {
PowerManager.BatteryBand.NORMAL -> 60_000L
PowerManager.BatteryBand.LOW -> 120_000L
PowerManager.BatteryBand.CRITICAL -> 300_000L
}
}

View File

@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER,
MessageType.VOICE_FRAME,
MessageType.LEAVE
)) {
return true

View File

@ -167,6 +167,23 @@ class UnifiedMeshService(
}
}
override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
if (recipientPeerID == null) {
when {
isBleEnabled() -> bluetooth.sendVoiceFrame(null, payload)
else -> wifiService()?.sendVoiceFrame(null, payload)
}
return
}
when {
isBleReady(recipientPeerID) -> bluetooth.sendVoiceFrame(recipientPeerID, payload)
isWifiReady(recipientPeerID) -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.sendVoiceFrame(recipientPeerID, payload)
else -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
}
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -21,6 +21,7 @@ enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered
VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u),

View File

@ -242,6 +242,7 @@ class NostrDirectMessageHandler(
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.VOICE_FRAME,
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
}
}

View File

@ -17,7 +17,8 @@ enum class MessageType(val value: UByte) {
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT(0x20u), // Fragmentation for large packets
REQUEST_SYNC(0x21u), // GCS-based sync request
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync
companion object {
fun fromValue(value: UByte): MessageType? {

View File

@ -101,7 +101,16 @@ class MeshForegroundService : Service() {
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
peerAvailabilityNotifier = PeerAvailabilityNotifier(applicationContext)
peerAvailabilityNotifier = PeerAvailabilityNotifier(
context = applicationContext,
scope = scope,
isAppCurrentlyInBackground = {
!ProcessLifecycleOwner.get()
.lifecycle
.currentState
.isAtLeast(Lifecycle.State.STARTED)
}
)
createChannel()
// Ensure mesh service exists in holder (create if needed)

View File

@ -16,6 +16,10 @@ import androidx.core.content.ContextCompat
import androidx.core.content.edit
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal enum class PeerAvailabilityAction {
NONE,
@ -151,9 +155,12 @@ private class AndroidPeerAvailabilityTextProvider(
/**
* Owns the user-visible "bitchatters nearby" notification independently of the UI delegate.
* The first eligible peer starts a fixed aggregation window; arrivals within that window update
* the count without postponing the alert indefinitely.
*/
internal class PeerAvailabilityNotifier(
private val context: Context,
private val scope: CoroutineScope,
private val notificationManager: NotificationManagerCompat =
NotificationManagerCompat.from(context),
private val tracker: PeerAvailabilityTracker = PeerAvailabilityTracker(
@ -161,6 +168,8 @@ internal class PeerAvailabilityNotifier(
),
private val textProvider: PeerAvailabilityTextProvider =
AndroidPeerAvailabilityTextProvider(context),
private val isAppCurrentlyInBackground: () -> Boolean,
private val aggregationWindowMs: Long = AGGREGATION_WINDOW_MS,
private val canPostNotifications: () -> Boolean = {
notificationManager.areNotificationsEnabled() &&
(
@ -175,25 +184,47 @@ internal class PeerAvailabilityNotifier(
companion object {
internal const val CHANNEL_ID = "bitchat_peer_availability_notifications"
internal const val NOTIFICATION_ID = 997
internal const val AGGREGATION_WINDOW_MS = 10_000L
private const val TAG = "PeerAvailability"
}
private var latestPeerCount = 0
private var pendingNotificationJob: Job? = null
init {
require(aggregationWindowMs >= 0) { "aggregationWindowMs must not be negative" }
createNotificationChannel()
}
fun onPeerCountChanged(peerCount: Int, isAppInBackground: Boolean) {
latestPeerCount = peerCount
when (tracker.update(peerCount, isAppInBackground)) {
PeerAvailabilityAction.NONE -> Unit
PeerAvailabilityAction.CLEAR -> clear()
PeerAvailabilityAction.SHOW -> showNotification(peerCount)
PeerAvailabilityAction.SHOW -> scheduleNotification()
}
}
fun clear() {
pendingNotificationJob?.cancel()
pendingNotificationJob = null
notificationManager.cancel(NOTIFICATION_ID)
}
private fun scheduleNotification() {
if (pendingNotificationJob?.isActive == true) return
pendingNotificationJob = scope.launch {
delay(aggregationWindowMs)
pendingNotificationJob = null
val peerCount = latestPeerCount
if (peerCount > 0 && isAppCurrentlyInBackground()) {
showNotification(peerCount)
}
}
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,

View File

@ -196,6 +196,29 @@ object AppStateStore {
}
}
/** Replace a live media row by ID, or append it if the row was not admitted yet. */
fun upsertPublicMessage(msg: BitchatMessage) {
synchronized(this) {
val index = _publicMessages.value.indexOfFirst { it.id == msg.id }
if (index >= 0) {
_publicMessages.value = _publicMessages.value.toMutableList().also { it[index] = msg }
} else {
seenMessageIds.add(msg.id)
seenPublicMessageKeys.add(publicMessageKey(msg))
_publicMessages.value = _publicMessages.value + msg
}
}
}
fun removePublicMessage(messageID: String) {
synchronized(this) {
val existing = _publicMessages.value.firstOrNull { it.id == messageID } ?: return
_publicMessages.value = _publicMessages.value.filterNot { it.id == messageID }
seenMessageIds.remove(messageID)
seenPublicMessageKeys.remove(publicMessageKey(existing))
}
}
fun addPrivateMessage(
peerID: String,
msg: BitchatMessage,
@ -204,6 +227,38 @@ object AppStateStore {
addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true)
}
/** Replace-or-append used by a live voice row as its partial file becomes final media. */
fun upsertPrivateMessage(peerID: String, msg: BitchatMessage, forceRead: Boolean = false) {
synchronized(this) {
if (privateConversationWritesSuspended) return
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val matchingKey = map.keys.firstOrNull {
ContactDirectory.canonicalConversationId(it).equals(canonicalID, ignoreCase = true)
} ?: canonicalID
val messages = map[matchingKey].orEmpty().toMutableList()
val index = messages.indexOfFirst { it.id == msg.id }
if (index >= 0) {
messages[index] = msg
} else {
messages += msg
seenMessageIds.add(msg.id)
}
map[matchingKey] = messages
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
if (forceRead) {
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
}
conversationRepository?.upsertMessage(
conversationID = canonicalID,
aliases = privateConversationAliases(peerID, canonicalID),
displayName = ContactDirectory.resolve(canonicalID).displayName,
message = msg,
isRead = forceRead
)
}
}
/**
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
* notification state. Transport callbacks invoke this from their background worker.

View File

@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material.icons.filled.Warning
@ -382,6 +383,7 @@ fun AboutSheet(
Column {
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState()
Surface(
modifier = Modifier
.fillMaxWidth()
@ -389,30 +391,52 @@ fun AboutSheet(
color = colorScheme.surface,
shape = AboutCardShape
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ThemeChip(
label = stringResource(R.string.about_system),
selected = themePref.isSystem,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
modifier = Modifier.weight(1f)
)
ThemeChip(
label = stringResource(R.string.about_light),
selected = themePref.isLight,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
modifier = Modifier.weight(1f)
)
ThemeChip(
label = stringResource(R.string.about_dark),
selected = themePref.isDark,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
modifier = Modifier.weight(1f)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ThemeChip(
label = stringResource(R.string.about_system),
selected = themePref.isSystem,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
modifier = Modifier.weight(1f)
)
ThemeChip(
label = stringResource(R.string.about_light),
selected = themePref.isLight,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
modifier = Modifier.weight(1f)
)
ThemeChip(
label = stringResource(R.string.about_dark),
selected = themePref.isDark,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ThemeChip(
label = stringResource(R.string.chat_ui_bubbles),
selected = chatUiMode.isBubbles,
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) },
modifier = Modifier.weight(1f)
)
ThemeChip(
label = stringResource(R.string.chat_ui_matrix),
selected = chatUiMode.isMatrix,
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) },
modifier = Modifier.weight(1f)
)
}
}
}
}
@ -482,6 +506,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 +547,23 @@ fun AboutSheet(
color = colorScheme.outlineVariant
)
SettingsToggleRow(
icon = Icons.Filled.Mic,
title = "Live push-to-talk",
subtitle = "Play voice bursts live on the mesh; voice notes are always sent on release",
checked = liveVoiceEnabled,
onCheckedChange = { enabled ->
liveVoiceEnabled = enabled
com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(context, enabled)
}
)
HorizontalDivider(
modifier = Modifier.padding(start = 54.dp),
thickness = 1.dp,
color = colorScheme.outlineVariant
)
// Proof of Work Toggle
SettingsToggleRow(
icon = Icons.Filled.Speed,

View File

@ -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()
}
}
@ -386,6 +401,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
if (accepted) {
messageText = TextFieldValue("")
viewModel.setConversationDraft(selectedPrivatePeer, "")
// Clearing the field in code does not run onMessageTextChange,
// so the popups have to be dismissed here.
viewModel.clearSuggestions()
forceScrollToBottom = !forceScrollToBottom
}
}
@ -400,6 +418,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
onSendFileNote = { peer, onionOrChannel, path ->
viewModel.sendFileNote(peer, onionOrChannel, path)
},
recorderFactory = viewModel::createVoiceRecorder,
showCommandSuggestions = showCommandSuggestions,
commandSuggestions = commandSuggestions,
@ -625,8 +644,13 @@ fun ChatInputSection(
nickname: String,
colorScheme: ColorScheme,
showMediaButtons: Boolean,
recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null,
modifier: Modifier = Modifier
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activePublicTalker by remember(context) {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context).activePublicTalker
}.collectAsState()
Column(
// Flat, slightly translucent screen background — the same treatment as the top bar, so the
// two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked
@ -703,6 +727,8 @@ fun ChatInputSection(
nickname = nickname,
showMediaButtons = showMediaButtons,
mentionPeerIdentities = mentionPeerIdentities,
recorderFactory = recorderFactory,
activePublicTalker = activePublicTalker,
modifier = Modifier.fillMaxWidth()
)
}

View File

@ -53,11 +53,14 @@ fun getRSSIColor(rssi: Int): Color {
}
}
/** Thin space (U+2009) separating a display name from its `#abcd` disambiguation suffix. */
internal const val SUFFIX_THIN_SPACE = " "
/**
* Build the sender label shown above the first message of a group.
*
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
* annotation for everyone except yourself.
* Renders `@name` plus a dimmed `#abcd` suffix, separated by a thin space. The name carries
* a `nickname_click` annotation for everyone except yourself.
*/
fun formatTextMessageSender(
message: BitchatMessage,
@ -97,6 +100,7 @@ fun formatTextMessageSender(
builder.pop()
if (suffix.isNotEmpty()) {
builder.append(SUFFIX_THIN_SPACE)
builder.pushStyle(
SpanStyle(
color = senderColor.copy(alpha = SUFFIX_ALPHA),
@ -304,6 +308,7 @@ fun formatMessageHeaderAnnotatedString(
builder.pop()
if (suffix.isNotEmpty()) {
builder.append(SUFFIX_THIN_SPACE)
builder.pushStyle(
SpanStyle(
color = baseColor.copy(alpha = SUFFIX_ALPHA),

View File

@ -39,6 +39,9 @@ import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.VoiceRecorder
private data class ConversationLiveIdentityState(
val connectedPeerIDs: List<String>,
@ -73,6 +76,22 @@ class ChatViewModel(
mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun createVoiceRecorder(toPeerIDOrNull: String?, channelOrNull: String?): VoiceRecorder {
val context = getApplication<Application>().applicationContext
if (!LiveVoicePreferences.isEnabled(context)) return VoiceRecorder(context)
val recipientPeerID = toPeerIDOrNull?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)?.meshPeerID
}
val liveTarget = when {
toPeerIDOrNull != null && recipientPeerID != null && mesh.hasEstablishedSession(recipientPeerID) ->
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipientPeerID, payload) }
toPeerIDOrNull == null && channelOrNull == null && mesh.getActivePeerCount() > 0 ->
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(null, payload) }
else -> null
}
return VoiceRecorder(context, liveTarget)
}
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
mediaSendingManager.sendFileNote(toPeerIDOrNull, channelOrNull, filePath)
}
@ -1341,7 +1360,11 @@ class ChatViewModel(
fun updateCommandSuggestions(input: String) {
commandProcessor.updateCommandSuggestions(input)
}
fun clearSuggestions() {
commandProcessor.clearSuggestions()
}
fun selectCommandSuggestion(suggestion: CommandSuggestion): String {
return commandProcessor.selectCommandSuggestion(suggestion)
}

View File

@ -465,6 +465,18 @@ class CommandProcessor(
messageManager.addMessage(systemMessage)
}
/**
* Dismiss the command and mention popups. The composer clears its field in code
* after a send, which does not run onValueChange, so the popups need an explicit
* clear.
*/
fun clearSuggestions() {
state.setShowCommandSuggestions(false)
state.setCommandSuggestions(emptyList())
state.setShowMentionSuggestions(false)
state.setMentionSuggestions(emptyList())
}
// MARK: - Command Autocomplete
fun updateCommandSuggestions(input: String) {

View File

@ -71,6 +71,7 @@ import androidx.compose.ui.unit.toSize
import kotlin.math.roundToInt
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import com.bitchat.android.features.voice.VoiceRecorder
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@ -317,6 +318,8 @@ fun MessageInput(
nickname: String,
showMediaButtons: Boolean,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
recorderFactory: ((String?, String?) -> VoiceRecorder)? = null,
activePublicTalker: String? = null,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
@ -325,6 +328,7 @@ fun MessageInput(
val hasText = value.text.isNotBlank()
val focusRequester = remember { FocusRequester() }
var isRecording by remember { mutableStateOf(false) }
var isLiveRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
val cashuToken = remember(value.text) {
@ -484,7 +488,9 @@ fun MessageInput(
)
if (placeholderAlpha > 0f) {
Text(
text = stringResource(R.string.type_a_message_placeholder),
text = if (
selectedPrivatePeer == null && currentChannel == null && activePublicTalker != null
) "$activePublicTalker is live" else stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = BitchatFontFamily
),
@ -518,7 +524,8 @@ fun MessageInput(
// scrolls off the left edge while live data streams in from the right.
val secs = (elapsedMs / 1000).toInt()
Text(
text = String.format("%02d:%02d", secs / 60, secs % 60),
text = (if (isLiveRecording) "LIVE · " else "") +
String.format("%02d:%02d", secs / 60, secs % 60),
fontFamily = BitchatFontFamily,
color = colorScheme.error,
fontSize = (BASE_FONT_SIZE - 4).sp
@ -628,12 +635,18 @@ fun MessageInput(
VoiceRecordButton(
isRecording = isRecording,
recorderFactory = recorderFactory?.let { factory ->
{ factory(latestSelectedPeer.value, latestChannel.value) }
},
courtesyActive = selectedPrivatePeer == null && currentChannel == null &&
activePublicTalker != null,
shouldCancel = { pos ->
cancelBounds?.inflate(cancelSlackPx)?.contains(pos) == true
},
onTrackFinger = { cancelFinger = it },
onStart = {
onStart = { live ->
isRecording = true
isLiveRecording = live
elapsedMs = 0L
// Keep existing focus to avoid IME collapse, but do not
// force-show the keyboard.
@ -647,6 +660,7 @@ fun MessageInput(
},
onFinish = { path ->
isRecording = false
isLiveRecording = false
// Extract and cache the waveform from the actual audio file
// so it matches the receiver's rendering.
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
@ -667,6 +681,7 @@ fun MessageInput(
// waveform over an empty field.
onCancel = {
isRecording = false
isLiveRecording = false
amplitude = 0
elapsedMs = 0L
}

View File

@ -1849,7 +1849,9 @@ fun PrivateChatSheet(
onMessageTextChange = { newText ->
messageText = newText
viewModel.setConversationDraft(peerID, newText.text)
viewModel.updateMentionSuggestions(newText.text)
// Do not update the shared suggestion state here: this sheet
// renders its own popups as hidden, so an update only leaves
// a stale popup behind for the main composer.
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
@ -1872,6 +1874,7 @@ fun PrivateChatSheet(
onSendFileNote = { peer, channel, path ->
viewModel.sendFileNote(peer, channel, path)
},
recorderFactory = viewModel::createVoiceRecorder,
showCommandSuggestions = false,
commandSuggestions = emptyList(),
showMentionSuggestions = false,

View File

@ -8,7 +8,7 @@ import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
@ -16,9 +16,6 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -27,15 +24,19 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@ -49,6 +50,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -61,16 +63,21 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BitchatFontFamily
@ -83,10 +90,12 @@ import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.ChatUiModeManager
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.colorForPeer
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Locale
@ -223,6 +232,10 @@ fun MessagesList(
mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
}
// Collected once here so individual rows never subscribe to the preference flow; a mode
// switch simply recomposes the list against the new layout.
val bubbles by ChatUiModeManager.modeFlow.collectAsState()
// A fresh scroll position per conversation. Sharing one state meant a switch inherited the
// previous channel's offset and then had to correct itself, which is what the jump was.
//
@ -352,6 +365,7 @@ fun MessagesList(
meshService = meshService,
mentionPeerIdentities = resolvedMentionPeerIdentities,
showSender = !isGrouped,
bubbles = bubbles.isBubbles,
topSpacing = MessageGrouping.topSpacingFor(
isGrouped = isGrouped,
isFirstInList = originalIndex == 0
@ -385,6 +399,7 @@ fun MessageItem(
messages: List<BitchatMessage> = emptyList(),
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
showSender: Boolean = true,
bubbles: Boolean = false,
topSpacing: Dp = 0.dp,
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
@ -407,8 +422,9 @@ fun MessageItem(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Top
) {
// Provide a small end padding for own private messages so overlay doesn't cover text
val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
// Provide a small end padding for own private messages so overlay doesn't cover text.
// Bubble mode draws the status beneath the bubble instead, so no inset is needed.
val endPad = if (!bubbles && message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
// Create a custom layout that combines selectable text with clickable nickname areas
MessageTextWithClickableNicknames(
message = message,
@ -419,6 +435,7 @@ fun MessageItem(
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -429,8 +446,9 @@ fun MessageItem(
)
}
// Delivery status for private messages (overlay, non-displacing)
if (message.isPrivate && message.sender == currentUserNickname) {
// Delivery status for private messages (overlay, non-displacing). Bubble mode aligns
// own messages to the end edge where this overlay lives, so it renders below instead.
if (!bubbles && message.isPrivate && message.sender == currentUserNickname) {
message.deliveryStatus?.let { status ->
Box(
modifier = Modifier
@ -442,7 +460,24 @@ fun MessageItem(
}
}
}
// Bubble mode: text and media bubbles carry the marker inline, trailing the timestamp.
// File rows have no bubble shell, so their marker stays beneath the end-aligned row.
if (bubbles && message.type == BitchatMessageType.File &&
message.isPrivate && message.sender == currentUserNickname
) {
message.deliveryStatus?.let { status ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp, end = 4.dp),
contentAlignment = Alignment.CenterEnd
) {
DeliveryStatusIcon(status = status)
}
}
}
// Link previews removed; links are now highlighted inline and clickable within the message text
}
}
@ -458,6 +493,7 @@ fun MessageItem(
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
showSender: Boolean,
bubbles: Boolean = false,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
@ -476,6 +512,7 @@ fun MessageItem(
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -494,6 +531,7 @@ fun MessageItem(
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -514,7 +552,15 @@ fun MessageItem(
}
else -> null to null
}
Column(modifier = modifier.fillMaxWidth()) {
Column(
modifier = modifier.fillMaxWidth(),
// Bubble mode aligns self-authored file rows to the end side, mirroring text bubbles.
horizontalAlignment = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
Alignment.End
} else {
Alignment.Start
},
) {
// Header: nickname + timestamp line above the file, identical styling to text messages
val headerText = formatMessageHeaderAnnotatedString(
message = message,
@ -560,7 +606,14 @@ fun MessageItem(
null
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
Arrangement.End
} else {
Arrangement.Start
}
) {
Box {
if (packet != null) {
if (overrideProgress != null) {
@ -624,6 +677,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
@ -668,6 +722,7 @@ fun MessageItem(
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
@ -687,6 +742,7 @@ internal fun TextMessageLayout(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true,
bubbles: Boolean = false,
bodyContent: String = message.content,
) {
val palette = LocalBitchatPalette.current
@ -702,8 +758,17 @@ internal fun TextMessageLayout(
palette = palette,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val handleLongPress: () -> Unit = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
// The timestamp trails the body rather than occupying its own column, so a short message
// no longer reserves a full-width row for eight grey characters.
// no longer reserves a full-width row for eight grey characters. Self bubbles leave the
// timestamp to their meta cluster (see BubbleTextMessageLayout).
val bodyText = remember(
displayMessage,
currentUserNickname,
@ -711,7 +776,9 @@ internal fun TextMessageLayout(
colorScheme.onSurface,
colorScheme.secondary,
mentionPeerIdentities,
timeFormatter
timeFormatter,
bubbles,
isSelf
) {
formatTextMessageBody(
message = displayMessage,
@ -721,14 +788,23 @@ internal fun TextMessageLayout(
linkColor = colorScheme.secondary,
mentionPeerIdentities = mentionPeerIdentities,
timeFormatter = timeFormatter,
includeTimestamp = !bubbles || !isSelf,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val handleLongPress: () -> Unit = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
if (bubbles) {
BubbleTextMessageLayout(
message = message,
senderText = senderText,
bodyText = bodyText,
isSelf = isSelf,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = handleLongPress,
modifier = modifier,
)
return
}
Column(
@ -788,6 +864,199 @@ internal fun TextMessageLayout(
}
}
/**
* Classic messenger rendering of a text message: a rounded bubble that hugs its content, own
* messages on the right and everyone else on the left, with the corner on the speaker's side
* tightened into a subtle tail.
*
* The bubble is washed with the author's stable peer colour — the same identity-derived colour
* the `@name` label and mention chips already use — so the speaker stays identifiable at a
* glance without touching any surface, background, or theme colour. Body text keeps the
* standard `onSurface` tone; only the bubble shell carries the identity.
*/
@Composable
private fun BubbleTextMessageLayout(
message: BitchatMessage,
senderText: AnnotatedString,
bodyText: AnnotatedString,
isSelf: Boolean,
showSender: Boolean,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onLongPress: () -> Unit,
modifier: Modifier = Modifier,
) {
val palette = LocalBitchatPalette.current
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val authorColor = remember(message, isSelf, palette) {
if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette)
}
val corner = ChatVisualTokens.BubbleCornerRadius
val tail = ChatVisualTokens.BubbleTailRadius
val bubbleShape = if (isSelf) {
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner)
} else {
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail)
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start,
) {
// Cap the bubble at a fraction of the row so long messages wrap instead of touching the
// opposite edge, while short ones hug their content.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val maxBubbleWidth = maxWidth * ChatVisualTokens.BubbleMaxWidthFraction
val density = LocalDensity.current
val textCapPx = with(density) {
(maxBubbleWidth - ChatVisualTokens.BubblePaddingHorizontal * 2 - 2.dp).toPx()
}
var bodyLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
var clusterSize by remember { mutableStateOf(IntSize.Zero) }
// The meta cluster (timestamp, then delivery checks for own private messages) rides
// flush-right on the body's last line when that line has room for it, and drops
// below the text only when it does not. Placement is computed from the laid-out
// text, so wrapping is never influenced by the cluster: no early wraps, no slack
// carved out of the first lines, no minimum bubble width.
val metaGapPx = with(density) { 8.dp.toPx() }
val metaPlan = remember(bodyLayout, clusterSize, textCapPx) {
val layout = bodyLayout ?: return@remember null
if (layout.lineCount == 0 || clusterSize.width <= 0) return@remember null
val lastLineRight = layout.getLineRight(layout.lineCount - 1)
if (lastLineRight + metaGapPx + clusterSize.width <= textCapPx) {
BubbleMetaPlan(
widthPx = maxOf(layout.size.width.toFloat(), lastLineRight + metaGapPx + clusterSize.width),
reserveOwnLine = false,
)
} else {
BubbleMetaPlan(
widthPx = maxOf(layout.size.width.toFloat(), clusterSize.width.toFloat()),
reserveOwnLine = true,
)
}
}
Box(
modifier = Modifier
.align(if (isSelf) Alignment.CenterEnd else Alignment.CenterStart)
.widthIn(max = maxBubbleWidth)
.border(
width = 1.dp,
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha),
shape = bubbleShape
)
.background(
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha),
shape = bubbleShape
)
.padding(
horizontal = ChatVisualTokens.BubblePaddingHorizontal,
vertical = ChatVisualTokens.BubblePaddingVertical,
)
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
// The sender's name heads the first bubble of their run, like classic group
// messengers, instead of floating above it. Own bubbles never show a name —
// the end side is attribution enough. Continuation bubbles skip it too.
if (showSender && !isSelf) {
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = onLongPress,
fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
style = MessageSenderTextStyle,
)
}
Box(
modifier = if (isSelf && metaPlan != null) {
Modifier.width(with(density) { metaPlan!!.widthPx.toDp() })
} else {
Modifier
}
) {
AnnotatedClickableText(
text = bodyText,
annotationTags = listOf("geohash_click", "url_click"),
onAnnotationClick = { tag, item ->
when (tag) {
"geohash_click" -> {
navigateToGeohash(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
"url_click" -> {
openMessageUrl(context, item)
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
true
}
else -> false
}
},
onLongPress = onLongPress,
modifier = Modifier.padding(
bottom = if (metaPlan?.reserveOwnLine == true) {
with(density) { clusterSize.height.toDp() }
} else {
0.dp
}
),
fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface),
onTextLayout = { bodyLayout = it },
)
if (isSelf) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.BottomEnd)
.onSizeChanged { clusterSize = it }
.graphicsLayer { alpha = if (metaPlan != null) 1f else 0f },
) {
Text(
text = formatTextMessageMetadata(message, timeFormatter),
fontFamily = BitchatFontFamily,
)
if (message.isPrivate) {
message.deliveryStatus?.let { status ->
Spacer(Modifier.width(4.dp))
DeliveryStatusIcon(status = status)
}
}
}
}
}
}
}
}
}
}
private data class BubbleMetaPlan(
val widthPx: Float,
val reserveOwnLine: Boolean,
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CashuMessageContent(
@ -797,6 +1066,7 @@ private fun CashuMessageContent(
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
bubbles: Boolean = false,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
@ -816,6 +1086,7 @@ private fun CashuMessageContent(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
bodyContent = remainingText,
@ -931,43 +1202,77 @@ private fun redeemCashu(context: Context, token: String, preferWallet: Boolean)
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) }
}
/**
* Per-check target colours for the delivery marker.
*
* Both checks always render — grey (disabled) until an acknowledgement turns them on — so a
* status change recolours in place and never reflows text around it. Read receipts use the
* app's primary green rather than a separate accent. [status] == null yields the all-grey
* baseline used while a message is still being sent.
*/
private fun deliveryCheckColors(status: DeliveryStatus?, colorScheme: ColorScheme): Pair<Color, Color> {
val grey = colorScheme.onSurface.copy(alpha = 0.35f)
val green = colorScheme.primary
return when (status) {
is DeliveryStatus.Read -> green to green
is DeliveryStatus.Delivered -> green to grey
is DeliveryStatus.PartiallyDelivered -> green to grey
is DeliveryStatus.Failed -> colorScheme.error to colorScheme.error
else -> grey to grey
}
}
/** Acknowledgement progress ordering, used to fire the pop only when the state advances. */
private fun deliveryCheckRank(status: DeliveryStatus): Int = when (status) {
is DeliveryStatus.Read -> 3
is DeliveryStatus.Delivered -> 2
is DeliveryStatus.PartiallyDelivered -> 2
is DeliveryStatus.Failed -> 1
else -> 0
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme
val (firstTarget, secondTarget) = deliveryCheckColors(status, colorScheme)
val first by animateColorAsState(
targetValue = firstTarget,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "firstCheckColor",
)
val second by animateColorAsState(
targetValue = secondTarget,
animationSpec = tween(BitchatMotion.QUICK_MS),
label = "secondCheckColor",
)
// Status advances on its own as acks come back, so a hard glyph swap reads as a flicker.
// Keyed on the status *type* rather than the instance, because Delivered/Read carry a
// timestamp that would otherwise retrigger the transition on every identical update.
AnimatedContent(
targetState = status::class,
transitionSpec = {
fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith
fadeOut(tween(BitchatMotion.QUICK_MS))
},
label = "deliveryStatus"
) { statusClass ->
val (text, color, weight) = when (statusClass) {
DeliveryStatus.Sending::class ->
Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
// Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity).
DeliveryStatus.Sent::class ->
Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
DeliveryStatus.Delivered::class ->
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal)
DeliveryStatus.Read::class ->
Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold)
DeliveryStatus.Failed::class ->
Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal)
// A single subdued check, without the numeric label.
else ->
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
// Snappy micro pop when the state advances to (more) acknowledged. Keyed on the rank, not
// the instance, because Delivered/Read carry timestamps that would retrigger it otherwise.
val scale = remember { Animatable(1f) }
LaunchedEffect(deliveryCheckRank(status)) {
if (deliveryCheckRank(status) >= 2) {
scale.snapTo(1.3f)
scale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 900f))
}
Text(
text = stringResource(text),
fontSize = 10.sp,
color = color,
fontWeight = weight
)
}
val text = remember(first, second) {
androidx.compose.ui.text.buildAnnotatedString {
pushStyle(androidx.compose.ui.text.SpanStyle(color = first))
append("✓")
pop()
pushStyle(androidx.compose.ui.text.SpanStyle(color = second))
append("✓")
pop()
}
}
Text(
text = text,
fontSize = 10.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
)
}

View File

@ -62,6 +62,8 @@ private const val ReleaseTailMs = 500L
@Composable
fun VoiceRecordButton(
modifier: Modifier = Modifier,
recorderFactory: (() -> VoiceRecorder)? = null,
courtesyActive: Boolean = false,
/**
* Recording state as the composer sees it. Drives the active tint so the button and the
* pill's border change together instead of one lagging the other.
@ -79,7 +81,7 @@ fun VoiceRecordButton(
* cancel target); null once the gesture ends.
*/
onTrackFinger: (Offset?) -> Unit = {},
onStart: () -> Unit,
onStart: (isLive: Boolean) -> Unit,
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
onFinish: (filePath: String) -> Unit,
/**
@ -109,6 +111,7 @@ fun VoiceRecordButton(
val latestOnCancel = rememberUpdatedState(onCancel)
val latestShouldCancel = rememberUpdatedState(shouldCancel)
val latestOnTrackFinger = rememberUpdatedState(onTrackFinger)
val latestRecorderFactory = rememberUpdatedState(recorderFactory)
// Set when this instance was composed, so presses inherited from whatever occupied this spot
// beforehand can be rejected.
@ -130,7 +133,7 @@ fun VoiceRecordButton(
ampJob = null
if (isCapturing) {
isCapturing = false
runCatching { recorder?.stop() }
runCatching { recorder?.stop(canceled = true) }
recorder = null
recordedFilePath = null
latestOnTrackFinger.value(null)
@ -141,8 +144,11 @@ fun VoiceRecordButton(
// Same disc, same sizing and the same press feedback as the camera and send buttons.
ComposerActionSurface(
isActive = isRecording || isCapturing,
isActive = isRecording || isCapturing || courtesyActive,
isPressed = isCapturing,
activeColor = if (courtesyActive && !isRecording && !isCapturing) {
androidx.compose.ui.graphics.Color(0xFFFFB300)
} else androidx.compose.ui.graphics.Color.Unspecified,
modifier = modifier
.onGloballyPositioned { buttonCoords = it }
.pointerInput(Unit) {
@ -169,12 +175,12 @@ fun VoiceRecordButton(
}
if (releasedEarly != null || stolenDuringArm) return@awaitEachGesture
val rec = VoiceRecorder(context)
val rec = latestRecorderFactory.value?.invoke() ?: VoiceRecorder(context)
val startedFile = rec.start()
if (startedFile == null) {
// Recorder refused to start; make sure the caller does not sit in a
// recording state that never began.
runCatching { rec.stop() }
runCatching { rec.stop(canceled = true) }
latestOnCancel.value()
return@awaitEachGesture
}
@ -183,7 +189,7 @@ fun VoiceRecordButton(
recordedFilePath = startedFile.absolutePath
recordingStart = System.currentTimeMillis()
isCapturing = true
latestOnStart.value()
latestOnStart.value(rec.isLive)
buzz()
ampJob?.cancel()
@ -198,7 +204,7 @@ fun VoiceRecordButton(
val file = recorder?.stop()
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
val path = file?.absolutePath ?: recordedFilePath?.takeIf { File(it).isFile }
recordedFilePath = null
latestOnTrackFinger.value(null)
buzz()
@ -237,10 +243,10 @@ fun VoiceRecordButton(
withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() }
}
if (isCapturing) {
val file = recorder?.stop()
val file = recorder?.stop(canceled = cancel)
isCapturing = false
recorder = null
val path = file?.absolutePath ?: recordedFilePath
val path = file?.absolutePath ?: recordedFilePath?.takeIf { File(it).isFile }
recordedFilePath = null
if (cancel) {
path?.let { runCatching { File(it).delete() } }

View File

@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -23,6 +25,7 @@ import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
import androidx.compose.ui.platform.LocalContext
@Composable
fun AudioMessageItem(
@ -35,9 +38,14 @@ fun AudioMessageItem(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true
showSender: Boolean = true,
bubbles: Boolean = false
) {
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val liveMessageIDs by com.bitchat.android.features.voice.LiveVoiceManager
.getInstance(context).liveMessageIDs.collectAsState()
val isLive = message.id in liveMessageIDs
val path = message.content.trim()
// Derive sending progress if applicable
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
@ -48,7 +56,56 @@ fun AudioMessageItem(
}
else -> null to null
}
Column(modifier = modifier.fillMaxWidth()) {
// Bubble mode wraps the voice note in the same tinted shell as text bubbles; Matrix mode
// keeps the flat header-plus-player layout.
if (bubbles) {
MediaBubbleShell(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = { onMessageLongPress?.invoke(message) },
modifier = modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLive) {
androidx.compose.material3.Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 8.dp)
)
}
VoiceNotePlayer(
path = path,
progressOverride = overrideProgress,
progressColor = overrideColor,
modifier = Modifier.widthIn(max = 260.dp)
)
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(26.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
}
return
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
// Header: nickname + timestamp line above the audio note, identical styling to text messages
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
@ -78,10 +135,18 @@ fun AudioMessageItem(
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLive) {
androidx.compose.material3.Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 8.dp)
)
}
VoiceNotePlayer(
path = path,
progressOverride = overrideProgress,
progressColor = overrideColor
progressColor = overrideColor,
)
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {

View File

@ -2,9 +2,11 @@ package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
@ -43,11 +45,59 @@ fun ImageMessageItem(
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true
showSender: Boolean = true,
bubbles: Boolean = false
) {
val palette = LocalBitchatPalette.current
val path = message.content.trim()
Column(modifier = modifier.fillMaxWidth()) {
// Bubble mode wraps the image in the same tinted shell as text bubbles; Matrix mode keeps
// the flat header-plus-card layout.
val bubblesMode = bubbles
val imageShape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
val context = LocalContext.current
val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } }
// Collect all image paths from messages for swipe navigation
val imagePaths = remember(messages) {
messages.filter { it.type == BitchatMessageType.Image }
.map { it.content.trim() }
}
val haptic = LocalHapticFeedback.current
if (bubblesMode) {
MediaBubbleShell(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = { onMessageLongPress?.invoke(message) },
modifier = modifier,
) {
ImageMessageCard(
message = message,
currentUserNickname = currentUserNickname,
path = path,
bmp = bmp,
imagePaths = imagePaths,
imageShape = imageShape,
onImageClick = onImageClick,
onCancelTransfer = onCancelTransfer,
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
},
)
}
return
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
@ -57,7 +107,6 @@ fun ImageMessageItem(
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
text = headerText,
annotationTags = listOf("nickname_click"),
@ -75,75 +124,93 @@ fun ImageMessageItem(
color = colorScheme.onSurface,
)
val context = LocalContext.current
val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } }
// Collect all image paths from messages for swipe navigation
val imagePaths = remember(messages) {
messages.filter { it.type == BitchatMessageType.Image }
.map { it.content.trim() }
}
if (bmp != null) {
val img = bmp.asImageBitmap()
val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f
val progressFraction: Float? = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
else -> null
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Box {
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
// Cyberpunk block-reveal while sending
BlockRevealImage(
bitmap = img,
progress = progressFraction,
blocksX = 24,
blocksY = 16,
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
.clickable {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
}
)
} else {
// Fully revealed image
Image(
bitmap = img,
contentDescription = stringResource(com.bitchat.android.R.string.cd_image),
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
.clickable {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
},
contentScale = ContentScale.Fit
)
}
// Cancel button overlay during sending
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
ImageMessageCard(
message = message,
currentUserNickname = currentUserNickname,
path = path,
bmp = bmp,
imagePaths = imagePaths,
imageShape = imageShape,
onImageClick = onImageClick,
onCancelTransfer = onCancelTransfer,
onLongPress = null,
)
}
} else {
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ImageMessageCard(
message: BitchatMessage,
currentUserNickname: String,
path: String,
bmp: android.graphics.Bitmap?,
imagePaths: List<String>,
imageShape: androidx.compose.foundation.shape.RoundedCornerShape,
onImageClick: ((String, List<String>, Int) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onLongPress: (() -> Unit)?,
) {
if (bmp == null) {
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
return
}
val img = bmp.asImageBitmap()
val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f
val progressFraction: Float? = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
else -> null
}
Box {
val imageModifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(imageShape)
.combinedClickable(
onClick = {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
},
onLongClick = { onLongPress?.invoke() },
)
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
// Cyberpunk block-reveal while sending
BlockRevealImage(
bitmap = img,
progress = progressFraction,
blocksX = 24,
blocksY = 16,
modifier = imageModifier
)
} else {
// Fully revealed image
Image(
bitmap = img,
contentDescription = stringResource(com.bitchat.android.R.string.cd_image),
modifier = imageModifier,
contentScale = ContentScale.Fit
)
}
// Cancel button overlay during sending
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
}

View File

@ -0,0 +1,155 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.DeliveryStatusIcon
import com.bitchat.android.ui.formatTextMessageMetadata
import com.bitchat.android.ui.formatTextMessageSender
import com.bitchat.android.ui.isFromSelf
import com.bitchat.android.ui.peerIdentityForMessage
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
/**
* Classic messenger shell for media messages (images, voice notes) in `ChatUiMode.Bubbles`,
* mirroring the text-bubble treatment: the author's identity colour washes the fill and
* hairline, the speaker-side corner tightens into a tail, the sender's name heads the first
* bubble of a run, and the timestamp (plus delivery ticks for own private messages) rides
* flush-right beneath the media. The shell itself is long-clickable so media bubbles always
* open the message action sheet even when no sender label is shown and the media consumes
* touches (voice-note player controls).
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MediaBubbleShell(
message: BitchatMessage,
currentUserNickname: String,
myPeerID: String,
showSender: Boolean,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onLongPress: (() -> Unit)?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val palette = LocalBitchatPalette.current
val haptic = LocalHapticFeedback.current
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
val authorColor = remember(message, isSelf, palette) {
if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette)
}
val corner = ChatVisualTokens.BubbleCornerRadius
val tail = ChatVisualTokens.BubbleTailRadius
val bubbleShape = if (isSelf) {
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner)
} else {
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail)
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start,
) {
Box(
modifier = Modifier
.border(
width = 1.dp,
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha),
shape = bubbleShape
)
.background(
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha),
shape = bubbleShape
)
.combinedClickable(
enabled = onLongPress != null,
onClick = {},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPress?.invoke()
},
)
.padding(
horizontal = ChatVisualTokens.BubblePaddingHorizontal,
vertical = ChatVisualTokens.BubblePaddingVertical,
)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (showSender && !isSelf) {
val senderText = remember(message, currentUserNickname, myPeerID, palette) {
formatTextMessageSender(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = myPeerID,
palette = palette,
)
}
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onLongPress?.invoke() },
fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
style = MessageSenderTextStyle,
)
}
content()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.End),
) {
Text(
text = formatTextMessageMetadata(message, timeFormatter),
fontFamily = BitchatFontFamily,
)
if (isSelf && message.isPrivate) {
message.deliveryStatus?.let { status ->
Spacer(Modifier.width(4.dp))
DeliveryStatusIcon(status = status)
}
}
}
}
}
}
}

View File

@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp
@Composable
fun VoiceNotePlayer(
path: String,
modifier: Modifier = Modifier,
progressOverride: Float? = null,
progressColor: Color? = null
) {
@ -86,7 +87,7 @@ fun VoiceNotePlayer(
DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } }
Row(
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {

View File

@ -0,0 +1,49 @@
package com.bitchat.android.ui.theme
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Chat transcript presentation.
*
* [Matrix] is the established terminal-style transcript: a flat, left-aligned monochrome
* stream where colour is reserved for `@names` and links.
*
* [Bubbles] is the classic messenger layout: messages hug their content inside rounded
* bubbles, own messages on the right and everyone else on the left. Each bubble is tinted
* with its author's stable peer colour, so the speaker stays identifiable without reading
* the name. Colours, surfaces, and typography are untouched — only the message layout
* changes.
*/
enum class ChatUiMode {
Matrix,
Bubbles;
val isMatrix: Boolean get() = this == Matrix
val isBubbles: Boolean get() = this == Bubbles
}
/**
* Simple SharedPreferences-backed manager for the chat UI mode with a StateFlow.
* Mirrors [ThemePreferenceManager].
*/
object ChatUiModeManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_CHAT_UI_MODE = "chat_ui_mode"
private val _modeFlow = MutableStateFlow(ChatUiMode.Bubbles)
val modeFlow: StateFlow<ChatUiMode> = _modeFlow
fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val saved = prefs.getString(KEY_CHAT_UI_MODE, ChatUiMode.Bubbles.name)
_modeFlow.value = runCatching { ChatUiMode.valueOf(saved!!) }.getOrDefault(ChatUiMode.Bubbles)
}
fun set(context: Context, mode: ChatUiMode) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_CHAT_UI_MODE, mode.name).apply()
_modeFlow.value = mode
}
}

View File

@ -37,6 +37,30 @@ internal object ChatVisualTokens {
val SenderTopPadding: Dp = 8.dp
val SenderToBodySpacing: Dp = 4.dp
// MARK: - Bubble geometry (ChatUiMode.Bubbles)
/** Rounded corner on the three "free" corners of a message bubble. */
val BubbleCornerRadius: Dp = 16.dp
/** Tightened corner on the speaker's own side, giving the bubble a subtle tail. */
val BubbleTailRadius: Dp = 4.dp
/** Padding inside a bubble, around the text. */
val BubblePaddingHorizontal: Dp = 12.dp
val BubblePaddingVertical: Dp = 8.dp
/** A bubble never grows past this fraction of the list width, so long lines still wrap. */
const val BubbleMaxWidthFraction: Float = 0.80f
/**
* Author-colour wash inside a bubble. Matches the mention-chip treatment so a tinted
* bubble stays legible on both the near-black and near-white chat surfaces.
*/
const val BubbleBackgroundAlpha: Float = 0.18f
/** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */
const val BubbleBorderAlpha: Float = 0.38f
const val SenderSuffixAlpha: Float = 0.60f
const val HighlightAlpha: Float = 0.20f
const val MutedTextAlpha: Float = 0.50f

View File

@ -23,9 +23,6 @@ object AppConstants {
const val CONNECTION_CLEANUP_INTERVAL_MS: Long = 30_000L
const val BROADCAST_CLEANUP_DELAY_MS: Long = 500L
// GATT client RSSI updates
const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L
object Gatt {
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")

View File

@ -1422,6 +1422,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendFilePrivate(recipientPeerID, file)
}
override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
meshCore.sendVoiceFrame(recipientPeerID, payload)
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">BitChat</string>
<string name="permission_bluetooth_rationale">Oprávnění Bluetooth je nezbytné pro funkci odesílání zpráv skrze BitChat.</string>
<string name="permission_location_rationale">Je třeba povolit polohu, aby bylo možné vyhledávat okolní zařízení přes Bluetooth.</string>
<string name="permission_notification_rationale">Oprávnění pro zasílání oznámení je nutné pro upozornění na nové zprávy.</string>
<string name="nickname_hint">Přezdívka</string>
<string name="message_hint">Napište zprávu…</string>
<string name="channel_password_hint">Heslo</string>
<string name="join_channel">Připojit se ke kanálu</string>
<string name="leave_channel">Odejít</string>
<string name="send_message">Odeslat</string>
<string name="back">Zpět</string>
<string name="people">Lidé</string>
<string name="channels">Kanály</string>
<string name="online_users">Online uživatelé</string>
<string name="no_one_connected">Nikdo není připojen</string>
<string name="emergency_clear_hint">Klepněte třikrát pro smazání všech dat</string>
</resources>

View File

@ -293,12 +293,6 @@
<string name="media_type_file">File</string>
<!-- Mga status ng mensahe -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- Tulong na text sa abiso -->
<string name="notification_sent_image">📷 nagpadala ng larawan</string>

View File

@ -294,12 +294,6 @@
<string name="media_type_file">Fichier</string>
<!-- Statuts de message -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- Aide de texte de notification -->
<string name="notification_sent_image">📷 a envoyé une image</string>

View File

@ -349,12 +349,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">תמונה</string>
<string name="media_type_file">קובץ</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 שלח/ה תמונה</string>
<string name="notification_sent_voice">🎤 שלח/ה הודעה קולית</string>
<string name="notification_sent_file">📎 שלח/ה קובץ</string>

View File

@ -338,12 +338,6 @@
<string name="warning_emoji">⚠️</string>
<!-- Icone stato messaggio -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- Helper file notifiche -->
<string name="notification_file_pdf">📄</string>

View File

@ -302,12 +302,6 @@
<string name="media_type_file">Rakitra</string>
<!-- Famantarana toe-javatra hafatra -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- Mpanampy lahatsoratra fampandrenesana -->
<string name="notification_sent_image">📷 nandefasa sary</string>

View File

@ -374,12 +374,6 @@
<string name="media_type_file">Fail</string>
<!-- Message status icons -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- Notification text helpers -->
<string name="notification_sent_image">📷 menghantar imej</string>

View File

@ -293,12 +293,6 @@
<string name="media_type_file">फाइल</string>
<!-- सन्देश स्थिति -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- सूचना पाठ सहायक -->
<string name="notification_sent_image">📷 तस्वीर पठाइयो</string>

View File

@ -339,12 +339,6 @@
<string name="debug_question_mark">?</string>
<!-- Berichtstatus-pictogrammen -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- Meldingsbestand-emojis -->
<string name="notification_file_pdf">📄</string>

View File

@ -347,12 +347,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">Obraz</string>
<string name="media_type_file">Plik</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 wysłał(a) obraz</string>
<string name="notification_sent_voice">🎤 wysłał(a) wiadomość głosową</string>
<string name="notification_sent_file">📎 wysłał(a) plik</string>

View File

@ -276,13 +276,6 @@
<string name="media_type_image">Изображение</string>
<string name="media_type_file">Файл</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 отправил изображение</string>
<string name="notification_sent_voice">🎤 отправил голосовое</string>
<string name="notification_sent_file">📎 отправил файл</string>

View File

@ -276,13 +276,6 @@
<string name="media_type_image">Bild</string>
<string name="media_type_file">Fil</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 skickade en bild</string>
<string name="notification_sent_voice">🎤 skickade ett röstmeddelande</string>
<string name="notification_sent_file">📎 skickade en fil</string>

View File

@ -352,12 +352,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">படம்</string>
<string name="media_type_file">கோப்பு</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 ஒரு படத்தை அனுப்பியது</string>
<string name="notification_sent_voice">🎤 ஒரு குரல் செய்தியை அனுப்பியது</string>
<string name="notification_sent_file">📎 ஒரு கோப்பை அனுப்பியது</string>

View File

@ -276,13 +276,6 @@
<string name="media_type_image">Görsel</string>
<string name="media_type_file">Dosya</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 görsel gönderdi</string>
<string name="notification_sent_voice">🎤 sesli mesaj gönderdi</string>
<string name="notification_sent_file">📎 dosya gönderdi</string>

View File

@ -341,12 +341,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">Зображення</string>
<string name="media_type_file">Файл</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 надіслав(-ла) зображення</string>
<string name="notification_sent_voice">🎤 надіслав(-ла) голосове повідомлення</string>
<string name="notification_sent_file">📎 надіслав(-ла) файл</string>

View File

@ -347,12 +347,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">图片</string>
<string name="media_type_file">文件</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 发送了一张图片</string>
<string name="notification_sent_voice">🎤 发送了一条语音消息</string>
<string name="notification_sent_file">📎 发送了一个文件</string>

View File

@ -347,12 +347,6 @@
<string name="image_star">image/*</string>
<string name="media_type_image">圖片</string>
<string name="media_type_file">檔案</string>
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<string name="notification_sent_image">📷 傳送了一張圖片</string>
<string name="notification_sent_voice">🎤 傳送了一則語音訊息</string>
<string name="notification_sent_file">📎 傳送了一個檔案</string>

View File

@ -293,12 +293,6 @@
<string name="media_type_file">文件</string>
<!-- 消息状态 -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- 通知文本辅助 -->
<string name="notification_sent_image">📷 发送了图片</string>

View File

@ -196,6 +196,8 @@
<string name="about_system">System</string>
<string name="about_light">Light</string>
<string name="about_dark">Dark</string>
<string name="chat_ui_matrix">Matrix</string>
<string name="chat_ui_bubbles">Bubbles</string>
<string name="about_pow">Proof of Work</string>
<string name="about_pow_off">PoW Off</string>
<string name="about_pow_on">PoW On</string>
@ -556,14 +558,6 @@
<string name="media_type_image">Image</string>
<string name="media_type_file">File</string>
<!-- Message status icons -->
<string name="status_sending">○</string>
<string name="status_pending">○</string>
<string name="status_sent">✓</string>
<string name="status_delivered">✓✓</string>
<string name="status_failed">⚠</string>
<string name="status_read">✓</string>
<!-- Notification text helpers -->
<string name="notification_sent_image">📷 sent an image</string>
<string name="notification_sent_voice">🎤 sent a voice message</string>

View File

@ -0,0 +1,116 @@
package com.bitchat.android.features.voice
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class VoiceBurstPacketTest {
private val burstID = ByteArray(8) { (it + 1).toByte() }
@Test
fun wirePacketsRoundTripWithIosLayout() {
val packets = listOf(
VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)!!,
VoiceBurstPacket.create(
burstID,
7,
VoiceBurstPacket.Kind.Frames(
listOf(byteArrayOf(0xDE.toByte(), 0xAD.toByte()), ByteArray(130) { 0x42 })
)
)!!,
VoiceBurstPacket.create(burstID, 42, VoiceBurstPacket.Kind.End(41, 2_688))!!,
VoiceBurstPacket.create(burstID, 3, VoiceBurstPacket.Kind.Canceled)!!
)
packets.forEach { expected ->
val actual = VoiceBurstPacket.decode(expected.encode())
assertNotNull(actual)
assertArrayEquals(expected.burstID, actual!!.burstID)
assertEquals(expected.sequence, actual.sequence)
assertEquals(expected.kind, actual.kind)
}
}
@Test
fun encodedGoldenVectorsMatchIos() {
val start = VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)!!.encode()
assertArrayEquals(
byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 1, 1),
start
)
val end = VoiceBurstPacket.create(
burstID,
42,
VoiceBurstPacket.Kind.End(41, 2_688)
)!!.encode()
assertArrayEquals(
byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 42, 2, 0, 41, 0, 0, 10, 0x80.toByte()),
end
)
}
@Test
fun rejectsMalformedPacketsAndConstruction() {
assertNull(VoiceBurstPacket.decode(byteArrayOf()))
assertNull(VoiceBurstPacket.decode(ByteArray(10)))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0xFF.toByte())))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0)))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0, 0, 16, 0xAB.toByte())))
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 0, 1, 0x7F)))
assertNull(VoiceBurstPacket.create(byteArrayOf(1, 2), 0, VoiceBurstPacket.Kind.Canceled))
assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(emptyList())))
assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(listOf(byteArrayOf()))))
}
@Test
fun packetizerRespectsBudgetAndCounters() {
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
val frame = ByteArray(130) { 0x55 }
assertTrue(packetizer.add(frame).isEmpty())
val first = packetizer.add(frame).single()
assertEquals(1, VoiceBurstPacket.decode(first)!!.sequence)
val final = packetizer.flush().single()
assertEquals(2, VoiceBurstPacket.decode(final)!!.sequence)
assertEquals(2, packetizer.dataPacketCount)
assertEquals(3, packetizer.nextSequence)
assertTrue(packetizer.flush().isEmpty())
assertTrue(first.size + 1 + 16 <= 256)
}
@Test
fun packetizerBatchesEightOrBudgetAndAdtsHeaderIsValid() {
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
repeat(4) { assertTrue(packetizer.add(ByteArray(40) { 0x11 }).isEmpty()) }
val decoded = VoiceBurstPacket.decode(packetizer.flush().single())!!
assertEquals(4, (decoded.kind as VoiceBurstPacket.Kind.Frames).frames.size)
val framed = AdtsFramer.frame(byteArrayOf(1, 2, 3))
assertEquals(10, framed.size)
assertEquals(0xFF, framed[0].toInt() and 0xFF)
assertEquals(0xF1, framed[1].toInt() and 0xFF)
assertArrayEquals(byteArrayOf(1, 2, 3), framed.copyOfRange(7, framed.size))
}
@Test
fun packetizerReportsFramesThatCannotFitTheWireBudget() {
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
assertTrue(packetizer.add(ByteArray(198)).isEmpty())
assertEquals(1, packetizer.droppedFrameCount)
assertEquals(0, packetizer.dataPacketCount)
assertEquals(1, packetizer.nextSequence)
}
}

View File

@ -24,6 +24,7 @@ class BLEPacketPaddingPolicyTest {
MessageType.REQUEST_SYNC to false,
MessageType.FRAGMENT to false,
MessageType.FILE_TRANSFER to false,
MessageType.VOICE_FRAME to false,
MessageType.NOISE_ENCRYPTED to true,
MessageType.NOISE_HANDSHAKE to true
)
@ -50,7 +51,8 @@ class BLEPacketPaddingPolicyTest {
MessageType.LEAVE,
MessageType.REQUEST_SYNC,
MessageType.FRAGMENT,
MessageType.FILE_TRANSFER
MessageType.FILE_TRANSFER,
MessageType.VOICE_FRAME
)
publicTypes.forEach { type ->

View File

@ -385,7 +385,7 @@ class ChatUIUtilsTest {
palette = palette,
)
assertEquals("@carol#04af", sender.text)
assertEquals("@carol #04af", sender.text)
val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }

View File

@ -8,6 +8,7 @@ import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@ -161,4 +162,30 @@ class CommandProcessorTest() {
)
assertEquals(0, chatState.getMessagesValue().size)
}
@Test
fun `clearSuggestions hides the command suggestion popup`() {
// Typing "/" opens the command popup.
commandProcessor.updateCommandSuggestions("/")
assertTrue(chatState.getShowCommandSuggestionsValue())
// The send handlers call this after clearing the field in code, which does
// not run the text-change handler that normally hides the popup.
commandProcessor.clearSuggestions()
assertFalse(chatState.getShowCommandSuggestionsValue())
assertTrue(chatState.getCommandSuggestionsValue().isEmpty())
}
@Test
fun `clearSuggestions hides the mention suggestion popup`() {
whenever(meshService.getPeerNicknames()).thenReturn(mapOf("peer-1" to "alice"))
// Typing "@a" opens the mention popup.
commandProcessor.updateMentionSuggestions("@a", meshService, viewModel = null)
assertTrue(chatState.getShowMentionSuggestionsValue())
commandProcessor.clearSuggestions()
assertFalse(chatState.getShowMentionSuggestionsValue())
assertTrue(chatState.getMentionSuggestionsValue().isEmpty())
}
}

View File

@ -4,6 +4,11 @@ import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
@ -121,6 +126,7 @@ class PeerAvailabilityTrackerTest {
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [32])
@OptIn(ExperimentalCoroutinesApi::class)
class PeerAvailabilityNotifierTest {
private lateinit var context: Context
private lateinit var systemNotificationManager: NotificationManager
@ -138,18 +144,25 @@ class PeerAvailabilityNotifierTest {
}
@Test
fun `background availability posts on dedicated channel`() {
fun `background availability waits ten seconds before posting on dedicated channel`() = runTest {
val textProvider = testTextProvider()
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = tracker(),
textProvider = textProvider,
canPostNotifications = { true }
val notifier = createNotifier(
scope = this,
textProvider = textProvider
)
notifier.onPeerCountChanged(0, isAppInBackground = true)
notifier.onPeerCountChanged(2, isAppInBackground = true)
advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS - 1)
assertNull(
shadowOf(systemNotificationManager).getNotification(
PeerAvailabilityNotifier.NOTIFICATION_ID
)
)
advanceTimeBy(1)
runCurrent()
val notification =
shadowOf(systemNotificationManager).getNotification(
PeerAvailabilityNotifier.NOTIFICATION_ID
@ -171,16 +184,63 @@ class PeerAvailabilityNotifierTest {
}
@Test
fun `returning to zero cancels availability notification`() {
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = tracker(),
textProvider = testTextProvider(),
canPostNotifications = { true }
fun `later peer arrivals are included without restarting aggregation window`() = runTest {
val textProvider = testTextProvider()
val notifier = createNotifier(
scope = this,
textProvider = textProvider
)
notifier.onPeerCountChanged(1, isAppInBackground = true)
advanceTimeBy(6_000L)
notifier.onPeerCountChanged(3, isAppInBackground = true)
advanceTimeBy(4_000L)
runCurrent()
val notification =
shadowOf(systemNotificationManager).getNotification(
PeerAvailabilityNotifier.NOTIFICATION_ID
)
assertNotNull(notification)
assertEquals(
textProvider.body(3),
notification.extras.getString("android.text")
)
}
@Test
fun `opening app during aggregation suppresses notification`() = runTest {
var isAppInBackground = true
val history = InMemoryAlertHistory()
val notifier = createNotifier(
scope = this,
availabilityTracker = PeerAvailabilityTracker(history),
isAppCurrentlyInBackground = { isAppInBackground }
)
notifier.onPeerCountChanged(1, isAppInBackground = true)
advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2)
isAppInBackground = false
advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2)
runCurrent()
assertNull(
shadowOf(systemNotificationManager).getNotification(
PeerAvailabilityNotifier.NOTIFICATION_ID
)
)
assertNull(history.lastAlertAtMillis)
}
@Test
fun `returning to zero cancels pending availability notification`() = runTest {
val notifier = createNotifier(scope = this)
notifier.onPeerCountChanged(1, isAppInBackground = true)
advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS / 2)
notifier.onPeerCountChanged(0, isAppInBackground = true)
advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS)
runCurrent()
assertNull(
shadowOf(systemNotificationManager).getNotification(
@ -190,16 +250,13 @@ class PeerAvailabilityNotifierTest {
}
@Test
fun `explicit clear cancels availability notification`() {
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = tracker(),
textProvider = testTextProvider(),
canPostNotifications = { true }
)
fun `explicit clear cancels pending availability notification`() = runTest {
val notifier = createNotifier(scope = this)
notifier.onPeerCountChanged(1, isAppInBackground = true)
notifier.clear()
advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS)
runCurrent()
assertNull(
shadowOf(systemNotificationManager).getNotification(
@ -209,16 +266,17 @@ class PeerAvailabilityNotifierTest {
}
@Test
fun `disabled notifications do not post`() {
fun `disabled notifications do not post`() = runTest {
val history = InMemoryAlertHistory()
val notifier = PeerAvailabilityNotifier(
context = context,
tracker = PeerAvailabilityTracker(history),
textProvider = testTextProvider(),
val notifier = createNotifier(
scope = this,
availabilityTracker = PeerAvailabilityTracker(history),
canPostNotifications = { false }
)
notifier.onPeerCountChanged(1, isAppInBackground = true)
advanceTimeBy(PeerAvailabilityNotifier.AGGREGATION_WINDOW_MS)
runCurrent()
assertNull(
shadowOf(systemNotificationManager).getNotification(
@ -243,6 +301,23 @@ class PeerAvailabilityNotifierTest {
return PeerAvailabilityTracker(InMemoryAlertHistory())
}
private fun createNotifier(
scope: CoroutineScope,
availabilityTracker: PeerAvailabilityTracker = tracker(),
textProvider: PeerAvailabilityTextProvider = testTextProvider(),
isAppCurrentlyInBackground: () -> Boolean = { true },
canPostNotifications: () -> Boolean = { true }
): PeerAvailabilityNotifier {
return PeerAvailabilityNotifier(
context = context,
scope = scope,
tracker = availabilityTracker,
textProvider = textProvider,
isAppCurrentlyInBackground = isAppCurrentlyInBackground,
canPostNotifications = canPostNotifications
)
}
private fun testTextProvider(): PeerAvailabilityTextProvider {
return object : PeerAvailabilityTextProvider {
override fun title(): String = "Bitchatters nearby"

204
docs/LIVE_VOICE_V1.md Normal file
View File

@ -0,0 +1,204 @@
# Live voice v1 wire protocol
This document defines the interoperable live push-to-talk protocol used by
Android, Wear OS, and iOS clients. Live packets are ephemeral and best-effort;
the ordinary voice note sent when the user releases the microphone remains the
reliable, persistent fallback.
All multi-byte integers in this document use network byte order (big-endian).
## Type assignments
| Layer | Name | Value | Purpose |
| --- | --- | --- | --- |
| Outer mesh packet | `MessageType.VOICE_FRAME` | `0x29` | Signed public-mesh burst packet |
| Noise inner payload | `NoisePayloadType.VOICE_FRAME` | `0x08` | Encrypted direct-message burst packet |
| Outer private packet | `MessageType.NOISE_ENCRYPTED` | `0x11` | Existing recipient-directed Noise envelope |
These values are canonical. Implementations must not emit live voice under a
different outer or inner type.
## Transport envelopes
### Public mesh
The sender emits a normal mesh packet with:
- version `1`;
- type `VOICE_FRAME (0x29)`;
- a broadcast recipient (either no recipient field or the eight-byte all-`FF`
broadcast value; receivers accept both encodings);
- the encoded `VoiceBurstPacket` as its payload;
- origin TTL `7`; and
- an Ed25519 signature over the canonical outer packet signing bytes.
Receivers accept public live voice only from a known peer whose announcement
has established a verified nickname and signing key. They must reject an
unsigned packet, an invalid signature, a non-broadcast recipient, or a packet
whose timestamp is more than 30 seconds from the receiver's current time. A
rejected public voice packet must not be relayed.
Public live voice is not added to gossip sync. Older clients ignore the unknown
`0x29` type and therefore remain compatible.
### Direct message
The sender requires an established Noise session. It encodes:
```text
[NoisePayloadType.VOICE_FRAME: 0x08][VoiceBurstPacket]
```
and encrypts that value in a recipient-directed `NOISE_ENCRYPTED (0x11)` mesh
packet. The existing Noise session provides confidentiality and authentication.
Intermediate relays see only the ordinary Noise envelope and route it according
to the existing directed-packet rules.
There are no delivery acknowledgements or retransmissions for live packets.
If no established session exists when recording begins, the client records and
sends only the ordinary voice note.
## `VoiceBurstPacket`
Every packet in one press-and-hold gesture shares an opaque, randomly generated
eight-byte burst ID.
```text
+------------+-------------+-------------+------------------+
| burstID | sequence | flags | payload |
| 8 bytes | UInt16 BE | UInt8 | variant-specific |
+------------+-------------+-------------+------------------+
```
The fixed header is 11 bytes. `flags` is a complete discriminator, not a
bitset that may combine values. Unknown or combined flag values are invalid.
### START (`flags = 0x01`)
```text
[codec: UInt8]
```
Current senders use sequence `0`. The only v1 codec is:
| Codec | Value | Encoded frames |
| --- | --- | --- |
| AAC-LC, 16 kHz, mono | `0x01` | Raw AAC access units without ADTS headers |
Receivers must reject unsupported codec values. Because START can be lost on a
best-effort mesh, a receiver may establish a v1 assembly from a valid data
packet and use the sole v1 codec.
### Data (`flags = 0x00`)
The payload contains one to eight length-prefixed AAC access units:
```text
[length: UInt16 BE][AAC access unit] ...
```
Each length must be non-zero and must not extend past the packet. A sender uses
sequence `1` for the first data packet and increments the value for each later
data packet. The sequence counts data packets, not individual AAC frames.
The v1 sender budget is 210 bytes for the entire `VoiceBurstPacket`. This keeps
one live packet below the transport's fragmentation threshold after the Noise
type byte, authentication tag, and BLE padding are applied. An encoded frame
that cannot fit this budget is dropped rather than fragmented.
### END (`flags = 0x02`)
```text
[totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
```
`totalDataPackets` is the number of data packets emitted for the burst; it does
not include START or END. `durationMs` describes the encoded audio duration.
The END packet uses the next sequence value after the final data packet.
Receivers use the total to account for tail loss before finalizing playback.
### CANCELED (`flags = 0x04`)
CANCELED has no payload and uses the next sequence value. A receiver stops live
playback, discards buffered audio, removes the transient message, and does not
wait for a finalized voice note.
## Sender lifecycle
A successful live gesture follows this order:
1. Generate one eight-byte burst ID.
2. Emit START at sequence `0` after the first encoded AAC access unit becomes
available.
3. Emit data packets starting at sequence `1`.
4. On release, flush pending data and emit END.
5. Send the finalized M4A through the existing voice-note transfer path.
The finalized file name is `voice_<burst-id-hex>.m4a`, where the hexadecimal
component is the 16 lowercase characters representing the same eight-byte
burst ID. This lets the receiver replace the transient live capture with the
reliable final voice note without adding another wire field.
If capture is canceled or does not produce a valid recording, the sender emits
CANCELED and does not send a final note.
AAC-LC at 16 kHz uses 1,024 samples per access unit, so each encoded frame
represents 64 ms of audio. Live access units are ADTS-less; receivers may add an
ADTS header locally for streaming playback or temporary-file assembly.
## Receiver safety and ordering
Receivers must fail closed on malformed framing and must bound concurrent
assemblies, buffered out-of-order packets, bytes per burst, and inbound byte
rate. The Android/Wear OS v1 bounds are:
- 8 concurrent assemblies;
- 384 KiB per burst;
- 6,000 inbound bytes per second per assembly, with a two-second initial
allowance; and
- 128 buffered out-of-order packets.
Duplicate and already-delivered sequence values are ignored. Android/Wear OS
wait 550 ms for a sequence gap before skipping it and finalize an idle partial
burst after 3 seconds. These timers are receiver policy rather than additional
wire fields.
## Relay behavior
Public `VOICE_FRAME` packets use the normal mesh relay path only after signature,
sender, timestamp, recipient, and burst validation succeeds. Relays add a small
8–25 ms jitter. In a mesh larger than six peers, the relayed TTL is capped at
five after the normal per-hop decrement. Live packets remain excluded from
gossip sync and file-transfer retransmission.
Private frames retain the existing `NOISE_ENCRYPTED` relay behavior because an
intermediate node cannot inspect the `0x08` inner type.
## Golden vectors
For burst ID `01 02 03 04 05 06 07 08`, the canonical encodings are:
```text
# START, sequence 0, AAC-LC/16 kHz/mono
01 02 03 04 05 06 07 08 00 00 01 01
# One data packet, sequence 1, one three-byte frame DE AD BE
01 02 03 04 05 06 07 08 00 01 00 00 03 DE AD BE
# END, sequence 42, 41 data packets, duration 2688 ms
01 02 03 04 05 06 07 08 00 2A 02 00 29 00 00 0A 80
# CANCELED, sequence 3
01 02 03 04 05 06 07 08 00 03 04
```
The corresponding executable Android vectors live in
`VoiceBurstPacketTest.encodedGoldenVectorsMatchIos`.
## Implementation references
- `app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt`
- `app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt`
- `app/src/main/java/com/bitchat/android/features/voice/VoiceBurstPacket.kt`
- `app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt`
- `app/src/test/java/com/bitchat/android/features/voice/VoiceBurstPacketTest.kt`

View File

@ -14,7 +14,7 @@ The remaining implementation work and milestone progress are tracked in
|---|---|---|
| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` |
| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` |
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `FragmentManagerTest` |
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` |
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |

View File

@ -248,7 +248,7 @@ command and stop the local relay/Tor fixture.
For day-to-day development there is a lighter-weight harness that drives a
debug-only broadcast receiver (`app/src/debug/`, never shipped in release)
exposing mesh operations over ADB: scan, connect, Noise handshake, DMs,
public broadcast, announce, file send/receive, BLE toggle, state dumps, and
public broadcast, live push-to-talk, announce, file send/receive, BLE toggle, state dumps, and
raw packet injection. Results are JSON files in the app sandbox polled by the
host (`cache/testhook/results/<id>.json`, also logged under tag `TestHook`).
@ -298,6 +298,8 @@ python3 tools/release_gate/mesh_lab.py scenario all \
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
| `broadcast` | public mesh message A→B |
| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption |
| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions |
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
| `file_oversize` | >256-fragment broadcast file is rejected sender-side, receiver sees nothing |
| `file_private` | Noise-encrypted private file, digest match |
@ -326,7 +328,8 @@ See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`,
`favorite_status`, `verification_set`, `verification_status`, `file_send`,
`file_recv`, `file_cancel`, `raw_send`, `ble`, `state`, `clear_results`).
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`,
`clear_results`).
### Troubleshooting

Binary file not shown.

Before

Width:  |  Height:  |  Size: 919 KiB

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -532,6 +532,96 @@ def scenario_broadcast(a: Device, b: Device) -> dict:
return {"send": send_result, "recv": recv_result}
def _ptt_one_way(
sender: Device,
receiver: Device,
sender_id: str,
receiver_id: str,
scope: str,
) -> dict:
# Long enough to expose sustained GATT/codec backpressure while remaining a quick gate.
send_args: dict[str, object] = {"duration_ms": 3_000}
if scope == "dm":
send_args["peer"] = receiver_id
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(
receiver.cmd_ok,
"ptt_recv",
240_000,
peer=sender_id,
scope="public" if scope == "public" else "dm",
)
time.sleep(2)
send = pool.submit(sender.cmd_ok, "ptt_send", 240_000, **send_args)
recv_result, send_result = recv.result(), send.result()
if not recv_result.get("live_observed") or recv_result.get("frames", 0) <= 0:
raise MeshLabError(f"live {scope} burst was not assembled before fallback: {recv_result}")
if recv_result.get("burst_id") != send_result.get("burst_id"):
raise MeshLabError(
f"live {scope} burst/final-note identity mismatch: send={send_result} recv={recv_result}"
)
encoded = int(send_result.get("encoded_frames", 0))
queued = int(send_result.get("queued_pcm_frames", 0))
sent_packets = int(send_result.get("data_packets", 0))
received_packets = int(recv_result.get("data_packets", 0))
expected_packets = int(recv_result.get("expected_packets", 0))
missing_packets = int(recv_result.get("missing_packets", -1))
received_frames = int(recv_result.get("frames", 0))
if int(send_result.get("dropped_oversize_frames", -1)) != 0:
raise MeshLabError(f"live {scope} encoder produced an unsent AAC frame: {send_result}")
# AAC encoders may emit up to two priming access units in addition to one unit per PCM block.
if queued <= 0 or encoded < queued or encoded > queued + 2 or sent_packets != encoded:
raise MeshLabError(f"live {scope} encoder/packetizer continuity failed: {send_result}")
outbound_packets = int(send_result.get("outbound_packets", 0))
delivered_packets = int(send_result.get("delivered_packets", -1))
if outbound_packets != delivered_packets or outbound_packets != sent_packets + 2:
raise MeshLabError(f"live {scope} network dispatch did not drain in order: {send_result}")
minimum_realtime_pcm_frames = int(3_000 / 64) - 2
if queued < minimum_realtime_pcm_frames:
raise MeshLabError(f"live {scope} capture fell behind real time: {send_result}")
if missing_packets != 0:
raise MeshLabError(f"live {scope} burst contained sequence gaps: {recv_result}")
if not (received_packets == expected_packets == sent_packets and received_frames == encoded):
raise MeshLabError(
f"live {scope} frame counts differ across the physical link: "
f"send={send_result} recv={recv_result}"
)
decoded_samples = int(recv_result.get("decoded_samples", 0))
if decoded_samples < max(1, received_frames - 2) * 1_024:
raise MeshLabError(f"live {scope} decoded PCM is truncated: {recv_result}")
rms = float(recv_result.get("rms", 0.0))
silent_fraction = float(recv_result.get("silent_block_fraction", 1.0))
longest_silent_run = int(recv_result.get("longest_silent_block_run", 999))
crossings_per_second = float(recv_result.get("zero_crossings_per_second", 0.0))
if rms < 0.05 or silent_fraction > 0.10 or longest_silent_run > 2:
raise MeshLabError(f"live {scope} decoded tone is silent or broken up: {recv_result}")
if not 650.0 <= crossings_per_second <= 1_150.0:
raise MeshLabError(f"live {scope} decoded tone continuity is distorted: {recv_result}")
return {"send": send_result, "recv": recv_result}
def scenario_ptt_dm(a: Device, b: Device) -> dict:
"""Gap-free Noise-encrypted PTT tone plus finalized note in both directions."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
a.cmd_ok("handshake", timeout_ms=60_000, peer=id_b)
b.cmd_ok("handshake", timeout_ms=60_000, peer=id_a)
return {
"a_to_b": _ptt_one_way(a, b, id_a, id_b, "dm"),
"b_to_a": _ptt_one_way(b, a, id_b, id_a, "dm"),
}
def scenario_ptt_broadcast(a: Device, b: Device) -> dict:
"""Gap-free signed public PTT tone plus finalized note in both directions."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
return {
"a_to_b": _ptt_one_way(a, b, id_a, id_b, "public"),
"b_to_a": _ptt_one_way(b, a, id_b, id_a, "public"),
}
def scenario_file(
a: Device,
b: Device,
@ -779,6 +869,8 @@ SCENARIOS = {
"dm": scenario_dm,
"favorite_verification": scenario_favorite_verification,
"broadcast": scenario_broadcast,
"ptt_dm": scenario_ptt_dm,
"ptt_broadcast": scenario_ptt_broadcast,
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
# the small fixture is end-to-end receivable.
"file": lambda a, b: scenario_file(
@ -807,6 +899,8 @@ WATCH_SCENARIOS = [
"dm",
"favorite_verification",
"broadcast",
"ptt_dm",
"ptt_broadcast",
"raw",
"file",
"file_private",

View File

@ -0,0 +1,142 @@
package com.bitchat.watch.testhook
import android.media.MediaCodec
import android.media.MediaExtractor
import java.io.File
import java.nio.ByteOrder
import kotlin.math.sqrt
internal data class PttTestAudioAnalysis(
val decodedSamples: Long,
val rms: Double,
val silentBlockFraction: Double,
val longestSilentBlockRun: Int,
val zeroCrossingsPerSecond: Double
)
/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */
internal object PttTestAudioAnalyzer {
private const val BLOCK_SAMPLES = 1_024
private const val SILENT_BLOCK_RMS = 0.015
private const val SAMPLE_RATE = 16_000.0
fun analyze(file: File): PttTestAudioAnalysis {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
try {
extractor.setDataSource(file.absolutePath)
val track = (0 until extractor.trackCount).firstOrNull { index ->
extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true
} ?: error("received live stream has no audio track")
extractor.selectTrack(track)
val format = extractor.getTrackFormat(track)
val mime = format.getString("mime") ?: error("received live stream has no audio MIME")
val activeDecoder = MediaCodec.createDecoderByType(mime).apply {
configure(format, null, null, 0)
start()
}
decoder = activeDecoder
val info = MediaCodec.BufferInfo()
var inputEnded = false
var outputEnded = false
var idlePolls = 0
var decodedSamples = 0L
var sumSquares = 0.0
var zeroCrossings = 0L
var previousSample: Short? = null
var blockSquares = 0.0
var blockSamples = 0
var blocks = 0
var silentBlocks = 0
var silentRun = 0
var longestSilentRun = 0
while (!outputEnded && idlePolls < 500) {
if (!inputEnded) {
val inputIndex = activeDecoder.dequeueInputBuffer(10_000L)
if (inputIndex >= 0) {
val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input")
input.clear()
val size = extractor.readSampleData(input, 0)
if (size < 0) {
activeDecoder.queueInputBuffer(
inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
} else {
activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0
else -> if (outputIndex >= 0) {
idlePolls = 0
activeDecoder.getOutputBuffer(outputIndex)?.let { output ->
output.position(info.offset)
output.limit(info.offset + info.size)
output.order(ByteOrder.LITTLE_ENDIAN)
val pcm = output.asShortBuffer()
while (pcm.hasRemaining()) {
val sample = pcm.get()
val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble()
val square = normalized * normalized
sumSquares += square
blockSquares += square
decodedSamples++
blockSamples++
previousSample?.let { previous ->
if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) {
zeroCrossings++
}
}
previousSample = sample
if (blockSamples == BLOCK_SAMPLES) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
} else {
silentRun = 0
}
blockSquares = 0.0
blockSamples = 0
}
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
activeDecoder.releaseOutputBuffer(outputIndex, false)
}
}
}
if (!outputEnded) error("received live stream decoder did not finish")
if (decodedSamples == 0L) error("received live stream decoded to no PCM")
if (blockSamples > 0) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
}
}
return PttTestAudioAnalysis(
decodedSamples = decodedSamples,
rms = sqrt(sumSquares / decodedSamples),
silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks,
longestSilentBlockRun = longestSilentRun,
zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples
)
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
extractor.release()
}
}
}

View File

@ -4,6 +4,13 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.features.voice.LiveVoiceEvent
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceScope
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.LiveVoiceCapture
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.service.TransportBridgeService
@ -64,6 +71,8 @@ object WearTestHookDriver {
"verification_status" -> verificationStatus(context, intent.requiredString("peer"))
"raw_send" -> rawSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"ptt_send" -> pttSend(context, intent)
"state" -> state(context)
"clear_results" -> clearResults(context)
else -> err(cmd, "unknown command: $cmd")
@ -380,6 +389,104 @@ object WearTestHookDriver {
return err("file_recv", "timeout after ${timeoutMs}ms")
}
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", 180_000L)
val fromPeer = intent.getStringExtra("peer")
val expectedScope = if (intent.getStringExtra("scope") == "public") {
LiveVoiceScope.PUBLIC_MESH
} else {
LiveVoiceScope.DIRECT_MESSAGE
}
LiveVoicePreferences.setEnabled(context, true)
var finished: LiveVoiceEvent.Finished? = null
var liveSnapshot: File? = null
val absorbed = withTimeoutOrNull(timeoutMs) {
LiveVoiceManager.getInstance(context).events.first { event ->
val matches = event.scope == expectedScope &&
(fromPeer == null || event.peerID == fromPeer)
if (matches && event is LiveVoiceEvent.Finished) {
finished = event
liveSnapshot = runCatching {
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
snapshot.parentFile?.mkdirs()
File(event.path).copyTo(snapshot, overwrite = true)
}
}.getOrNull()
}
matches && event is LiveVoiceEvent.Absorbed
} as LiveVoiceEvent.Absorbed
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
val analysis = liveSnapshot?.let { snapshot ->
try {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
PttTestAudioAnalyzer.analyze(snapshot)
}
} finally {
snapshot.delete()
}
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
return ok("ptt_recv")
.put("live_observed", finished != null)
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
.put("from", absorbed.peerID)
.put("burst_id", absorbed.burstID)
.put("frames", finished?.frames ?: 0)
.put("data_packets", finished?.dataPackets ?: 0)
.put("bytes", finished?.bytes ?: 0)
.put("expected_packets", finished?.expectedPackets ?: 0)
.put("missing_packets", finished?.missingPackets ?: 0)
.put("decoded_samples", analysis.decodedSamples)
.put("rms", analysis.rms)
.put("silent_block_fraction", analysis.silentBlockFraction)
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
}
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
val peerID = intent.getStringExtra("peer")
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
val mesh = mesh(context)
LiveVoicePreferences.setEnabled(context, true)
if (peerID != null && !mesh.hasEstablishedSession(peerID)) {
val handshake = handshake(context, peerID, intent)
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
}
val recorder = LiveVoiceCapture(
File(context.filesDir, "voicenotes/outgoing"),
LiveVoiceTarget { payload -> mesh.sendVoiceFrame(peerID, payload) },
syntheticPcm = true
)
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
delay(durationMs)
val finalFile = recorder.stop(canceled = false)
?: return err("ptt_send", "capture did not produce a finalized note")
val captureStats = recorder.stats()
if (finalFile != pendingFile || !finalFile.isFile) {
return err("ptt_send", "finalized note is unavailable")
}
val content = finalFile.readBytes()
val packet = BitchatFilePacket(
fileName = finalFile.name,
fileSize = content.size.toLong(),
mimeType = "audio/mp4",
content = content
)
if (peerID == null) mesh.sendFileBroadcast(packet)
else mesh.sendFilePrivateEncrypted(peerID, packet)
return ok("ptt_send")
.put("live", true)
.put("scope", if (peerID == null) "public" else "dm")
.put("duration_ms", durationMs)
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
.put("bytes", content.size)
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
.put("encoded_frames", captureStats.encodedFrames)
.put("data_packets", captureStats.dataPackets)
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
.put("outbound_packets", captureStats.outboundPackets)
.put("delivered_packets", captureStats.deliveredPackets)
}
// MARK: - State
private fun state(context: Context): JSONObject {

View File

@ -137,6 +137,8 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(applicationContext)
.setAppForeground(true)
WearChatState.setAppInForeground(true)
WearChatState.openDmPeer?.let { peerID ->
WearChatState.openDm(peerID)
@ -146,6 +148,8 @@ class MainActivity : ComponentActivity() {
}
override fun onPause() {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(applicationContext)
.setAppForeground(false)
WearChatState.setAppInForeground(false)
super.onPause()
}

View File

@ -373,6 +373,10 @@ class WearMeshService private constructor(private val context: Context) {
meshCore.sendFileBroadcast(file)
}
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
meshCore.sendVoiceFrame(recipientPeerID, payload)
}
/**
* Noise-encrypted private file transfer with session/prep retry (mirrors the phone's
* dispatchFileSend): ensures an established session, then retries transient

View File

@ -54,7 +54,12 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette
* level so [VoiceRecordOverlay] can render full-screen outside this slot.
*/
@Composable
fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: Modifier = Modifier) {
fun ChatActionBar(
onKeyboard: () -> Unit,
voice: VoiceNoteController,
busyTalker: String? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val palette = LocalBitchatPalette.current
@ -87,7 +92,11 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier:
.size(38.dp)
.clip(CircleShape)
.background(
if (voice.recording) MaterialTheme.colorScheme.primary else palette.inputButton
when {
voice.recording -> MaterialTheme.colorScheme.primary
busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300).copy(alpha = 0.28f)
else -> palette.inputButton
}
)
.pointerInput(Unit) {
detectTapGestures(
@ -115,8 +124,11 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier:
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = "push to talk",
tint = if (voice.recording) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.primary,
tint = when {
voice.recording -> MaterialTheme.colorScheme.onPrimary
busyTalker != null -> androidx.compose.ui.graphics.Color(0xFFFFB300)
else -> MaterialTheme.colorScheme.primary
},
modifier = Modifier.size(18.dp)
)
}
@ -226,7 +238,7 @@ fun VoiceRecordOverlay(
.height(44.dp)
)
Text(
text = "%d:%02d".format(
text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60
) + " / 0:10",

View File

@ -42,6 +42,8 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.watch.ui.theme.BitchatMotion
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
/**
* The shared chat body for global chat and DM threads, following the classic messenger
@ -106,11 +108,31 @@ fun ChatScaffold(
}
// Stick to bottom: follow new messages while resting at the newest.
// Capture this when the message count changes, before the new layout can temporarily make
// canScrollForward true and report that the user is browsing history.
val followNewest = remember(messages.size) { atNewest }
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && atNewest) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
columnState.scroll { scrollBy(Float.MAX_VALUE) }
if (messages.isNotEmpty() && followNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
expectedSingleMessageKey = expectedSingleMessageKey,
measuredLayouts = snapshotFlow {
val layoutInfo = columnState.layoutInfo
MeasuredChatLayout(
itemCount = layoutInfo.totalItemsCount,
singleVisibleItemKey = if (expectedSingleMessageKey != null) {
layoutInfo.visibleItems.singleOrNull()?.key
} else {
null
}
)
}
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
}
@ -128,6 +150,25 @@ fun ChatScaffold(
)
}
internal data class MeasuredChatLayout(
val itemCount: Int,
val singleVisibleItemKey: Any?
)
internal suspend fun scrollToNewestAfterItemsMeasured(
expectedItemCount: Int,
expectedSingleMessageKey: Any?,
measuredLayouts: Flow<MeasuredChatLayout>,
scrollToEnd: suspend () -> Unit
) {
measuredLayouts.first { layout ->
layout.itemCount >= expectedItemCount &&
(expectedSingleMessageKey == null ||
layout.singleVisibleItemKey == expectedSingleMessageKey)
}
scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,

View File

@ -27,6 +27,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
import androidx.wear.compose.foundation.rotary.rotaryScrollable
@ -59,14 +60,33 @@ import java.util.Locale
@Composable
fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
val context = LocalContext.current
val messages by AppStateStore.publicMessages.collectAsState()
val peers by AppStateStore.peers.collectAsState()
val unreadDms by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
val myPeerID = mesh?.myPeerID ?: ""
var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path ->
mesh?.let { sendVoiceNote(it, null, path) }
val liveVoiceManager = remember(context) {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context)
}
val busyTalker by liveVoiceManager.activePublicTalker.collectAsState()
val voice = rememberVoiceNoteController(
recorderFactory = {
val target = if (
mesh != null &&
com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context) &&
mesh.getPeerNicknames().isNotEmpty()
) com.bitchat.android.features.voice.LiveVoiceTarget { payload ->
mesh.sendVoiceFrame(null, payload)
} else null
com.bitchat.android.features.voice.VoiceRecorder(context, target)
}
) { path -> mesh?.let { sendVoiceNote(it, null, path) } }
androidx.compose.runtime.DisposableEffect(liveVoiceManager) {
liveVoiceManager.showPublicMesh()
onDispose { liveVoiceManager.clearVisibleConversation() }
}
ChatScaffold(
@ -84,7 +104,7 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
)
},
actionBar = {
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice)
ChatActionBar(onKeyboard = onOpenTextInput, voice = voice, busyTalker = busyTalker)
}
)
@ -230,7 +250,10 @@ fun MessageItem(
path = message.content.trim(),
onOpen = onOpenImage
)
BitchatMessageType.Audio -> VoiceNoteItem(path = message.content.trim())
BitchatMessageType.Audio -> VoiceNoteItem(
path = message.content.trim(),
messageID = message.id
)
BitchatMessageType.File -> {
val path = message.content.trim()
val file = remember(path) { File(path) }

View File

@ -60,9 +60,20 @@ fun DmScreen(
val myPeerID = mesh?.myPeerID ?: ""
val palette = LocalBitchatPalette.current
var viewerPath by remember { mutableStateOf<String?>(null) }
val voice = rememberVoiceNoteController { path ->
mesh?.let { sendVoiceNote(it, peerID, path) }
val liveVoiceManager = remember(context) {
com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context)
}
val voice = rememberVoiceNoteController(
recorderFactory = {
val target = if (
mesh != null && mesh.hasEstablishedSession(peerID) &&
com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)
) com.bitchat.android.features.voice.LiveVoiceTarget { payload ->
mesh.sendVoiceFrame(peerID, payload)
} else null
com.bitchat.android.features.voice.VoiceRecorder(context, target)
}
) { path -> mesh?.let { sendVoiceNote(it, peerID, path) } }
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
val identityRevision by WearPeerIdentityState.revision.collectAsState()
@ -74,9 +85,13 @@ fun DmScreen(
}
DisposableEffect(peerID) {
liveVoiceManager.showDirectMessage(peerID)
WearChatState.openDm(peerID)
WearNotificationCoordinator.getInstance(context).clearConversation(peerID)
onDispose { WearChatState.closeDm() }
onDispose {
WearChatState.closeDm()
liveVoiceManager.clearVisibleConversation()
}
}
LaunchedEffect(peerID) {

View File

@ -13,6 +13,10 @@ import androidx.compose.material.icons.filled.Verified
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -38,6 +42,7 @@ import com.bitchat.watch.ui.theme.colorForPeer
@Composable
fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
val context = LocalContext.current
val peers by AppStateStore.peers.collectAsState()
val unread by WearChatState.unreadDms.collectAsState()
val mesh = WearMeshService.peek()
@ -45,6 +50,9 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
val palette = LocalBitchatPalette.current
val nicknames = mesh?.getPeerNicknames() ?: emptyMap()
val identityRevision by WearPeerIdentityState.revision.collectAsState()
var liveVoiceEnabled by remember {
mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context))
}
// Peers with unread messages float to the top so they are easy to see and reach.
val sortedPeers = androidx.compose.runtime.remember(
@ -92,6 +100,29 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
onClick = onEditNickname
)
}
item(key = "live_voice") {
Card(
onClick = {
liveVoiceEnabled = !liveVoiceEnabled
com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(
context,
liveVoiceEnabled
)
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp)
) {
Text(
text = if (liveVoiceEnabled) "Live push-to-talk: on" else "Live push-to-talk: off",
style = ChatVisualTokens.SenderStyle,
color = if (liveVoiceEnabled) MaterialTheme.colorScheme.primary else palette.textTertiary
)
Text(
text = "Tap to toggle; a voice note is still sent on release",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)
}
}
items(sortedPeers, key = { it }) { peerID ->
val nick = nicknames[peerID] ?: peerID.take(8)
val identity = WearPeerIdentityState.snapshot(peerID, mesh)

View File

@ -29,12 +29,15 @@ private const val LIVE_BARS = 32
class VoiceNoteController(
private val context: Context,
private val scope: CoroutineScope,
private val recorderFactory: () -> VoiceRecorder,
private val onSendVoice: (String) -> Unit
) {
private val recorder = VoiceRecorder(context.applicationContext)
private var recorder: VoiceRecorder? = null
var recording by mutableStateOf(false)
private set
var isLive by mutableStateOf(false)
private set
var elapsedMs by mutableLongStateOf(0L)
private set
var liveSamples by mutableStateOf(FloatArray(LIVE_BARS))
@ -45,7 +48,10 @@ class VoiceNoteController(
fun start() {
if (recording) return
recorder.start() ?: return
val activeRecorder = recorderFactory()
activeRecorder.start() ?: return
recorder = activeRecorder
isLive = activeRecorder.isLive
startedAt = System.currentTimeMillis()
elapsedMs = 0L
liveSamples = FloatArray(LIVE_BARS)
@ -54,7 +60,7 @@ class VoiceNoteController(
pollJob = scope.launch {
while (true) {
delay(AMPLITUDE_POLL_MS)
val amp = normalizeAmplitudeSample(recorder.pollAmplitude())
val amp = normalizeAmplitudeSample(recorder?.pollAmplitude() ?: 0)
liveSamples = liveSamples.copyOfRange(1, LIVE_BARS) + amp
val elapsed = System.currentTimeMillis() - startedAt
elapsedMs = elapsed
@ -74,7 +80,9 @@ class VoiceNoteController(
if (send) WearHaptics.click(context)
pollJob?.cancel()
pollJob = null
val file = recorder.stop()
val file = recorder?.stop(canceled = !send)
recorder = null
isLive = false
val elapsed = System.currentTimeMillis() - startedAt
if (send && file != null && elapsed >= MIN_RECORDING_MS) {
onSendVoice(file.absolutePath)
@ -85,8 +93,11 @@ class VoiceNoteController(
}
@Composable
fun rememberVoiceNoteController(onSendVoice: (String) -> Unit): VoiceNoteController {
fun rememberVoiceNoteController(
recorderFactory: () -> VoiceRecorder,
onSendVoice: (String) -> Unit
): VoiceNoteController {
val context = LocalContext.current
val scope = rememberCoroutineScope()
return remember { VoiceNoteController(context, scope, onSendVoice) }
return remember { VoiceNoteController(context, scope, recorderFactory, onSendVoice) }
}

View File

@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -41,6 +42,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -124,8 +126,12 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
* duration/progress. Playback via MediaPlayer.
*/
@Composable
fun VoiceNoteItem(path: String) {
fun VoiceNoteItem(path: String, messageID: String? = null) {
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val liveIDs by com.bitchat.android.features.voice.LiveVoiceManager
.getInstance(context).liveMessageIDs.collectAsState()
val isLive = messageID != null && messageID in liveIDs
var samples by remember { mutableStateOf(VoiceWaveformCache.get(path)) }
var playing by remember { mutableStateOf(false) }
var progress by remember { mutableFloatStateOf(0f) }
@ -175,6 +181,14 @@ fun VoiceNoteItem(path: String) {
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isLive) {
Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = ChatVisualTokens.SystemActionStyle,
modifier = Modifier.padding(end = 4.dp)
)
}
Box(
modifier = Modifier
.size(26.dp)

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M-0.2617076 -0.1145289H135.7284V135.5812H-0.2617076V-0.1145289Z"
android:fillColor="#000000" />
android:fillColor="#FFFFFFFF"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@ -1,15 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
BitChatIcon glyph (About button) in app primary green (#32D74B),
with pixel "BIT" in black. Sized to keep clear padding inside the circle.
-->
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
android:translateX="26.8"
android:translateY="27"
android:scaleX="6.8"
android:scaleY="6.8">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#FFFFFF" />
android:fillColor="#FF32D74B"
android:pathData="M2,0L6,0L6,1L7,1L7,2L8,2L8,5L7,5L7,6L6,6L6,8L5,8L5,7L3,7L3,6L1,6L1,5L0,5L0,2L1,2L1,1L2,1Z" />
<path
android:fillColor="#FF000000"
android:pathData="M1.8,1.95h0.4v0.4h-0.4zM2.2,1.95h0.4v0.4h-0.4zM1.8,2.35h0.4v0.4h-0.4zM2.6,2.35h0.4v0.4h-0.4zM1.8,2.75h0.4v0.4h-0.4zM2.2,2.75h0.4v0.4h-0.4zM1.8,3.15h0.4v0.4h-0.4zM2.6,3.15h0.4v0.4h-0.4zM1.8,3.55h0.4v0.4h-0.4zM2.2,3.55h0.4v0.4h-0.4zM3.4,1.95h0.4v0.4h-0.4zM3.8,1.95h0.4v0.4h-0.4zM4.2,1.95h0.4v0.4h-0.4zM3.8,2.35h0.4v0.4h-0.4zM3.8,2.75h0.4v0.4h-0.4zM3.8,3.15h0.4v0.4h-0.4zM3.4,3.55h0.4v0.4h-0.4zM3.8,3.55h0.4v0.4h-0.4zM4.2,3.55h0.4v0.4h-0.4zM5,1.95h0.4v0.4h-0.4zM5.4,1.95h0.4v0.4h-0.4zM5.8,1.95h0.4v0.4h-0.4zM5.4,2.35h0.4v0.4h-0.4zM5.4,2.75h0.4v0.4h-0.4zM5.4,3.15h0.4v0.4h-0.4zM5.4,3.55h0.4v0.4h-0.4z" />
</group>
</vector>

View File

@ -1,16 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:name="vector"
android:viewportWidth="135.4667"
android:viewportHeight="135.4667"
android:width="108dp"
android:height="108dp">
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.2645833"
android:scaleY="0.2645833"
android:translateX="-1.0877"
android:translateY="47.11238">
android:translateX="26.8"
android:translateY="27"
android:scaleX="6.8"
android:scaleY="6.8">
<path
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
android:fillColor="#000000" />
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M2,0L6,0L6,1L7,1L7,2L8,2L8,5L7,5L7,6L6,6L6,8L5,8L5,7L3,7L3,6L1,6L1,5L0,5L0,2L1,2L1,1L2,1ZM1.8,1.95h0.4v0.4h-0.4zM2.2,1.95h0.4v0.4h-0.4zM1.8,2.35h0.4v0.4h-0.4zM2.6,2.35h0.4v0.4h-0.4zM1.8,2.75h0.4v0.4h-0.4zM2.2,2.75h0.4v0.4h-0.4zM1.8,3.15h0.4v0.4h-0.4zM2.6,3.15h0.4v0.4h-0.4zM1.8,3.55h0.4v0.4h-0.4zM2.2,3.55h0.4v0.4h-0.4zM3.4,1.95h0.4v0.4h-0.4zM3.8,1.95h0.4v0.4h-0.4zM4.2,1.95h0.4v0.4h-0.4zM3.8,2.35h0.4v0.4h-0.4zM3.8,2.75h0.4v0.4h-0.4zM3.8,3.15h0.4v0.4h-0.4zM3.4,3.55h0.4v0.4h-0.4zM3.8,3.55h0.4v0.4h-0.4zM4.2,3.55h0.4v0.4h-0.4zM5,1.95h0.4v0.4h-0.4zM5.4,1.95h0.4v0.4h-0.4zM5.8,1.95h0.4v0.4h-0.4zM5.4,2.35h0.4v0.4h-0.4zM5.4,2.75h0.4v0.4h-0.4zM5.4,3.15h0.4v0.4h-0.4zM5.4,3.55h0.4v0.4h-0.4z" />
</group>
</vector>

View File

@ -0,0 +1,82 @@
package com.bitchat.watch.ui
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class ChatAutoScrollTest {
@Test
fun `newest scroll waits until appended message is measured`() = runTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value = MeasuredChatLayout(4, null)
scrollJob.join()
assertEquals(1, scrollCount)
}
@Test
fun `newest scroll runs immediately when messages are already measured`() = runTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(4, null))
var scrollCount = 0
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
assertEquals(1, scrollCount)
}
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value = MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey
)
scrollJob.join()
assertEquals(1, scrollCount)
}
}