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