mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Compare commits
43 Commits
a41fbf331d
...
aaa70f3f27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa70f3f27 | ||
|
|
094657efa0 | ||
|
|
1376b55931 | ||
|
|
d852f3dc3a | ||
|
|
94e46da234 | ||
|
|
49753ccb88 | ||
|
|
bc572cc2ea | ||
|
|
c02dda308e | ||
|
|
a49f38216a | ||
|
|
2325b7c9c3 | ||
|
|
b8c7470ace | ||
|
|
28bd6bdcfd | ||
|
|
dcec0c10ec | ||
|
|
17a173a685 | ||
|
|
b43dc1e48f | ||
|
|
53cd03886c | ||
|
|
5cc181f1e2 | ||
|
|
1965d4bd7e | ||
|
|
2e943b2ebc | ||
|
|
aad0c4d7f8 | ||
|
|
bd5ae8702a | ||
|
|
d85401fb49 | ||
|
|
79d2e0328a | ||
|
|
6d9dfd970c | ||
|
|
d0c2a39140 | ||
|
|
498c35188f | ||
|
|
6eafa5932d | ||
|
|
7247d0ad5b | ||
|
|
af91abab01 | ||
|
|
7569fba7b4 | ||
|
|
862fd8449d | ||
|
|
94d5fa3084 | ||
|
|
e07a38f634 | ||
|
|
e907773b5f | ||
|
|
e9bc8e0f31 | ||
|
|
44b2b8cb79 | ||
|
|
efeb9b54b5 | ||
|
|
cbc59aaf8b | ||
|
|
e62e3bd1ea | ||
|
|
6cd5d1687e | ||
|
|
a97cc1782a | ||
|
|
7af6f35e4a | ||
|
|
cbbf0423e2 |
@ -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`.
|
||||
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -0,0 +1,142 @@
|
||||
package com.bitchat.android.testhook
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal data class PttTestAudioAnalysis(
|
||||
val decodedSamples: Long,
|
||||
val rms: Double,
|
||||
val silentBlockFraction: Double,
|
||||
val longestSilentBlockRun: Int,
|
||||
val zeroCrossingsPerSecond: Double
|
||||
)
|
||||
|
||||
/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */
|
||||
internal object PttTestAudioAnalyzer {
|
||||
private const val BLOCK_SAMPLES = 1_024
|
||||
private const val SILENT_BLOCK_RMS = 0.015
|
||||
private const val SAMPLE_RATE = 16_000.0
|
||||
|
||||
fun analyze(file: File): PttTestAudioAnalysis {
|
||||
val extractor = MediaExtractor()
|
||||
var decoder: MediaCodec? = null
|
||||
try {
|
||||
extractor.setDataSource(file.absolutePath)
|
||||
val track = (0 until extractor.trackCount).firstOrNull { index ->
|
||||
extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true
|
||||
} ?: error("received live stream has no audio track")
|
||||
extractor.selectTrack(track)
|
||||
val format = extractor.getTrackFormat(track)
|
||||
val mime = format.getString("mime") ?: error("received live stream has no audio MIME")
|
||||
val activeDecoder = MediaCodec.createDecoderByType(mime).apply {
|
||||
configure(format, null, null, 0)
|
||||
start()
|
||||
}
|
||||
decoder = activeDecoder
|
||||
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var inputEnded = false
|
||||
var outputEnded = false
|
||||
var idlePolls = 0
|
||||
var decodedSamples = 0L
|
||||
var sumSquares = 0.0
|
||||
var zeroCrossings = 0L
|
||||
var previousSample: Short? = null
|
||||
var blockSquares = 0.0
|
||||
var blockSamples = 0
|
||||
var blocks = 0
|
||||
var silentBlocks = 0
|
||||
var silentRun = 0
|
||||
var longestSilentRun = 0
|
||||
|
||||
while (!outputEnded && idlePolls < 500) {
|
||||
if (!inputEnded) {
|
||||
val inputIndex = activeDecoder.dequeueInputBuffer(10_000L)
|
||||
if (inputIndex >= 0) {
|
||||
val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input")
|
||||
input.clear()
|
||||
val size = extractor.readSampleData(input, 0)
|
||||
if (size < 0) {
|
||||
activeDecoder.queueInputBuffer(
|
||||
inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputEnded = true
|
||||
} else {
|
||||
activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0
|
||||
else -> if (outputIndex >= 0) {
|
||||
idlePolls = 0
|
||||
activeDecoder.getOutputBuffer(outputIndex)?.let { output ->
|
||||
output.position(info.offset)
|
||||
output.limit(info.offset + info.size)
|
||||
output.order(ByteOrder.LITTLE_ENDIAN)
|
||||
val pcm = output.asShortBuffer()
|
||||
while (pcm.hasRemaining()) {
|
||||
val sample = pcm.get()
|
||||
val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble()
|
||||
val square = normalized * normalized
|
||||
sumSquares += square
|
||||
blockSquares += square
|
||||
decodedSamples++
|
||||
blockSamples++
|
||||
previousSample?.let { previous ->
|
||||
if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) {
|
||||
zeroCrossings++
|
||||
}
|
||||
}
|
||||
previousSample = sample
|
||||
if (blockSamples == BLOCK_SAMPLES) {
|
||||
val blockRms = sqrt(blockSquares / blockSamples)
|
||||
blocks++
|
||||
if (blockRms < SILENT_BLOCK_RMS) {
|
||||
silentBlocks++
|
||||
silentRun++
|
||||
longestSilentRun = maxOf(longestSilentRun, silentRun)
|
||||
} else {
|
||||
silentRun = 0
|
||||
}
|
||||
blockSquares = 0.0
|
||||
blockSamples = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
activeDecoder.releaseOutputBuffer(outputIndex, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!outputEnded) error("received live stream decoder did not finish")
|
||||
if (decodedSamples == 0L) error("received live stream decoded to no PCM")
|
||||
if (blockSamples > 0) {
|
||||
val blockRms = sqrt(blockSquares / blockSamples)
|
||||
blocks++
|
||||
if (blockRms < SILENT_BLOCK_RMS) {
|
||||
silentBlocks++
|
||||
silentRun++
|
||||
longestSilentRun = maxOf(longestSilentRun, silentRun)
|
||||
}
|
||||
}
|
||||
return PttTestAudioAnalysis(
|
||||
decodedSamples = decodedSamples,
|
||||
rms = sqrt(sumSquares / decodedSamples),
|
||||
silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks,
|
||||
longestSilentBlockRun = longestSilentRun,
|
||||
zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples
|
||||
)
|
||||
} finally {
|
||||
runCatching { decoder?.stop() }
|
||||
runCatching { decoder?.release() }
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,12 @@ import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.features.file.FileUtils
|
||||
import com.bitchat.android.features.voice.LiveVoiceEvent
|
||||
import com.bitchat.android.features.voice.LiveVoiceManager
|
||||
import com.bitchat.android.features.voice.LiveVoicePreferences
|
||||
import com.bitchat.android.features.voice.LiveVoiceScope
|
||||
import com.bitchat.android.features.voice.LiveVoiceTarget
|
||||
import com.bitchat.android.features.voice.LiveVoiceCapture
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.PrivateMediaPreparation
|
||||
@ -80,6 +86,8 @@ object TestHookDriver {
|
||||
"file_send" -> fileSend(context, intent)
|
||||
"file_recv" -> fileRecv(context, intent)
|
||||
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
|
||||
"ptt_send" -> pttSend(context, intent)
|
||||
"ptt_recv" -> pttRecv(context, intent)
|
||||
"raw_send" -> rawSend(context, intent)
|
||||
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
|
||||
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
|
||||
@ -491,6 +499,131 @@ object TestHookDriver {
|
||||
return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled)
|
||||
}
|
||||
|
||||
// MARK: - Live push-to-talk
|
||||
|
||||
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
|
||||
val requestedPeer = intent.getStringExtra("peer")
|
||||
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
|
||||
val mesh = mesh(context)
|
||||
LiveVoicePreferences.setEnabled(context, true)
|
||||
val recipient = requestedPeer?.let {
|
||||
PrivateMediaRecipientResolver.resolve(it, mesh)
|
||||
?: return err("ptt_send", "no active mesh route for private conversation")
|
||||
}
|
||||
if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) {
|
||||
val handshake = handshake(context, recipient.meshPeerID, intent)
|
||||
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
|
||||
}
|
||||
val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) }
|
||||
val recorder = LiveVoiceCapture(
|
||||
File(context.filesDir, "voicenotes/outgoing"),
|
||||
target,
|
||||
syntheticPcm = true
|
||||
)
|
||||
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
|
||||
delay(durationMs)
|
||||
val finalFile = recorder.stop(canceled = false)
|
||||
?: return err("ptt_send", "capture did not produce a finalized note")
|
||||
val captureStats = recorder.stats()
|
||||
if (finalFile != pendingFile || !finalFile.isFile) {
|
||||
return err("ptt_send", "finalized note is unavailable")
|
||||
}
|
||||
val content = withContext(Dispatchers.IO) { finalFile.readBytes() }
|
||||
val packet = BitchatFilePacket(
|
||||
fileName = finalFile.name,
|
||||
fileSize = content.size.toLong(),
|
||||
mimeType = "audio/mp4",
|
||||
content = content
|
||||
)
|
||||
val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note")
|
||||
val transferId = sha256Hex(encoded)
|
||||
return coroutineScope {
|
||||
val completion = async(Dispatchers.Default) {
|
||||
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
|
||||
}
|
||||
delay(50)
|
||||
val sendError = dispatchFileSend(
|
||||
context,
|
||||
intent,
|
||||
mesh,
|
||||
recipient?.meshPeerID,
|
||||
packet,
|
||||
transferId
|
||||
)
|
||||
if (sendError != null) {
|
||||
completion.cancel()
|
||||
return@coroutineScope sendError.put("cmd", "ptt_send")
|
||||
}
|
||||
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
|
||||
?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer")
|
||||
if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed")
|
||||
ok("ptt_send")
|
||||
.put("live", true)
|
||||
.put("scope", if (recipient == null) "public" else "dm")
|
||||
.put("duration_ms", durationMs)
|
||||
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
|
||||
.put("bytes", content.size)
|
||||
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
|
||||
.put("encoded_frames", captureStats.encodedFrames)
|
||||
.put("data_packets", captureStats.dataPackets)
|
||||
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
|
||||
.put("outbound_packets", captureStats.outboundPackets)
|
||||
.put("delivered_packets", captureStats.deliveredPackets)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
|
||||
val fromPeer = intent.getStringExtra("peer")
|
||||
val expectedScope = if (intent.getStringExtra("scope") == "public") {
|
||||
LiveVoiceScope.PUBLIC_MESH
|
||||
} else {
|
||||
LiveVoiceScope.DIRECT_MESSAGE
|
||||
}
|
||||
LiveVoicePreferences.setEnabled(context, true)
|
||||
var finished: LiveVoiceEvent.Finished? = null
|
||||
var liveSnapshot: File? = null
|
||||
val absorbed = withTimeoutOrNull(timeoutMs) {
|
||||
LiveVoiceManager.getInstance(context).events.first { event ->
|
||||
val matches = event.scope == expectedScope &&
|
||||
(fromPeer == null || event.peerID == fromPeer)
|
||||
if (matches && event is LiveVoiceEvent.Finished) {
|
||||
finished = event
|
||||
liveSnapshot = runCatching {
|
||||
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
|
||||
snapshot.parentFile?.mkdirs()
|
||||
File(event.path).copyTo(snapshot, overwrite = true)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
matches && event is LiveVoiceEvent.Absorbed
|
||||
} as LiveVoiceEvent.Absorbed
|
||||
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
|
||||
val analysis = liveSnapshot?.let { snapshot ->
|
||||
try {
|
||||
withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) }
|
||||
} finally {
|
||||
snapshot.delete()
|
||||
}
|
||||
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
|
||||
return ok("ptt_recv")
|
||||
.put("live_observed", finished != null)
|
||||
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
|
||||
.put("from", absorbed.peerID)
|
||||
.put("burst_id", absorbed.burstID)
|
||||
.put("frames", finished?.frames ?: 0)
|
||||
.put("data_packets", finished?.dataPackets ?: 0)
|
||||
.put("bytes", finished?.bytes ?: 0)
|
||||
.put("expected_packets", finished?.expectedPackets ?: 0)
|
||||
.put("missing_packets", finished?.missingPackets ?: 0)
|
||||
.put("decoded_samples", analysis.decodedSamples)
|
||||
.put("rms", analysis.rms)
|
||||
.put("silent_block_fraction", analysis.silentBlockFraction)
|
||||
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
|
||||
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
|
||||
}
|
||||
|
||||
// MARK: - Raw packet injection
|
||||
|
||||
private fun rawSend(context: Context, intent: Intent): JSONObject {
|
||||
|
||||
@ -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
|
||||
|
||||
|
@ -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) { }
|
||||
|
||||
|
||||
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,362 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
/** Destination selected for one hold gesture. Calls must be safe from a capture thread. */
|
||||
fun interface LiveVoiceTarget {
|
||||
fun send(packet: ByteArray)
|
||||
}
|
||||
|
||||
internal data class LiveVoiceCaptureStats(
|
||||
val queuedPcmFrames: Int,
|
||||
val encodedFrames: Int,
|
||||
val dataPackets: Int,
|
||||
val droppedOversizeFrames: Int,
|
||||
val outboundPackets: Int,
|
||||
val deliveredPackets: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* AudioRecord + MediaCodec capture used only when a live mesh route is available.
|
||||
*
|
||||
* Encoded access units are streamed as iOS-compatible burst packets while the same units are
|
||||
* muxed into an ordinary `.m4a`, which the existing voice-note path sends on release.
|
||||
*/
|
||||
internal class LiveVoiceCapture(
|
||||
private val outputDirectory: File,
|
||||
private val target: LiveVoiceTarget,
|
||||
private val burstID: ByteArray = VoiceBurstPacket.makeBurstID(),
|
||||
/** Debug Mesh Lab uses a deterministic tone so physical tests never capture ambient audio. */
|
||||
private val syntheticPcm: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "LiveVoiceCapture"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val CHANNEL_COUNT = 1
|
||||
private const val BIT_RATE = 16_000
|
||||
private const val SAMPLES_PER_AAC_FRAME = 1_024
|
||||
private const val MIN_VALID_DURATION_MS = 600L
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
private const val CODEC_INPUT_DEADLINE_MS = 1_000L
|
||||
private const val SYNTHETIC_TONE_HZ = 440.0
|
||||
private const val SYNTHETIC_TONE_AMPLITUDE = 8_000
|
||||
private const val AAC_FRAME_DURATION_NS = 64_000_000L
|
||||
private const val OUTBOUND_QUEUE_CAPACITY = 256
|
||||
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 10_000L
|
||||
}
|
||||
|
||||
private val running = AtomicBoolean(false)
|
||||
private val amplitude = AtomicInteger(0)
|
||||
private val queuedPcmFrames = AtomicInteger(0)
|
||||
private val encodedFrames = AtomicInteger(0)
|
||||
private val outboundPackets = AtomicInteger(0)
|
||||
private val deliveredPackets = AtomicInteger(0)
|
||||
private val outboundQueue = LinkedBlockingQueue<ByteArray>(OUTBOUND_QUEUE_CAPACITY)
|
||||
private val senderRunning = AtomicBoolean(false)
|
||||
private val packetizer = VoiceBurstPacketizer(burstID)
|
||||
private var streamStarted = false
|
||||
private var startedAtMs = 0L
|
||||
private var totalSamples = 0L
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var codec: MediaCodec? = null
|
||||
private var muxer: MediaMuxer? = null
|
||||
private var muxerTrack = -1
|
||||
private var muxerStarted = false
|
||||
private var outputFile: File? = null
|
||||
private var captureThread: Thread? = null
|
||||
private var senderThread: Thread? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(): File? {
|
||||
if (running.get()) return outputFile
|
||||
return try {
|
||||
outputDirectory.mkdirs()
|
||||
val burstHex = VoiceBurstPacket.burstIDHex(burstID)
|
||||
val file = File(outputDirectory, "voice_$burstHex.m4a")
|
||||
if (file.exists()) file.delete()
|
||||
outputFile = file
|
||||
|
||||
val format = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
SAMPLE_RATE,
|
||||
CHANNEL_COUNT
|
||||
).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, SAMPLES_PER_AAC_FRAME * 4)
|
||||
}
|
||||
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
||||
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
start()
|
||||
}
|
||||
codec = encoder
|
||||
val mediaMuxer = MediaMuxer(file.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
muxer = mediaMuxer
|
||||
val record = if (syntheticPcm) {
|
||||
null
|
||||
} else {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
if (minBuffer <= 0) error("AudioRecord buffer unavailable: $minBuffer")
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
max(minBuffer * 4, SAMPLES_PER_AAC_FRAME * 16)
|
||||
).also {
|
||||
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||
it.release()
|
||||
error("AudioRecord did not initialize")
|
||||
}
|
||||
it.startRecording()
|
||||
if (it.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
it.release()
|
||||
error("AudioRecord did not start")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioRecord = record
|
||||
startedAtMs = System.currentTimeMillis()
|
||||
senderRunning.set(true)
|
||||
senderThread = Thread(::senderLoop, "bitchat-ptt-sender").also(Thread::start)
|
||||
running.set(true)
|
||||
captureThread = Thread(::captureLoop, "bitchat-ptt-capture").also(Thread::start)
|
||||
file
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live capture unavailable; caller will fall back to a voice note: ${error.message}")
|
||||
releaseResources()
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun pollAmplitude(): Int = amplitude.get()
|
||||
|
||||
fun stats(): LiveVoiceCaptureStats = LiveVoiceCaptureStats(
|
||||
queuedPcmFrames = queuedPcmFrames.get(),
|
||||
encodedFrames = encodedFrames.get(),
|
||||
dataPackets = packetizer.dataPacketCount,
|
||||
droppedOversizeFrames = packetizer.droppedFrameCount,
|
||||
outboundPackets = outboundPackets.get(),
|
||||
deliveredPackets = deliveredPackets.get()
|
||||
)
|
||||
|
||||
fun stop(canceled: Boolean): File? {
|
||||
val wasRunning = running.getAndSet(false)
|
||||
if (wasRunning) {
|
||||
runCatching { audioRecord?.stop() }
|
||||
runCatching { captureThread?.join(3_000L) }
|
||||
}
|
||||
captureThread = null
|
||||
|
||||
val elapsedMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
|
||||
val durationMs = (encodedFrames.get().toLong() * SAMPLES_PER_AAC_FRAME * 1_000L) / SAMPLE_RATE
|
||||
val valid = !canceled && elapsedMs >= MIN_VALID_DURATION_MS &&
|
||||
durationMs >= MIN_VALID_DURATION_MS && encodedFrames.get() > 0 &&
|
||||
(outputFile?.length() ?: 0L) > 0L
|
||||
|
||||
packetizer.flush().forEach(::queueOutbound)
|
||||
val controlKind = if (valid) {
|
||||
VoiceBurstPacket.Kind.End(packetizer.dataPacketCount, durationMs.coerceAtMost(0xFFFF_FFFFL))
|
||||
} else {
|
||||
VoiceBurstPacket.Kind.Canceled
|
||||
}
|
||||
VoiceBurstPacket.create(burstID, packetizer.nextSequence, controlKind)
|
||||
?.encode()
|
||||
?.let(::queueOutbound)
|
||||
senderRunning.set(false)
|
||||
runCatching { senderThread?.join(OUTBOUND_DRAIN_TIMEOUT_MS) }
|
||||
if (senderThread?.isAlive == true) {
|
||||
Log.w(TAG, "Live voice sender did not drain before timeout")
|
||||
senderThread?.interrupt()
|
||||
}
|
||||
senderThread = null
|
||||
|
||||
val file = outputFile
|
||||
outputFile = null
|
||||
if (!valid) {
|
||||
file?.delete()
|
||||
return null
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun captureLoop() {
|
||||
val pcm = ShortArray(SAMPLES_PER_AAC_FRAME)
|
||||
var nextSyntheticFrameNs = System.nanoTime() + AAC_FRAME_DURATION_NS
|
||||
try {
|
||||
while (running.get()) {
|
||||
val read = if (syntheticPcm) {
|
||||
val waitNs = nextSyntheticFrameNs - System.nanoTime()
|
||||
if (waitNs > 0L) {
|
||||
Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt())
|
||||
}
|
||||
nextSyntheticFrameNs += AAC_FRAME_DURATION_NS
|
||||
val firstSample = totalSamples
|
||||
pcm.indices.forEach { index ->
|
||||
val phase = 2.0 * Math.PI * SYNTHETIC_TONE_HZ *
|
||||
(firstSample + index).toDouble() / SAMPLE_RATE.toDouble()
|
||||
pcm[index] = (sin(phase) * SYNTHETIC_TONE_AMPLITUDE).roundToInt().toShort()
|
||||
}
|
||||
pcm.size
|
||||
} else {
|
||||
audioRecord?.read(pcm, 0, pcm.size, AudioRecord.READ_BLOCKING) ?: break
|
||||
}
|
||||
if (read <= 0) continue
|
||||
amplitude.set(pcm.take(read).maxOfOrNull { abs(it.toInt()) } ?: 0)
|
||||
queuePcm(pcm, read, endOfStream = false)
|
||||
drainEncoder(endOfStream = false)
|
||||
}
|
||||
queuePcm(pcm, 0, endOfStream = true)
|
||||
drainEncoder(endOfStream = true)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live capture stopped after codec/audio failure: ${error.message}")
|
||||
} finally {
|
||||
releaseResources()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuePcm(samples: ShortArray, count: Int, endOfStream: Boolean) {
|
||||
val encoder = codec ?: return
|
||||
val deadlineNs = System.nanoTime() + CODEC_INPUT_DEADLINE_MS * 1_000_000L
|
||||
while (true) {
|
||||
val index = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
val input = encoder.getInputBuffer(index)
|
||||
?: error("AAC encoder returned a null input buffer")
|
||||
input.clear()
|
||||
input.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, count)
|
||||
val presentationUs = (totalSamples * 1_000_000L) / SAMPLE_RATE
|
||||
totalSamples += count
|
||||
encoder.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
count * 2,
|
||||
presentationUs,
|
||||
if (endOfStream) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
||||
)
|
||||
if (!endOfStream) queuedPcmFrames.incrementAndGet()
|
||||
return
|
||||
}
|
||||
// Pull encoded output before retrying so transient codec backpressure cannot discard
|
||||
// the already-read 64 ms microphone block.
|
||||
drainEncoder(endOfStream = false)
|
||||
if (System.nanoTime() >= deadlineNs) {
|
||||
error("AAC encoder input remained unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainEncoder(endOfStream: Boolean) {
|
||||
val encoder = codec ?: return
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var sawEnd = false
|
||||
var idlePolls = 0
|
||||
while (!sawEnd && (!endOfStream || idlePolls < 100)) {
|
||||
when (val index = encoder.dequeueOutputBuffer(info, if (endOfStream) CODEC_TIMEOUT_US else 0L)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
||||
idlePolls++
|
||||
if (!endOfStream) return
|
||||
}
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
if (muxerStarted) error("AAC output format changed twice")
|
||||
muxerTrack = muxer?.addTrack(encoder.outputFormat) ?: -1
|
||||
muxer?.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
else -> if (index >= 0) {
|
||||
idlePolls = 0
|
||||
val output = encoder.getOutputBuffer(index)
|
||||
if (output != null && info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
||||
output.position(info.offset)
|
||||
output.limit(info.offset + info.size)
|
||||
if (muxerStarted && muxerTrack >= 0) {
|
||||
muxer?.writeSampleData(muxerTrack, output.duplicate(), info)
|
||||
}
|
||||
val accessUnit = ByteArray(info.size)
|
||||
output.get(accessUnit)
|
||||
emitEncodedFrame(accessUnit)
|
||||
}
|
||||
sawEnd = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
encoder.releaseOutputBuffer(index, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitEncodedFrame(frame: ByteArray) {
|
||||
if (frame.isEmpty()) return
|
||||
if (!streamStarted) {
|
||||
streamStarted = true
|
||||
VoiceBurstPacket.create(
|
||||
burstID,
|
||||
0,
|
||||
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
|
||||
)?.encode()?.let(::queueOutbound)
|
||||
}
|
||||
encodedFrames.incrementAndGet()
|
||||
packetizer.add(frame).forEach(::queueOutbound)
|
||||
// At the target bitrate a packet fits one frame; flushing immediately avoids latency.
|
||||
packetizer.flush().forEach(::queueOutbound)
|
||||
}
|
||||
|
||||
private fun queueOutbound(packet: ByteArray) {
|
||||
outboundQueue.put(packet.copyOf())
|
||||
outboundPackets.incrementAndGet()
|
||||
}
|
||||
|
||||
private fun senderLoop() {
|
||||
try {
|
||||
while (senderRunning.get() || outboundQueue.isNotEmpty()) {
|
||||
val packet = outboundQueue.poll(100L, TimeUnit.MILLISECONDS) ?: continue
|
||||
target.send(packet)
|
||||
deliveredPackets.incrementAndGet()
|
||||
}
|
||||
} catch (error: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live voice network sender stopped: ${error.message}")
|
||||
} finally {
|
||||
senderRunning.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseResources() {
|
||||
runCatching { audioRecord?.stop() }
|
||||
runCatching { audioRecord?.release() }
|
||||
audioRecord = null
|
||||
runCatching { codec?.stop() }
|
||||
runCatching { codec?.release() }
|
||||
codec = null
|
||||
if (muxerStarted) runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
muxer = null
|
||||
muxerStarted = false
|
||||
muxerTrack = -1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,478 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Date
|
||||
import java.util.TreeMap
|
||||
import java.util.UUID
|
||||
|
||||
enum class LiveVoiceScope { DIRECT_MESSAGE, PUBLIC_MESH }
|
||||
|
||||
sealed interface LiveVoiceEvent {
|
||||
val peerID: String
|
||||
val burstID: String
|
||||
val scope: LiveVoiceScope
|
||||
|
||||
data class Started(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope
|
||||
) : LiveVoiceEvent
|
||||
|
||||
data class Finished(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope,
|
||||
val dataPackets: Int,
|
||||
val frames: Int,
|
||||
val bytes: Int,
|
||||
val expectedPackets: Int?,
|
||||
val missingPackets: Int,
|
||||
val path: String
|
||||
) : LiveVoiceEvent
|
||||
|
||||
data class Canceled(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope
|
||||
) : LiveVoiceEvent
|
||||
|
||||
data class Absorbed(
|
||||
override val peerID: String,
|
||||
override val burstID: String,
|
||||
override val scope: LiveVoiceScope,
|
||||
val finalizedPath: String
|
||||
) : LiveVoiceEvent
|
||||
}
|
||||
|
||||
/** Shared phone/Wear receiver: bounded assembly, live playback, bubble state and note absorption. */
|
||||
class LiveVoiceManager private constructor(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "LiveVoiceManager"
|
||||
private const val MAX_CONCURRENT_ASSEMBLIES = 8
|
||||
private const val MAX_BURST_BYTES = 384 * 1_024
|
||||
private const val INBOUND_BYTES_PER_SECOND = 6_000
|
||||
private const val MAX_BUFFERED_PACKETS = 128
|
||||
private const val GAP_SKIP_MS = 550L
|
||||
private const val IDLE_TIMEOUT_MS = 3_000L
|
||||
private const val FINISHED_TTL_MS = 10 * 60 * 1_000L
|
||||
private const val FINISHED_CAP = 32
|
||||
|
||||
// The manager constructor immediately narrows this to context.applicationContext.
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Volatile private var instance: LiveVoiceManager? = null
|
||||
|
||||
fun getInstance(context: Context): LiveVoiceManager = instance ?: synchronized(this) {
|
||||
instance ?: LiveVoiceManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
fun burstIDFromVoiceFileName(fileName: String): String? {
|
||||
if (!fileName.startsWith("voice_")) return null
|
||||
val id = fileName.removePrefix("voice_").take(16)
|
||||
return id.takeIf { value ->
|
||||
value.length == 16 && value.all { it.digitToIntOrNull(16) != null }
|
||||
}?.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
private data class AssemblyKey(
|
||||
val peerID: String,
|
||||
val scope: LiveVoiceScope,
|
||||
val burstID: String
|
||||
)
|
||||
|
||||
private class Assembly(
|
||||
val key: AssemblyKey,
|
||||
val nickname: String,
|
||||
val message: BitchatMessage,
|
||||
var file: File,
|
||||
val output: FileOutputStream,
|
||||
val startedAtMs: Long,
|
||||
val player: PttAudioPlayer?
|
||||
) {
|
||||
val buffered = TreeMap<Int, List<ByteArray>>()
|
||||
var nextSequence = 1
|
||||
var deliveredFrames = 0
|
||||
var deliveredPackets = 0
|
||||
var missingPackets = 0
|
||||
var receivedBytes = 0
|
||||
var endTotalPackets: Int? = null
|
||||
var idleJob: Job? = null
|
||||
var gapJob: Job? = null
|
||||
}
|
||||
|
||||
private data class FinishedBurst(
|
||||
val key: AssemblyKey,
|
||||
val messageID: String,
|
||||
val nickname: String,
|
||||
val file: File,
|
||||
val timestamp: Date,
|
||||
val expiresAtMs: Long,
|
||||
val dataPackets: Int,
|
||||
val frames: Int,
|
||||
val bytes: Int
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val assemblies = linkedMapOf<AssemblyKey, Assembly>()
|
||||
private val finishedBursts = linkedMapOf<AssemblyKey, FinishedBurst>()
|
||||
private val _liveMessageIDs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val liveMessageIDs: StateFlow<Set<String>> = _liveMessageIDs.asStateFlow()
|
||||
private val _activePublicTalker = MutableStateFlow<String?>(null)
|
||||
val activePublicTalker: StateFlow<String?> = _activePublicTalker.asStateFlow()
|
||||
private val _events = MutableSharedFlow<LiveVoiceEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<LiveVoiceEvent> = _events.asSharedFlow()
|
||||
|
||||
@Volatile private var appForeground = false
|
||||
@Volatile private var visibleScope: LiveVoiceScope? = null
|
||||
@Volatile private var visiblePeerID: String? = null
|
||||
private var activePlayer: PttAudioPlayer? = null
|
||||
|
||||
init {
|
||||
liveDirectory().listFiles()
|
||||
?.filter { it.name.startsWith("voice_live_") }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
appForeground = foreground
|
||||
if (!foreground) {
|
||||
synchronized(this) {
|
||||
activePlayer?.stop()
|
||||
activePlayer = null
|
||||
assemblies.values.forEach { assembly -> assembly.player?.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showPublicMesh() {
|
||||
visibleScope = LiveVoiceScope.PUBLIC_MESH
|
||||
visiblePeerID = null
|
||||
}
|
||||
|
||||
fun showDirectMessage(peerID: String) {
|
||||
visibleScope = LiveVoiceScope.DIRECT_MESSAGE
|
||||
visiblePeerID = peerID
|
||||
}
|
||||
|
||||
fun clearVisibleConversation() {
|
||||
visibleScope = null
|
||||
visiblePeerID = null
|
||||
}
|
||||
|
||||
/** Returns false only when the frame itself violates the live-voice wire/resource contract. */
|
||||
@Synchronized
|
||||
fun handleFrame(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
scope: LiveVoiceScope,
|
||||
payload: ByteArray,
|
||||
timestampMs: Long
|
||||
): Boolean {
|
||||
val packet = VoiceBurstPacket.decode(payload) ?: return false
|
||||
if (!LiveVoicePreferences.isEnabled(context)) return true
|
||||
val burstHex = VoiceBurstPacket.burstIDHex(packet.burstID)
|
||||
val key = AssemblyKey(peerID, scope, burstHex)
|
||||
var assembly = assemblies[key]
|
||||
if (assembly == null) {
|
||||
if (packet.kind is VoiceBurstPacket.Kind.End || packet.kind == VoiceBurstPacket.Kind.Canceled) {
|
||||
return true
|
||||
}
|
||||
if (assemblies.size >= MAX_CONCURRENT_ASSEMBLIES) return false
|
||||
assembly = createAssembly(key, nickname, timestampMs) ?: return false
|
||||
assemblies[key] = assembly
|
||||
publishLiveState()
|
||||
_events.tryEmit(LiveVoiceEvent.Started(peerID, burstHex, scope))
|
||||
}
|
||||
|
||||
assembly.receivedBytes += payload.size
|
||||
val elapsedSeconds = ((System.currentTimeMillis() - assembly.startedAtMs).coerceAtLeast(0L) / 1_000.0) + 2.0
|
||||
if (
|
||||
assembly.receivedBytes > MAX_BURST_BYTES ||
|
||||
assembly.receivedBytes > (INBOUND_BYTES_PER_SECOND * elapsedSeconds).toInt()
|
||||
) {
|
||||
Log.w(TAG, "Dropping over-quota live voice burst")
|
||||
finalizeAssembly(assembly)
|
||||
return false
|
||||
}
|
||||
rescheduleIdle(assembly)
|
||||
|
||||
when (val kind = packet.kind) {
|
||||
is VoiceBurstPacket.Kind.Start -> if (kind.codec != VoiceBurstCodec.AAC_LC_16K_MONO) {
|
||||
cancelAssembly(assembly)
|
||||
return false
|
||||
}
|
||||
is VoiceBurstPacket.Kind.Frames -> {
|
||||
if (packet.sequence < assembly.nextSequence || packet.sequence in assembly.buffered) return true
|
||||
if (assembly.buffered.size >= MAX_BUFFERED_PACKETS) return false
|
||||
assembly.buffered[packet.sequence] = kind.frames
|
||||
drainInOrder(assembly)
|
||||
}
|
||||
is VoiceBurstPacket.Kind.End -> {
|
||||
assembly.endTotalPackets = kind.totalDataPackets
|
||||
drainInOrder(assembly)
|
||||
finalizeIfComplete(assembly)
|
||||
}
|
||||
VoiceBurstPacket.Kind.Canceled -> cancelAssembly(assembly)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Swaps a finalized `voice_<burstID>.m4a` into its existing live row. */
|
||||
@Synchronized
|
||||
fun absorbFinalizedVoiceNote(message: BitchatMessage): Boolean {
|
||||
if (message.type != BitchatMessageType.Audio) return false
|
||||
val burstID = burstIDFromVoiceFileName(File(message.content).name) ?: return false
|
||||
val messageScope = if (message.isPrivate) LiveVoiceScope.DIRECT_MESSAGE else LiveVoiceScope.PUBLIC_MESH
|
||||
val peerID = message.senderPeerID ?: return false
|
||||
assemblies.entries.firstOrNull {
|
||||
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
|
||||
}?.value?.let(::finalizeAssembly)
|
||||
pruneFinished()
|
||||
val entry = finishedBursts.entries.firstOrNull {
|
||||
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
|
||||
} ?: return false
|
||||
val finished = entry.value
|
||||
val replacement = message.copy(
|
||||
id = finished.messageID,
|
||||
timestamp = finished.timestamp,
|
||||
sender = finished.nickname,
|
||||
senderPeerID = peerID,
|
||||
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
|
||||
)
|
||||
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
|
||||
} else {
|
||||
AppStateStore.upsertPublicMessage(replacement)
|
||||
}
|
||||
finished.file.delete()
|
||||
finishedBursts.remove(entry.key)
|
||||
_events.tryEmit(LiveVoiceEvent.Absorbed(peerID, burstID, messageScope, message.content))
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun reset() {
|
||||
assemblies.values.toList().forEach(::cancelAssembly)
|
||||
activePlayer?.stop()
|
||||
activePlayer = null
|
||||
finishedBursts.clear()
|
||||
publishLiveState()
|
||||
}
|
||||
|
||||
private fun createAssembly(key: AssemblyKey, nickname: String, timestampMs: Long): Assembly? {
|
||||
val file = File(
|
||||
liveDirectory(),
|
||||
"voice_live_${key.burstID}_${key.peerID}_${if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
|
||||
)
|
||||
file.parentFile?.mkdirs()
|
||||
file.delete()
|
||||
val output = runCatching { FileOutputStream(file) }.getOrNull() ?: return null
|
||||
val message = BitchatMessage(
|
||||
id = UUID.randomUUID().toString().uppercase(),
|
||||
sender = nickname,
|
||||
content = file.absolutePath,
|
||||
type = BitchatMessageType.Audio,
|
||||
timestamp = Date(timestampMs),
|
||||
isPrivate = key.scope == LiveVoiceScope.DIRECT_MESSAGE,
|
||||
recipientNickname = AppStateStore.nickname.value.takeIf { key.scope == LiveVoiceScope.DIRECT_MESSAGE },
|
||||
senderPeerID = key.peerID
|
||||
)
|
||||
if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.addPrivateMessage(key.peerID, message, isVisible(key.scope, key.peerID))
|
||||
} else {
|
||||
AppStateStore.addPublicMessage(message)
|
||||
}
|
||||
val player = if (canAutoplay(key)) {
|
||||
activePlayer?.stop()
|
||||
PttAudioPlayer().also { activePlayer = it }
|
||||
} else null
|
||||
return Assembly(key, nickname, message, file, output, System.currentTimeMillis(), player)
|
||||
}
|
||||
|
||||
private fun drainInOrder(assembly: Assembly) {
|
||||
while (true) {
|
||||
val frames = assembly.buffered.remove(assembly.nextSequence)
|
||||
if (frames != null) {
|
||||
frames.forEach { frame ->
|
||||
runCatching { assembly.output.write(AdtsFramer.frame(frame)) }
|
||||
}
|
||||
runCatching { assembly.output.flush() }
|
||||
assembly.deliveredFrames += frames.size
|
||||
assembly.deliveredPackets++
|
||||
assembly.player?.enqueue(frames)
|
||||
assembly.nextSequence = (assembly.nextSequence + 1) and 0xFFFF
|
||||
assembly.gapJob?.cancel()
|
||||
assembly.gapJob = null
|
||||
continue
|
||||
}
|
||||
if (assembly.buffered.isNotEmpty() && assembly.gapJob == null) {
|
||||
val key = assembly.key
|
||||
assembly.gapJob = scope.launch {
|
||||
delay(GAP_SKIP_MS)
|
||||
synchronized(this@LiveVoiceManager) {
|
||||
val current = assemblies[key] ?: return@synchronized
|
||||
current.buffered.firstKey()?.let { skipGap(current, it) }
|
||||
current.gapJob = null
|
||||
drainInOrder(current)
|
||||
finalizeIfComplete(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeIfComplete(assembly: Assembly) {
|
||||
val total = assembly.endTotalPackets ?: return
|
||||
if (assembly.nextSequence > total) finalizeAssembly(assembly)
|
||||
}
|
||||
|
||||
private fun finalizeAssembly(assembly: Assembly) {
|
||||
if (assemblies.remove(assembly.key) == null) return
|
||||
assembly.idleJob?.cancel()
|
||||
assembly.gapJob?.cancel()
|
||||
while (assembly.buffered.isNotEmpty()) {
|
||||
skipGap(assembly, assembly.buffered.firstKey())
|
||||
drainInOrder(assembly)
|
||||
if (assembly.gapJob != null) {
|
||||
assembly.gapJob?.cancel()
|
||||
assembly.gapJob = null
|
||||
}
|
||||
}
|
||||
runCatching { assembly.output.close() }
|
||||
assembly.player?.finishAfterDrain()
|
||||
val missingPackets = assembly.endTotalPackets
|
||||
?.let { total -> (total - assembly.deliveredPackets).coerceAtLeast(assembly.missingPackets) }
|
||||
?: assembly.missingPackets
|
||||
if (assembly.deliveredFrames == 0) {
|
||||
removeBubble(assembly)
|
||||
assembly.file.delete()
|
||||
publishLiveState()
|
||||
return
|
||||
}
|
||||
val fallback = File(
|
||||
assembly.file.parentFile,
|
||||
"voice_${assembly.key.burstID}_${assembly.key.peerID}_${if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
|
||||
)
|
||||
fallback.delete()
|
||||
if (assembly.file.renameTo(fallback)) assembly.file = fallback
|
||||
val finalizedMessage = assembly.message.copy(content = assembly.file.absolutePath)
|
||||
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.upsertPrivateMessage(
|
||||
assembly.key.peerID,
|
||||
finalizedMessage,
|
||||
isVisible(assembly.key.scope, assembly.key.peerID)
|
||||
)
|
||||
} else {
|
||||
AppStateStore.upsertPublicMessage(finalizedMessage)
|
||||
}
|
||||
pruneFinished()
|
||||
finishedBursts[assembly.key] = FinishedBurst(
|
||||
key = assembly.key,
|
||||
messageID = assembly.message.id,
|
||||
nickname = assembly.nickname,
|
||||
file = assembly.file,
|
||||
timestamp = assembly.message.timestamp,
|
||||
expiresAtMs = System.currentTimeMillis() + FINISHED_TTL_MS,
|
||||
dataPackets = assembly.deliveredPackets,
|
||||
frames = assembly.deliveredFrames,
|
||||
bytes = assembly.receivedBytes
|
||||
)
|
||||
_events.tryEmit(
|
||||
LiveVoiceEvent.Finished(
|
||||
assembly.key.peerID,
|
||||
assembly.key.burstID,
|
||||
assembly.key.scope,
|
||||
assembly.deliveredPackets,
|
||||
assembly.deliveredFrames,
|
||||
assembly.receivedBytes,
|
||||
assembly.endTotalPackets,
|
||||
missingPackets,
|
||||
assembly.file.absolutePath
|
||||
)
|
||||
)
|
||||
publishLiveState()
|
||||
}
|
||||
|
||||
private fun cancelAssembly(assembly: Assembly) {
|
||||
if (assemblies.remove(assembly.key) == null) return
|
||||
assembly.idleJob?.cancel()
|
||||
assembly.gapJob?.cancel()
|
||||
assembly.player?.stop()
|
||||
runCatching { assembly.output.close() }
|
||||
removeBubble(assembly)
|
||||
assembly.file.delete()
|
||||
_events.tryEmit(
|
||||
LiveVoiceEvent.Canceled(assembly.key.peerID, assembly.key.burstID, assembly.key.scope)
|
||||
)
|
||||
publishLiveState()
|
||||
}
|
||||
|
||||
private fun skipGap(assembly: Assembly, nextAvailableSequence: Int) {
|
||||
val distance = (nextAvailableSequence - assembly.nextSequence) and 0xFFFF
|
||||
if (distance in 1..0x7FFF) assembly.missingPackets += distance
|
||||
assembly.nextSequence = nextAvailableSequence
|
||||
}
|
||||
|
||||
private fun removeBubble(assembly: Assembly) {
|
||||
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||
AppStateStore.removePrivateMessage(assembly.message.id)
|
||||
} else {
|
||||
AppStateStore.removePublicMessage(assembly.message.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rescheduleIdle(assembly: Assembly) {
|
||||
assembly.idleJob?.cancel()
|
||||
val key = assembly.key
|
||||
assembly.idleJob = scope.launch {
|
||||
delay(IDLE_TIMEOUT_MS)
|
||||
synchronized(this@LiveVoiceManager) {
|
||||
assemblies[key]?.let(::finalizeAssembly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishLiveState() {
|
||||
_liveMessageIDs.value = assemblies.values.mapTo(linkedSetOf()) { it.message.id }
|
||||
_activePublicTalker.value = assemblies.values
|
||||
.firstOrNull { it.key.scope == LiveVoiceScope.PUBLIC_MESH }
|
||||
?.nickname
|
||||
}
|
||||
|
||||
private fun pruneFinished() {
|
||||
val now = System.currentTimeMillis()
|
||||
finishedBursts.entries.removeAll { it.value.expiresAtMs <= now }
|
||||
while (finishedBursts.size >= FINISHED_CAP) {
|
||||
val oldest = finishedBursts.minByOrNull { it.value.expiresAtMs }?.key ?: break
|
||||
finishedBursts.remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canAutoplay(key: AssemblyKey): Boolean =
|
||||
LiveVoicePreferences.isEnabled(context) && appForeground && isVisible(key.scope, key.peerID)
|
||||
|
||||
private fun isVisible(scope: LiveVoiceScope, peerID: String): Boolean =
|
||||
visibleScope == scope && (scope == LiveVoiceScope.PUBLIC_MESH || visiblePeerID == peerID)
|
||||
|
||||
private fun liveDirectory(): File = File(context.cacheDir, "files/incoming").apply { mkdirs() }
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/** One preference gates live PTT sending and playback; finalized voice notes remain available. */
|
||||
object LiveVoicePreferences {
|
||||
private const val PREFERENCES = "bitchat_settings"
|
||||
private const val ENABLED = "ptt.liveVoiceEnabled"
|
||||
|
||||
fun isEnabled(context: Context): Boolean =
|
||||
context.applicationContext
|
||||
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getBoolean(ENABLED, true)
|
||||
|
||||
fun setEnabled(context: Context, enabled: Boolean) {
|
||||
context.applicationContext
|
||||
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(ENABLED, enabled)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** Small jitter-buffered AAC player for foreground live bursts. */
|
||||
internal class PttAudioPlayer {
|
||||
companion object {
|
||||
private const val TAG = "PttAudioPlayer"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val FRAME_DURATION_US = 64_000L
|
||||
private const val JITTER_FRAMES = 6
|
||||
private const val JITTER_DEADLINE_MS = 500L
|
||||
private const val CODEC_TIMEOUT_US = 10_000L
|
||||
}
|
||||
|
||||
private val frames = LinkedBlockingQueue<ByteArray>(128)
|
||||
private val stopped = AtomicBoolean(false)
|
||||
private val finishing = AtomicBoolean(false)
|
||||
private val startedAt = System.currentTimeMillis()
|
||||
private val worker = Thread(::playbackLoop, "bitchat-ptt-playback").also(Thread::start)
|
||||
|
||||
fun enqueue(accessUnits: List<ByteArray>) {
|
||||
if (stopped.get()) return
|
||||
accessUnits.forEach { frames.offer(it.copyOf()) }
|
||||
}
|
||||
|
||||
fun finishAfterDrain() {
|
||||
finishing.set(true)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopped.set(true)
|
||||
worker.interrupt()
|
||||
}
|
||||
|
||||
private fun playbackLoop() {
|
||||
var decoder: MediaCodec? = null
|
||||
var track: AudioTrack? = null
|
||||
try {
|
||||
while (
|
||||
!stopped.get() && frames.size < JITTER_FRAMES &&
|
||||
System.currentTimeMillis() - startedAt < JITTER_DEADLINE_MS &&
|
||||
!finishing.get()
|
||||
) {
|
||||
Thread.sleep(10L)
|
||||
}
|
||||
if (stopped.get() || (frames.isEmpty() && finishing.get())) return
|
||||
|
||||
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, 1).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, 2)
|
||||
// AudioSpecificConfig: AAC-LC, 16 kHz (index 8), mono.
|
||||
setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0x14, 0x08)))
|
||||
}
|
||||
decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
||||
configure(format, null, null, 0)
|
||||
start()
|
||||
}
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
).coerceAtLeast(4_096)
|
||||
track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.build()
|
||||
)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
track.play()
|
||||
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var presentationUs = 0L
|
||||
var inputEnded = false
|
||||
var outputEnded = false
|
||||
while (!stopped.get() && !outputEnded) {
|
||||
if (!inputEnded) {
|
||||
val frame = frames.poll(25L, TimeUnit.MILLISECONDS)
|
||||
if (frame != null) {
|
||||
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
decoder.getInputBuffer(index)?.apply {
|
||||
clear()
|
||||
put(frame)
|
||||
}
|
||||
decoder.queueInputBuffer(index, 0, frame.size, presentationUs, 0)
|
||||
presentationUs += FRAME_DURATION_US
|
||||
} else {
|
||||
frames.offer(frame)
|
||||
}
|
||||
} else if (finishing.get()) {
|
||||
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||
if (index >= 0) {
|
||||
decoder.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
0,
|
||||
presentationUs,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputEnded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val index = decoder.dequeueOutputBuffer(info, CODEC_TIMEOUT_US)) {
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED,
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
|
||||
else -> if (index >= 0) {
|
||||
decoder.getOutputBuffer(index)?.let { output ->
|
||||
if (info.size > 0) {
|
||||
output.position(info.offset)
|
||||
output.limit(info.offset + info.size)
|
||||
val pcm = ByteArray(info.size)
|
||||
output.get(pcm)
|
||||
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
|
||||
}
|
||||
}
|
||||
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
decoder.releaseOutputBuffer(index, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Live playback stopped: ${error.message}")
|
||||
} finally {
|
||||
stopped.set(true)
|
||||
runCatching { track?.stop() }
|
||||
runCatching { track?.release() }
|
||||
runCatching { decoder?.stop() }
|
||||
runCatching { decoder?.release() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.SecureRandom
|
||||
|
||||
/** iOS-compatible codec identifier carried by a live push-to-talk START packet. */
|
||||
enum class VoiceBurstCodec(val value: UByte) {
|
||||
AAC_LC_16K_MONO(0x01u);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): VoiceBurstCodec? = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One live push-to-talk packet.
|
||||
*
|
||||
* Wire format (shared with iOS):
|
||||
* `[burstID: 8][seq: UInt16 BE][flags: UInt8][payload...]`.
|
||||
*/
|
||||
class VoiceBurstPacket private constructor(
|
||||
val burstID: ByteArray,
|
||||
val sequence: Int,
|
||||
val kind: Kind
|
||||
) {
|
||||
sealed interface Kind {
|
||||
data class Start(val codec: VoiceBurstCodec) : Kind
|
||||
class Frames(val frames: List<ByteArray>) : Kind {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Frames && frames.size == other.frames.size &&
|
||||
frames.indices.all { frames[it].contentEquals(other.frames[it]) }
|
||||
|
||||
override fun hashCode(): Int = frames.fold(1) { acc, frame -> 31 * acc + frame.contentHashCode() }
|
||||
}
|
||||
data class End(val totalDataPackets: Int, val durationMs: Long) : Kind
|
||||
data object Canceled : Kind
|
||||
}
|
||||
|
||||
fun encode(): ByteArray {
|
||||
val output = ByteArrayOutputStream(HEADER_SIZE + 16)
|
||||
output.write(burstID)
|
||||
output.write((sequence ushr 8) and 0xFF)
|
||||
output.write(sequence and 0xFF)
|
||||
when (val packetKind = kind) {
|
||||
is Kind.Start -> {
|
||||
output.write(FLAG_START)
|
||||
output.write(packetKind.codec.value.toInt())
|
||||
}
|
||||
is Kind.Frames -> {
|
||||
output.write(0)
|
||||
packetKind.frames.forEach { frame ->
|
||||
output.write((frame.size ushr 8) and 0xFF)
|
||||
output.write(frame.size and 0xFF)
|
||||
output.write(frame)
|
||||
}
|
||||
}
|
||||
is Kind.End -> {
|
||||
output.write(FLAG_END)
|
||||
output.write((packetKind.totalDataPackets ushr 8) and 0xFF)
|
||||
output.write(packetKind.totalDataPackets and 0xFF)
|
||||
output.write(((packetKind.durationMs ushr 24) and 0xFF).toInt())
|
||||
output.write(((packetKind.durationMs ushr 16) and 0xFF).toInt())
|
||||
output.write(((packetKind.durationMs ushr 8) and 0xFF).toInt())
|
||||
output.write((packetKind.durationMs and 0xFF).toInt())
|
||||
}
|
||||
Kind.Canceled -> output.write(FLAG_CANCELED)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BURST_ID_SIZE = 8
|
||||
const val HEADER_SIZE = BURST_ID_SIZE + 2 + 1
|
||||
const val MAX_FRAMES_PER_PACKET = 8
|
||||
const val MAX_CONTENT_BYTES = 210
|
||||
private const val FLAG_START = 0x01
|
||||
private const val FLAG_END = 0x02
|
||||
private const val FLAG_CANCELED = 0x04
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun create(burstID: ByteArray, sequence: Int, kind: Kind): VoiceBurstPacket? {
|
||||
if (burstID.size != BURST_ID_SIZE || sequence !in 0..0xFFFF) return null
|
||||
when (kind) {
|
||||
is Kind.Frames -> if (
|
||||
kind.frames.isEmpty() ||
|
||||
kind.frames.size > MAX_FRAMES_PER_PACKET ||
|
||||
kind.frames.any { it.isEmpty() || it.size > 0xFFFF }
|
||||
) return null
|
||||
is Kind.End -> if (
|
||||
kind.totalDataPackets !in 0..0xFFFF ||
|
||||
kind.durationMs !in 0..0xFFFF_FFFFL
|
||||
) return null
|
||||
else -> Unit
|
||||
}
|
||||
return VoiceBurstPacket(burstID.copyOf(), sequence, kind)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): VoiceBurstPacket? {
|
||||
if (data.size < HEADER_SIZE) return null
|
||||
val burstID = data.copyOfRange(0, BURST_ID_SIZE)
|
||||
val sequence = ((data[BURST_ID_SIZE].toInt() and 0xFF) shl 8) or
|
||||
(data[BURST_ID_SIZE + 1].toInt() and 0xFF)
|
||||
val flags = data[BURST_ID_SIZE + 2].toInt() and 0xFF
|
||||
val offset = HEADER_SIZE
|
||||
val kind: Kind = when (flags) {
|
||||
FLAG_START -> {
|
||||
if (offset >= data.size) return null
|
||||
val codec = VoiceBurstCodec.fromValue(data[offset].toUByte()) ?: return null
|
||||
Kind.Start(codec)
|
||||
}
|
||||
FLAG_END -> {
|
||||
if (data.size - offset < 6) return null
|
||||
val total = ((data[offset].toInt() and 0xFF) shl 8) or
|
||||
(data[offset + 1].toInt() and 0xFF)
|
||||
var duration = 0L
|
||||
repeat(4) { index ->
|
||||
duration = (duration shl 8) or (data[offset + 2 + index].toLong() and 0xFF)
|
||||
}
|
||||
Kind.End(total, duration)
|
||||
}
|
||||
FLAG_CANCELED -> Kind.Canceled
|
||||
0 -> {
|
||||
val frames = mutableListOf<ByteArray>()
|
||||
var cursor = offset
|
||||
while (cursor < data.size) {
|
||||
if (data.size - cursor < 2 || frames.size >= MAX_FRAMES_PER_PACKET) return null
|
||||
val length = ((data[cursor].toInt() and 0xFF) shl 8) or
|
||||
(data[cursor + 1].toInt() and 0xFF)
|
||||
cursor += 2
|
||||
if (length <= 0 || data.size - cursor < length) {
|
||||
return null
|
||||
}
|
||||
frames += data.copyOfRange(cursor, cursor + length)
|
||||
cursor += length
|
||||
}
|
||||
if (frames.isEmpty()) return null
|
||||
Kind.Frames(frames)
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
return create(burstID, sequence, kind)
|
||||
}
|
||||
|
||||
fun makeBurstID(): ByteArray = ByteArray(BURST_ID_SIZE).also(random::nextBytes)
|
||||
|
||||
fun burstIDHex(burstID: ByteArray): String =
|
||||
burstID.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Greedy packetizer constrained so one Noise-wrapped frame stays out of fragmentation. */
|
||||
class VoiceBurstPacketizer(
|
||||
val burstID: ByteArray,
|
||||
private val budget: Int = VoiceBurstPacket.MAX_CONTENT_BYTES
|
||||
) {
|
||||
private val pendingFrames = mutableListOf<ByteArray>()
|
||||
private var pendingSize = 0
|
||||
|
||||
var nextSequence: Int = 1
|
||||
private set
|
||||
var dataPacketCount: Int = 0
|
||||
private set
|
||||
var droppedFrameCount: Int = 0
|
||||
private set
|
||||
|
||||
fun add(frame: ByteArray): List<ByteArray> {
|
||||
val frameCost = 2 + frame.size
|
||||
if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) {
|
||||
droppedFrameCount++
|
||||
return emptyList()
|
||||
}
|
||||
val output = mutableListOf<ByteArray>()
|
||||
if (
|
||||
pendingFrames.isNotEmpty() &&
|
||||
(VoiceBurstPacket.HEADER_SIZE + pendingSize + frameCost > budget ||
|
||||
pendingFrames.size >= VoiceBurstPacket.MAX_FRAMES_PER_PACKET)
|
||||
) {
|
||||
output += flush()
|
||||
}
|
||||
pendingFrames += frame.copyOf()
|
||||
pendingSize += frameCost
|
||||
return output
|
||||
}
|
||||
|
||||
fun flush(): List<ByteArray> {
|
||||
if (pendingFrames.isEmpty()) return emptyList()
|
||||
val packet = VoiceBurstPacket.create(
|
||||
burstID,
|
||||
nextSequence,
|
||||
VoiceBurstPacket.Kind.Frames(pendingFrames.map(ByteArray::copyOf))
|
||||
) ?: run {
|
||||
pendingFrames.clear()
|
||||
pendingSize = 0
|
||||
return emptyList()
|
||||
}
|
||||
pendingFrames.clear()
|
||||
pendingSize = 0
|
||||
nextSequence = (nextSequence + 1) and 0xFFFF
|
||||
dataPacketCount = (dataPacketCount + 1).coerceAtMost(0xFFFF)
|
||||
return listOf(packet.encode())
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a seven-byte ADTS header to an ADTS-less AAC-LC/16 kHz/mono access unit. */
|
||||
object AdtsFramer {
|
||||
fun frame(payload: ByteArray): ByteArray {
|
||||
val frameLength = payload.size + 7
|
||||
require(frameLength <= 0x1FFF) { "AAC frame is too large for ADTS" }
|
||||
return ByteArray(frameLength).also { output ->
|
||||
output[0] = 0xFF.toByte()
|
||||
output[1] = 0xF1.toByte()
|
||||
output[2] = 0x60.toByte() // AAC-LC, 16 kHz frequency index, mono channel config high bit
|
||||
output[3] = (0x40 or ((frameLength ushr 11) and 0x03)).toByte()
|
||||
output[4] = ((frameLength ushr 3) and 0xFF).toByte()
|
||||
output[5] = (((frameLength and 0x07) shl 5) or 0x1F).toByte()
|
||||
output[6] = 0xFC.toByte()
|
||||
payload.copyInto(output, destinationOffset = 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,17 +18,38 @@ import java.util.Locale
|
||||
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
|
||||
* The resulting file has MIME audio/mp4.
|
||||
*/
|
||||
class VoiceRecorder(private val context: Context) {
|
||||
class VoiceRecorder(
|
||||
private val context: Context,
|
||||
private val liveTarget: LiveVoiceTarget? = null
|
||||
) {
|
||||
companion object { private const val TAG = "VoiceRecorder" }
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var liveCapture: LiveVoiceCapture? = null
|
||||
private val _amplitude = MutableStateFlow(0)
|
||||
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
|
||||
|
||||
private var outFile: File? = null
|
||||
|
||||
val isLive: Boolean
|
||||
get() = liveCapture != null
|
||||
|
||||
fun start(): File? {
|
||||
stop() // ensure previous session closed
|
||||
if (liveTarget != null) {
|
||||
val directory = File(context.filesDir, "voicenotes/outgoing")
|
||||
val capture = LiveVoiceCapture(directory, liveTarget)
|
||||
val liveFile = capture.start()
|
||||
if (liveFile != null) {
|
||||
liveCapture = capture
|
||||
outFile = liveFile
|
||||
return liveFile
|
||||
}
|
||||
}
|
||||
return startClassic()
|
||||
}
|
||||
|
||||
private fun startClassic(): File? {
|
||||
return try {
|
||||
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
|
||||
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
|
||||
@ -56,13 +77,19 @@ class VoiceRecorder(private val context: Context) {
|
||||
|
||||
fun pollAmplitude(): Int {
|
||||
return try {
|
||||
val amp = recorder?.maxAmplitude ?: 0
|
||||
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
|
||||
_amplitude.value = amp
|
||||
amp
|
||||
} catch (_: Exception) { 0 }
|
||||
}
|
||||
|
||||
fun stop(): File? {
|
||||
fun stop(canceled: Boolean = false): File? {
|
||||
liveCapture?.let { capture ->
|
||||
val file = capture.stop(canceled)
|
||||
liveCapture = null
|
||||
outFile = null
|
||||
return file
|
||||
}
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) {}
|
||||
@ -73,6 +100,10 @@ class VoiceRecorder(private val context: Context) {
|
||||
val f = outFile
|
||||
recorder = null
|
||||
outFile = null
|
||||
if (canceled) {
|
||||
f?.delete()
|
||||
return null
|
||||
}
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -297,6 +297,14 @@ class BluetoothGattServerManager(
|
||||
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
|
||||
delegate?.onGattServerNotificationComplete(
|
||||
device.address,
|
||||
serverLinkIDs[device.address],
|
||||
status
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Proper cleanup sequencing to prevent race conditions
|
||||
|
||||
@ -20,6 +20,7 @@ import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.*
|
||||
import kotlin.math.sign
|
||||
import kotlin.random.Random
|
||||
@ -111,6 +112,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private lateinit var gossipSyncManager: GossipSyncManager
|
||||
// Service-level notification manager for background (no-UI) DMs
|
||||
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
|
||||
@ -129,6 +132,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private var terminated = false
|
||||
|
||||
init {
|
||||
serviceScope.launch {
|
||||
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
|
||||
}
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
VerificationService.configure(encryptionService)
|
||||
setupDelegates()
|
||||
@ -607,6 +613,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleLeave(routed) }
|
||||
@ -949,6 +958,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
|
||||
}
|
||||
|
||||
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
|
||||
try {
|
||||
val recipientPeerID = request.recipientPeerID
|
||||
val packet = if (recipientPeerID == null) {
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.VOICE_FRAME.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = request.payload,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
} else {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
|
||||
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
}
|
||||
broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Live voice frame send failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: com.bitchat.android.model.BitchatFilePacket,
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattServer
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
@ -19,8 +22,8 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.actor
|
||||
import java.util.ArrayDeque
|
||||
|
||||
/**
|
||||
* Handles packet broadcasting to connected devices using actor pattern for serialization
|
||||
@ -48,7 +51,10 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothPacketBroadcaster"
|
||||
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
|
||||
private const val MAX_PENDING_SENDS_PER_LINK = 256
|
||||
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
|
||||
private const val SEND_RETRY_DELAY_MS = 15L
|
||||
private const val MAX_CALLBACK_RETRIES = 3
|
||||
}
|
||||
|
||||
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
|
||||
@ -119,11 +125,38 @@ class BluetoothPacketBroadcaster(
|
||||
// Actor scope for the broadcaster
|
||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
|
||||
|
||||
private enum class SendDirection { CLIENT_WRITE, SERVER_NOTIFICATION }
|
||||
|
||||
private data class SendKey(
|
||||
val deviceAddress: String,
|
||||
val linkID: String,
|
||||
val direction: SendDirection
|
||||
)
|
||||
|
||||
private data class PendingSend(
|
||||
val data: ByteArray,
|
||||
val device: BluetoothDevice,
|
||||
val gatt: BluetoothGatt? = null,
|
||||
val gattServer: BluetoothGattServer? = null,
|
||||
val characteristic: BluetoothGattCharacteristic,
|
||||
var callbackFailures: Int = 0
|
||||
)
|
||||
|
||||
private class LinkSendState {
|
||||
val pending = ArrayDeque<PendingSend>()
|
||||
var pendingBytes = 0
|
||||
var inFlight = false
|
||||
var retryScheduled = false
|
||||
}
|
||||
|
||||
private val sendLock = Any()
|
||||
private val sendStates = mutableMapOf<SendKey, LinkSendState>()
|
||||
|
||||
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
private val broadcasterActor = broadcasterScope.actor<BroadcastRequest>(
|
||||
capacity = Channel.UNLIMITED
|
||||
capacity = 256
|
||||
) {
|
||||
for (request in channel) {
|
||||
val accepted = try {
|
||||
@ -443,21 +476,16 @@ class BluetoothPacketBroadcaster(
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
return try {
|
||||
characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
|
||||
result
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.removeSubscribedDevice(device)
|
||||
connectionTracker.addressPeerMap.remove(device.address)
|
||||
}
|
||||
false
|
||||
}
|
||||
val server = gattServer ?: return false
|
||||
val char = characteristic ?: return false
|
||||
val linkID = connectionTracker.getDeviceConnection(device.address)
|
||||
?.takeIf { !it.isClient }
|
||||
?.linkID
|
||||
?: return false
|
||||
return enqueueSend(
|
||||
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
|
||||
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -467,20 +495,160 @@ class BluetoothPacketBroadcaster(
|
||||
deviceConn: BluetoothConnectionTracker.DeviceConnection,
|
||||
data: ByteArray
|
||||
): Boolean {
|
||||
return try {
|
||||
deviceConn.characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
|
||||
result
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.cleanupDeviceConnection(deviceConn.device.address)
|
||||
val gatt = deviceConn.gatt ?: return false
|
||||
val char = deviceConn.characteristic ?: return false
|
||||
return enqueueSend(
|
||||
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
|
||||
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Android permits only one outstanding GATT operation per link. Queueing here mirrors the
|
||||
* readiness-driven iOS transport and prevents later voice frames from overwriting an operation
|
||||
* that the controller has not completed yet.
|
||||
*/
|
||||
private fun enqueueSend(key: SendKey, request: PendingSend): Boolean {
|
||||
val startNow = synchronized(sendLock) {
|
||||
val state = sendStates.getOrPut(key, ::LinkSendState)
|
||||
if (
|
||||
state.pending.size >= MAX_PENDING_SENDS_PER_LINK ||
|
||||
state.pendingBytes + request.data.size > MAX_PENDING_BYTES_PER_LINK
|
||||
) {
|
||||
Log.w(TAG, "BLE send queue full for ${key.direction}; rejecting ${request.data.size} bytes")
|
||||
return false
|
||||
}
|
||||
state.pending.addLast(request)
|
||||
state.pendingBytes += request.data.size
|
||||
if (!state.inFlight && !state.retryScheduled) {
|
||||
state.inFlight = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
if (startNow) startHead(key)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("MissingPermission", "ObsoleteSdkInt")
|
||||
private fun startHead(key: SendKey) {
|
||||
val request = synchronized(sendLock) { sendStates[key]?.pending?.peekFirst() } ?: return
|
||||
val accepted = try {
|
||||
when (key.direction) {
|
||||
SendDirection.CLIENT_WRITE -> {
|
||||
val gatt = request.gatt
|
||||
if (gatt == null) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(
|
||||
request.characteristic,
|
||||
request.data,
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
request.characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
request.characteristic.value = request.data
|
||||
gatt.writeCharacteristic(request.characteristic)
|
||||
}
|
||||
}
|
||||
SendDirection.SERVER_NOTIFICATION -> {
|
||||
val server = request.gattServer
|
||||
if (server == null) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
server.notifyCharacteristicChanged(
|
||||
request.device,
|
||||
request.characteristic,
|
||||
false,
|
||||
request.data
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
request.characteristic.value = request.data
|
||||
server.notifyCharacteristicChanged(request.device, request.characteristic, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "BLE ${key.direction} failed to start: ${error.message}")
|
||||
false
|
||||
}
|
||||
if (!accepted) rejectStart(key)
|
||||
}
|
||||
|
||||
private fun rejectStart(key: SendKey): Boolean {
|
||||
val schedule = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return false
|
||||
state.inFlight = false
|
||||
if (state.retryScheduled || state.pending.isEmpty()) false else {
|
||||
state.retryScheduled = true
|
||||
true
|
||||
}
|
||||
}
|
||||
if (schedule) {
|
||||
connectionScope.launch {
|
||||
delay(SEND_RETRY_DELAY_MS)
|
||||
val retry = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return@synchronized false
|
||||
state.retryScheduled = false
|
||||
if (!state.inFlight && state.pending.isNotEmpty()) {
|
||||
state.inFlight = true
|
||||
true
|
||||
} else false
|
||||
}
|
||||
if (retry) startHead(key)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
|
||||
completeSend(SendKey(deviceAddress, linkID, SendDirection.CLIENT_WRITE), status)
|
||||
}
|
||||
|
||||
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
|
||||
if (linkID == null) return
|
||||
completeSend(SendKey(deviceAddress, linkID, SendDirection.SERVER_NOTIFICATION), status)
|
||||
}
|
||||
|
||||
private fun completeSend(key: SendKey, status: Int) {
|
||||
var retry = false
|
||||
val startNext = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return
|
||||
val head = state.pending.peekFirst() ?: run {
|
||||
sendStates.remove(key)
|
||||
return
|
||||
}
|
||||
state.inFlight = false
|
||||
if (status != BluetoothGatt.GATT_SUCCESS && head.callbackFailures < MAX_CALLBACK_RETRIES) {
|
||||
head.callbackFailures++
|
||||
retry = true
|
||||
false
|
||||
} else {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "BLE ${key.direction} failed with status $status after retries")
|
||||
}
|
||||
state.pending.removeFirst()
|
||||
state.pendingBytes -= head.data.size
|
||||
if (state.pending.isEmpty()) {
|
||||
sendStates.remove(key)
|
||||
false
|
||||
} else {
|
||||
state.inFlight = true
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retry) rejectStart(key) else if (startNext) startHead(key)
|
||||
}
|
||||
|
||||
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
|
||||
synchronized(sendLock) {
|
||||
sendStates.keys.removeAll { key ->
|
||||
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -499,6 +667,7 @@ class BluetoothPacketBroadcaster(
|
||||
* Shutdown the broadcaster actor gracefully
|
||||
*/
|
||||
fun shutdown() {
|
||||
synchronized(sendLock) { sendStates.clear() }
|
||||
// Close the actor gracefully
|
||||
broadcasterActor.close()
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@ -111,6 +112,8 @@ class MeshCore(
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
@ -122,6 +125,9 @@ class MeshCore(
|
||||
private var isActive = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
|
||||
}
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
@ -481,6 +487,9 @@ class MeshCore(
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleLeave(routed) }
|
||||
}
|
||||
@ -611,6 +620,44 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
|
||||
}
|
||||
|
||||
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
|
||||
try {
|
||||
val recipientPeerID = request.recipientPeerID
|
||||
val packet = if (recipientPeerID == null) {
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.VOICE_FRAME.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = request.payload,
|
||||
ttl = maxTtl
|
||||
)
|
||||
} else {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
|
||||
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = maxTtl
|
||||
)
|
||||
}
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.w("MeshCore", "Live voice frame send failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -21,6 +21,7 @@ interface MeshService {
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -10,6 +10,8 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.sync.PacketIdUtil
|
||||
import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.features.voice.LiveVoiceManager
|
||||
import com.bitchat.android.features.voice.LiveVoiceScope
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
|
||||
@ -140,7 +142,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
senderPeerID = peerID
|
||||
)
|
||||
|
||||
delegate?.onMessageReceived(message)
|
||||
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
|
||||
delegate?.onMessageReceived(message)
|
||||
}
|
||||
|
||||
// Send delivery ACK with generated message ID
|
||||
sendDeliveryAck(uniqueMsgId, peerID)
|
||||
@ -149,6 +153,16 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
}
|
||||
}
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.VOICE_FRAME -> {
|
||||
return LiveVoiceManager.getInstance(appContext).handleFrame(
|
||||
peerID = peerID,
|
||||
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
|
||||
scope = LiveVoiceScope.DIRECT_MESSAGE,
|
||||
payload = noisePayload.data,
|
||||
timestampMs = packet.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.PEER_STATE -> {
|
||||
val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data)
|
||||
if (authenticatedState == null) {
|
||||
@ -411,6 +425,27 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
}
|
||||
// Message relay is now handled by centralized PacketRelayManager
|
||||
}
|
||||
|
||||
/** Validate and ingest an ephemeral public push-to-talk frame. */
|
||||
fun handlePublicVoiceFrame(routed: RoutedPacket): Boolean {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: return false
|
||||
if (peerID == myPeerID) return true
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(delegate?.getBroadcastRecipient())) return false
|
||||
if (packet.timestamp > Long.MAX_VALUE.toULong()) return false
|
||||
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (ageMs !in -30_000L..30_000L) return false
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
if (peerInfo == null || !peerInfo.isVerifiedNickname) return false
|
||||
return LiveVoiceManager.getInstance(appContext).handleFrame(
|
||||
peerID = peerID,
|
||||
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
|
||||
scope = LiveVoiceScope.PUBLIC_MESH,
|
||||
payload = packet.payload,
|
||||
timestampMs = packet.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle broadcast message with verification enforcement
|
||||
@ -441,7 +476,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
delegate?.onMessageReceived(message)
|
||||
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
|
||||
delegate?.onMessageReceived(message)
|
||||
}
|
||||
return
|
||||
} else if (isFileTransfer) {
|
||||
Log.w(TAG, "FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)}")
|
||||
@ -501,7 +538,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
recipientNickname = delegate?.getMyNickname()
|
||||
)
|
||||
Log.d(TAG, "📄 Saved incoming file to $savedPath")
|
||||
delegate?.onMessageReceived(message)
|
||||
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
|
||||
delegate?.onMessageReceived(message)
|
||||
}
|
||||
return
|
||||
} else if (isFileTransfer) {
|
||||
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
|
||||
|
||||
@ -136,6 +136,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
|
||||
MessageType.MESSAGE -> handleMessage(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
||||
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
|
||||
MessageType.LEAVE -> handleLeave(routed)
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
@ -296,6 +297,7 @@ interface PacketProcessorDelegate {
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
|
||||
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false
|
||||
fun handleLeave(routed: RoutedPacket)
|
||||
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
||||
fun handleRequestSync(routed: RoutedPacket)
|
||||
|
||||
@ -62,8 +62,16 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
}
|
||||
|
||||
// Decrement TTL by 1
|
||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
val networkSize = delegate?.getNetworkSize() ?: 1
|
||||
val decrementedTtl = (packet.ttl - 1u).toUByte()
|
||||
val voiceTtl = if (
|
||||
MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME && networkSize > 6
|
||||
) minOf(decrementedTtl, 5u.toUByte()) else decrementedTtl
|
||||
val relayPacket = packet.copy(ttl = voiceTtl)
|
||||
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
|
||||
if (MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME) {
|
||||
delay(Random.nextLong(8L, 26L))
|
||||
}
|
||||
|
||||
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
|
||||
val route = relayPacket.route
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
MessageType.ANNOUNCE,
|
||||
MessageType.MESSAGE,
|
||||
MessageType.FILE_TRANSFER,
|
||||
MessageType.VOICE_FRAME,
|
||||
MessageType.LEAVE
|
||||
)) {
|
||||
return true
|
||||
|
||||
@ -167,6 +167,23 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (recipientPeerID == null) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendVoiceFrame(null, payload)
|
||||
else -> wifiService()?.sendVoiceFrame(null, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendVoiceFrame(recipientPeerID, payload)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendVoiceFrame(recipientPeerID, payload)
|
||||
else -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -21,6 +21,7 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
FILE_TRANSFER(0x20u),
|
||||
|
||||
@ -242,6 +242,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE,
|
||||
NoisePayloadType.VOICE_FRAME,
|
||||
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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() } }
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
) {
|
||||
|
||||
49
app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt
Normal file
49
app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
19
app/src/main/res/values-cs
Normal file
19
app/src/main/res/values-cs
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceBurstPacketTest {
|
||||
private val burstID = ByteArray(8) { (it + 1).toByte() }
|
||||
|
||||
@Test
|
||||
fun wirePacketsRoundTripWithIosLayout() {
|
||||
val packets = listOf(
|
||||
VoiceBurstPacket.create(
|
||||
burstID,
|
||||
0,
|
||||
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
|
||||
)!!,
|
||||
VoiceBurstPacket.create(
|
||||
burstID,
|
||||
7,
|
||||
VoiceBurstPacket.Kind.Frames(
|
||||
listOf(byteArrayOf(0xDE.toByte(), 0xAD.toByte()), ByteArray(130) { 0x42 })
|
||||
)
|
||||
)!!,
|
||||
VoiceBurstPacket.create(burstID, 42, VoiceBurstPacket.Kind.End(41, 2_688))!!,
|
||||
VoiceBurstPacket.create(burstID, 3, VoiceBurstPacket.Kind.Canceled)!!
|
||||
)
|
||||
|
||||
packets.forEach { expected ->
|
||||
val actual = VoiceBurstPacket.decode(expected.encode())
|
||||
assertNotNull(actual)
|
||||
assertArrayEquals(expected.burstID, actual!!.burstID)
|
||||
assertEquals(expected.sequence, actual.sequence)
|
||||
assertEquals(expected.kind, actual.kind)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodedGoldenVectorsMatchIos() {
|
||||
val start = VoiceBurstPacket.create(
|
||||
burstID,
|
||||
0,
|
||||
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
|
||||
)!!.encode()
|
||||
assertArrayEquals(
|
||||
byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 1, 1),
|
||||
start
|
||||
)
|
||||
|
||||
val end = VoiceBurstPacket.create(
|
||||
burstID,
|
||||
42,
|
||||
VoiceBurstPacket.Kind.End(41, 2_688)
|
||||
)!!.encode()
|
||||
assertArrayEquals(
|
||||
byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0, 42, 2, 0, 41, 0, 0, 10, 0x80.toByte()),
|
||||
end
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMalformedPacketsAndConstruction() {
|
||||
assertNull(VoiceBurstPacket.decode(byteArrayOf()))
|
||||
assertNull(VoiceBurstPacket.decode(ByteArray(10)))
|
||||
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0xFF.toByte())))
|
||||
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0)))
|
||||
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 1, 0, 0, 16, 0xAB.toByte())))
|
||||
assertNull(VoiceBurstPacket.decode(burstID + byteArrayOf(0, 0, 1, 0x7F)))
|
||||
assertNull(VoiceBurstPacket.create(byteArrayOf(1, 2), 0, VoiceBurstPacket.Kind.Canceled))
|
||||
assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(emptyList())))
|
||||
assertNull(VoiceBurstPacket.create(burstID, 1, VoiceBurstPacket.Kind.Frames(listOf(byteArrayOf()))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packetizerRespectsBudgetAndCounters() {
|
||||
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
|
||||
val frame = ByteArray(130) { 0x55 }
|
||||
|
||||
assertTrue(packetizer.add(frame).isEmpty())
|
||||
val first = packetizer.add(frame).single()
|
||||
assertEquals(1, VoiceBurstPacket.decode(first)!!.sequence)
|
||||
val final = packetizer.flush().single()
|
||||
assertEquals(2, VoiceBurstPacket.decode(final)!!.sequence)
|
||||
assertEquals(2, packetizer.dataPacketCount)
|
||||
assertEquals(3, packetizer.nextSequence)
|
||||
assertTrue(packetizer.flush().isEmpty())
|
||||
assertTrue(first.size + 1 + 16 <= 256)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packetizerBatchesEightOrBudgetAndAdtsHeaderIsValid() {
|
||||
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
|
||||
repeat(4) { assertTrue(packetizer.add(ByteArray(40) { 0x11 }).isEmpty()) }
|
||||
val decoded = VoiceBurstPacket.decode(packetizer.flush().single())!!
|
||||
assertEquals(4, (decoded.kind as VoiceBurstPacket.Kind.Frames).frames.size)
|
||||
|
||||
val framed = AdtsFramer.frame(byteArrayOf(1, 2, 3))
|
||||
assertEquals(10, framed.size)
|
||||
assertEquals(0xFF, framed[0].toInt() and 0xFF)
|
||||
assertEquals(0xF1, framed[1].toInt() and 0xFF)
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3), framed.copyOfRange(7, framed.size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packetizerReportsFramesThatCannotFitTheWireBudget() {
|
||||
val packetizer = VoiceBurstPacketizer(burstID, budget = 210)
|
||||
|
||||
assertTrue(packetizer.add(ByteArray(198)).isEmpty())
|
||||
assertEquals(1, packetizer.droppedFrameCount)
|
||||
assertEquals(0, packetizer.dataPacketCount)
|
||||
assertEquals(1, packetizer.nextSequence)
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@ class BLEPacketPaddingPolicyTest {
|
||||
MessageType.REQUEST_SYNC to false,
|
||||
MessageType.FRAGMENT to false,
|
||||
MessageType.FILE_TRANSFER to false,
|
||||
MessageType.VOICE_FRAME to false,
|
||||
MessageType.NOISE_ENCRYPTED to true,
|
||||
MessageType.NOISE_HANDSHAKE to true
|
||||
)
|
||||
@ -50,7 +51,8 @@ class BLEPacketPaddingPolicyTest {
|
||||
MessageType.LEAVE,
|
||||
MessageType.REQUEST_SYNC,
|
||||
MessageType.FRAGMENT,
|
||||
MessageType.FILE_TRANSFER
|
||||
MessageType.FILE_TRANSFER,
|
||||
MessageType.VOICE_FRAME
|
||||
)
|
||||
|
||||
publicTypes.forEach { type ->
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
204
docs/LIVE_VOICE_V1.md
Normal 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`
|
||||
@ -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` |
|
||||
|
||||
@ -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 |
@ -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",
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user