diff --git a/PARITY.md b/PARITY.md
index 6c49ca8..40e52d5 100644
--- a/PARITY.md
+++ b/PARITY.md
@@ -95,7 +95,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Session restore across restarts | โ
credential and device both survive |
| Reactions | ๐ก codec done, no UI |
| Photos | โ
send via the system picker, render inline |
-| Voice notes (record + play) | โช |
+| Voice notes (record + play) | โ
AAC/MP4, tap to record, tap to send |
| Stories, and seen-state | โช |
| Groups: create, list, members, leave | โช |
| Group text and images | โช |
@@ -108,7 +108,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Buddy list, add by username | โ
|
| Remove, block, unblock | โ
|
| Member lookup | โช |
-| Buddy commands | โช |
+| Buddy commands | โ retired; not a feature any more (owner, 2026-08-25) |
| Voice calls (WebRTC, DTLS-SRTP, TURN) | โช |
| Notifications list, read state | โ
|
| Services (AIM/ICQ via `flamenet-oscar`) | โช |
diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt
index cd04295..a5f7c5b 100644
--- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt
+++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt
@@ -142,6 +142,10 @@ class CrudSurfaceTest {
// Identity state, on the thread it describes rather than buried in a
// settings screen. A freshly added contact is PINNED โ trust on first
// use โ so the state line and a way to reach the number both exist.
+ // Voice notes are reachable from the thread, and the control says what
+ // it does rather than being a bare icon.
+ compose.onNodeWithTag("record-voice").assertExists()
+
compose.onNodeWithTag("identity-state").assertExists()
compose.onNodeWithTag("show-safety-number").performClick()
compose.onNodeWithTag("safety-number").assertExists()
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1560d58..f73203e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,15 @@
+
+
+
+
+
+ granted = allowed
+ if (allowed) model.startRecording()
+ }
+
+ val seconds = chat.recordingSeconds
+ if (seconds == null) {
+ TextButton(
+ onClick = {
+ if (granted) model.startRecording()
+ else ask.launch(Manifest.permission.RECORD_AUDIO)
+ },
+ modifier = Modifier.testTag("record-voice"),
+ ) { Text("Record a voice note") }
+ } else {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ // A visible timer: a recorder with no feedback is one people talk
+ // into after it has already stopped.
+ Text(
+ "Recording ${"%.1f".format(seconds)}s",
+ modifier = Modifier.weight(1f).testTag("recording-timer"),
+ )
+ TextButton(
+ onClick = model::cancelRecording,
+ modifier = Modifier.testTag("cancel-voice"),
+ ) { Text("Cancel") }
+ Button(
+ onClick = model::stopAndSendRecording,
+ modifier = Modifier.testTag("send-voice"),
+ ) { Text("Send") }
+ }
+ }
}
@Composable
@@ -665,7 +743,14 @@ private fun Bubble(line: Session.Line, model: MessengerViewModel) {
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
- line.attachment?.let { AttachedImage(it, model) }
+ line.attachment?.let { ref ->
+ // One field, two very different things. Audio has no thumbnail
+ // to show and a duration that matters; an image has the
+ // reverse. Routing on the kind rather than trying to render
+ // both with one composable.
+ if (ref.kind == io.flamenet.e2e.Attachment.AUDIO) VoiceBubble(ref, model)
+ else AttachedImage(ref, model)
+ }
if (line.text.isNotEmpty()) {
Text(
line.text,
@@ -785,3 +870,58 @@ private fun ColumnScope.IdentityPanel(
}
}
}
+
+/**
+ * A received voice note: play, stop, and how long it runs.
+ *
+ * โ ๏ธ Fetched and decrypted **on tap**, never on render. A thread of twenty
+ * notes would otherwise download and decrypt all twenty on scroll โ bytes
+ * nobody asked for, and twenty decrypted files in the cache for the sake of a
+ * list that has not been listened to.
+ */
+@Composable
+private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val player = remember { VoiceNote.Player(context.cacheDir) }
+ var playing by remember(ref.id) { mutableStateOf(false) }
+ var busy by remember(ref.id) { mutableStateOf(false) }
+
+ // โ ๏ธ A MediaPlayer that outlives the composable holds an audio focus and a
+ // file handle. Leaving the thread mid-playback must stop it, or the note
+ // keeps playing into a screen that is no longer there.
+ DisposableEffect(ref.id) { onDispose { player.stop() } }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.testTag("voice-${ref.id}"),
+ ) {
+ TextButton(
+ enabled = !busy,
+ onClick = {
+ if (playing) {
+ player.stop()
+ playing = false
+ return@TextButton
+ }
+ busy = true
+ scope.launch {
+ val bytes = model.attachmentBytes(ref)
+ busy = false
+ if (bytes == null) return@launch
+ runCatching {
+ player.play(bytes) { playing = false }
+ playing = true
+ }
+ }
+ },
+ modifier = Modifier.testTag("play-voice"),
+ ) { Text(if (playing) "Stop" else "Play") }
+
+ Text(
+ if (ref.duration > 0) "%.0fs".format(ref.duration) else "Voice note",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
index 36f9d4f..a87765c 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
@@ -87,6 +87,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* for all of them. Showing a reassuring tick that covers some
* unknown subset of the members would be worse than showing none.
*/
+ /** Seconds elapsed while recording a voice note, or null when not. */
+ val recordingSeconds: Double? = null,
val trust: IdentityTrust = IdentityTrust.UNKNOWN,
/** The number both people read aloud. Null until something is pinned. */
val safetyNumber: String? = null,
@@ -675,8 +677,99 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
- /** Bytes for a received image, fetched and cached on first view. */
- suspend fun imageBytes(ref: io.flamenet.e2e.Attachment.Ref): ByteArray? {
+ // -- Voice notes (ยง6.6) --------------------------------------------------
+
+ private var recording: VoiceNote.Recording? = null
+ private var recordingTicker: kotlinx.coroutines.Job? = null
+
+ /**
+ * Begin recording.
+ *
+ * โ ๏ธ The caller must already hold `RECORD_AUDIO`. Asking here would raise
+ * the dialog after the user has pressed record, so the first note of every
+ * install would be lost to a prompt.
+ */
+ fun startRecording() {
+ if (recording != null) return
+ val started = runCatching { VoiceNote.start(getApplication().cacheDir) }
+ started.onFailure { failure ->
+ update { it.copy(error = "Could not start recording: ${failure.message}") }
+ return
+ }
+ recording = started.getOrNull()
+ // A visible timer, because a recorder with no feedback is one people
+ // talk into after it has already stopped.
+ recordingTicker = viewModelScope.launch {
+ while (recording != null) {
+ val seconds = recording?.elapsedSeconds
+ update { it.copy(openChat = it.openChat?.copy(recordingSeconds = seconds)) }
+ kotlinx.coroutines.delay(200)
+ }
+ }
+ }
+
+ /** Throw the recording away. Nothing is sent and no file is left behind. */
+ fun cancelRecording() {
+ recordingTicker?.cancel()
+ recordingTicker = null
+ recording?.cancel()
+ recording = null
+ update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) }
+ }
+
+ /**
+ * Stop, and send what was captured.
+ *
+ * A recording too short to carry speech is dropped rather than delivered:
+ * a note that plays silence looks like a delivery bug, and the user who
+ * fumbled the button already knows they did.
+ */
+ fun stopAndSendRecording() = viewModelScope.launch {
+ recordingTicker?.cancel()
+ recordingTicker = null
+ val finished = withContext(Dispatchers.IO) { recording?.stop() }
+ recording = null
+ update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) }
+
+ val current = session ?: return@launch
+ val chat = signedIn()?.openChat ?: return@launch
+ val peerId = chat.peerId
+ if (finished == null) {
+ update { it.copy(error = "Too short to send โ hold to record.") }
+ return@launch
+ }
+ if (peerId == null) {
+ update { it.copy(error = "Voice notes are one-to-one for now.") }
+ return@launch
+ }
+
+ update { it.copy(busy = true, error = null) }
+ val done = withContext(Dispatchers.IO) {
+ runCatching {
+ current.sendVoice(peerId, finished.bytes, finished.mime, finished.seconds)
+ }
+ }
+ done.onSuccess { sent ->
+ update {
+ it.copy(
+ busy = false,
+ openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
+ error = if (sent.failed.isEmpty()) null
+ else "Delivered to ${sent.delivered.size} of " +
+ "${sent.delivered.size + sent.failed.size} devices.",
+ )
+ }
+ }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
+ }
+
+ /**
+ * Bytes for a received attachment, fetched and cached on first use.
+ *
+ * Named for attachments rather than images: photos and voice notes take
+ * the same path, and a method called `imageBytes` that audio also calls is
+ * the kind of name that quietly stops being true.
+ */
+ suspend fun attachmentBytes(ref: io.flamenet.e2e.Attachment.Ref): ByteArray? {
val current = session ?: return null
return withContext(Dispatchers.IO) {
runCatching { current.attachmentBytes(ref, getApplication().cacheDir) }
@@ -809,4 +902,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
is Vault.Corrupt -> "This phone's stored keys could not be read."
else -> failure.message ?: "Something went wrong reaching that server."
}
+
+ /**
+ * โ ๏ธ A recorder outlives the view model unless something stops it, and a
+ * live MediaRecorder holds the microphone against the whole device.
+ */
+ override fun onCleared() {
+ recordingTicker?.cancel()
+ recording?.cancel()
+ recording = null
+ super.onCleared()
+ }
}
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt
index adff04d..c2087a6 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt
@@ -86,6 +86,23 @@ class Session(
return sent
}
+ /**
+ * Send a voice note.
+ *
+ * The duration is measured while recording rather than read back from the
+ * container: the sender knows how long they held the button, and parsing
+ * metadata to rediscover it is a second source of truth that can disagree.
+ */
+ fun sendVoice(peerUserId: Long, bytes: ByteArray, mime: String, seconds: Double):
+ io.flamenet.client.Conversations.Sent {
+ val sent = conversations.sendAttachment(
+ peerUserId, bytes, mime, Attachment.AUDIO, duration = seconds)
+ if (sent.reachedSomeone) {
+ record(Vault.threadForUser(peerUserId), "", mine = true, attachment = sent.attachment)
+ }
+ return sent
+ }
+
/** Bytes for an attachment, cached on disk after the first fetch. */
fun attachmentBytes(ref: Attachment.Ref, cacheDir: java.io.File): ByteArray {
val cached = java.io.File(cacheDir, "att-" + ref.id.replace('/', '_'))
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/VoiceNote.kt b/app/src/main/kotlin/io/flamenet/messenger/app/VoiceNote.kt
new file mode 100644
index 0000000..264ffab
--- /dev/null
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/VoiceNote.kt
@@ -0,0 +1,162 @@
+package io.flamenet.messenger.app
+
+import android.media.MediaPlayer
+import android.media.MediaRecorder
+import java.io.File
+
+/**
+ * Recording and playing voice notes.
+ *
+ * Platform APIs only โ `MediaRecorder` and `MediaPlayer` โ because a voice note
+ * is an ordinary encrypted attachment once it is bytes, and everything below
+ * this file already handles that. Nothing here knows about the relay.
+ *
+ * AAC in an MP4 container (`audio/mp4`): playable by the other clients without
+ * a codec conversation, and small enough that a minute of speech is tens of
+ * kilobytes rather than megabytes โ which matters when every byte is uploaded
+ * encrypted and stored by somebody paying for it.
+ */
+object VoiceNote {
+
+ /** What a finished recording produced. */
+ data class Recorded(val bytes: ByteArray, val mime: String, val seconds: Double) {
+ override fun equals(other: Any?): Boolean =
+ other is Recorded && bytes.contentEquals(other.bytes) &&
+ mime == other.mime && seconds == other.seconds
+
+ override fun hashCode(): Int = bytes.contentHashCode()
+ }
+
+ const val MIME = "audio/mp4"
+
+ /**
+ * A recording in progress.
+ *
+ * โ ๏ธ **Holds a file the caller must dispose of.** Both [stop] and [cancel]
+ * delete it; a path that does neither leaves plaintext audio in the cache
+ * directory, which is exactly the thing this product is not supposed to do.
+ */
+ class Recording internal constructor(
+ private val recorder: MediaRecorder,
+ private val file: File,
+ private val startedAtMillis: Long,
+ ) {
+ /**
+ * Stop, and return the bytes.
+ *
+ * โ ๏ธ **`MediaRecorder.stop()` throws if it never captured a frame** โ
+ * a tap rather than a hold, or a permission that was granted but is
+ * being denied by a policy. The exception is `RuntimeException` with no
+ * useful message, and it reads as "recording is broken" when the truth
+ * is "there was nothing to record". Treated as an empty recording,
+ * because that is what it is.
+ */
+ fun stop(): Recorded? {
+ val elapsed = (System.currentTimeMillis() - startedAtMillis) / 1000.0
+ val captured = try {
+ recorder.stop()
+ true
+ } catch (_: RuntimeException) {
+ false
+ } finally {
+ recorder.release()
+ }
+ val bytes = if (captured && file.exists()) file.readBytes() else ByteArray(0)
+ file.delete()
+ // Below this, the container holds a header and nothing worth
+ // sending. Better to drop it than to deliver a note that plays
+ // silence and looks like a delivery bug.
+ if (!captured || bytes.size < MIN_USEFUL_BYTES || elapsed < MIN_SECONDS) return null
+ return Recorded(bytes, MIME, elapsed)
+ }
+
+ /** Abandon it, and leave nothing behind. */
+ fun cancel() {
+ try {
+ recorder.stop()
+ } catch (_: RuntimeException) {
+ // Never started, or stopped with no frames. Nothing to salvage
+ // and nothing to report โ this path is the user changing their
+ // mind, not a failure.
+ } finally {
+ recorder.release()
+ file.delete()
+ }
+ }
+
+ val elapsedSeconds: Double
+ get() = (System.currentTimeMillis() - startedAtMillis) / 1000.0
+ }
+
+ private const val MIN_SECONDS = 0.4
+ private const val MIN_USEFUL_BYTES = 512
+
+ /**
+ * Start recording into the cache directory.
+ *
+ * @throws IllegalStateException when the microphone is unavailable โ most
+ * often because `RECORD_AUDIO` has not been granted. The caller asks for
+ * the permission first; this does not, because a library that raises
+ * dialogs is a library that raises them at the wrong moment.
+ */
+ fun start(cacheDir: File): Recording {
+ val file = File.createTempFile("voice-", ".m4a", cacheDir)
+ val recorder = MediaRecorder().apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ // Speech, not music. Mono at 32 kbit/s is clearly intelligible and
+ // roughly a tenth the size of a stereo default nobody asked for.
+ setAudioChannels(1)
+ setAudioSamplingRate(44_100)
+ setAudioEncodingBitRate(32_000)
+ setOutputFile(file.absolutePath)
+ }
+ try {
+ recorder.prepare()
+ recorder.start()
+ } catch (e: Exception) {
+ recorder.release()
+ file.delete()
+ throw IllegalStateException("could not start recording: ${e.message}", e)
+ }
+ return Recording(recorder, file, System.currentTimeMillis())
+ }
+
+ /**
+ * Play decrypted bytes.
+ *
+ * โ ๏ธ Writes them to a file first, because `MediaPlayer` cannot be handed a
+ * byte array. The file lives in the cache and is deleted when playback
+ * ends or is stopped โ decrypted audio sitting on disk after the user has
+ * finished with it is a leak, however briefly.
+ */
+ class Player(private val cacheDir: File) {
+ private var player: MediaPlayer? = null
+ private var file: File? = null
+
+ fun play(bytes: ByteArray, onFinished: () -> Unit) {
+ stop()
+ val target = File.createTempFile("play-", ".m4a", cacheDir).also { it.writeBytes(bytes) }
+ file = target
+ player = MediaPlayer().apply {
+ setDataSource(target.absolutePath)
+ setOnCompletionListener {
+ stop()
+ onFinished()
+ }
+ prepare()
+ start()
+ }
+ }
+
+ fun stop() {
+ player?.let { runCatching { it.stop() }; it.release() }
+ player = null
+ file?.delete()
+ file = null
+ }
+
+ val isPlaying: Boolean get() = player?.isPlaying == true
+ }
+}