Skip to content

Commit 3637c3a

Browse files
thatSFguyclaude
andcommitted
feat(lxmf): persist received FIELD_AUDIO clips — Phase 0 receive plumbing
Stage 2 of docs/ROADMAP.md Phase 0. Adds the outbound audioField() builder (flat [mode, bytes] per SPEC §5.9.3) + round-trip test, and persists inbound audio clips: - StoredMessage/MessageEntity gain a nullable audioMode column (Room v17, MIGRATION_16_17, additive); the clip bytes reuse the attachment-store columns, audioMode marks the row as a playable clip + records the codec. - ReticulumEngine.withAudio() mirrors withFile(); wired into all three inbound paths (propagation / link / opportunistic). No UI yet — a received clip currently renders as a file chip; the play bubble + playback land next. No send path yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 97e4db4 commit 3637c3a

6 files changed

Lines changed: 118 additions & 4 deletions

File tree

androidApp/src/main/kotlin/io/github/thatsfguy/reticulum/android/storage/Entities.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ internal data class MessageEntity(
143143
val imageSize: Int? = null,
144144
val attachmentToken: String? = null,
145145
val attachmentSize: Int? = null,
146+
// ---- v17 audio clip (FIELD_AUDIO, key 7) ----
147+
// Mode byte (AudioMode.*) when this row is a playable audio clip; the
148+
// bytes reuse the attachment-store columns above. Null otherwise.
149+
val audioMode: Int? = null,
146150
)
147151

148152
// ---- Reticulum Relay Chat (RRC) — experimental, gated by the

androidApp/src/main/kotlin/io/github/thatsfguy/reticulum/android/storage/Repositories.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,13 +399,15 @@ private fun MessageEntity.toModel() = StoredMessage(
399399
messageId, replyToMessageId, reactionsJson, arrivedViaDest,
400400
attachmentName, attachmentBytes,
401401
imageToken, imageSize, attachmentToken, attachmentSize,
402+
audioMode,
402403
)
403404
private fun StoredMessage.toEntity() = MessageEntity(
404405
id, contactHash, direction, content, title, timestamp, state, attempts,
405406
lastAttempt, lastError, rawPacket, packetHash, rssi, hopCount, imageBytes,
406407
messageId, replyToMessageId, reactionsJson, arrivedViaDest,
407408
attachmentName, attachmentBytes,
408409
imageToken, imageSize, attachmentToken, attachmentSize,
410+
audioMode,
409411
)
410412

411413
private fun encodeTelemetryJson(map: Map<String, String>): String =

androidApp/src/main/kotlin/io/github/thatsfguy/reticulum/android/storage/ReticulumDatabase.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
1717
RrcRoomEntity::class,
1818
RrcMessageEntity::class,
1919
],
20-
version = 16,
20+
version = 17,
2121
exportSchema = true,
2222
)
2323
internal abstract class ReticulumDatabase : RoomDatabase() {
@@ -271,6 +271,18 @@ internal abstract class ReticulumDatabase : RoomDatabase() {
271271
}
272272
}
273273

274+
/**
275+
* v17: LXMF `FIELD_AUDIO` (key 7) audio clips. Adds a single
276+
* nullable `audioMode` column (the `AudioMode.*` codec byte) that
277+
* marks a row as a playable clip; the clip bytes reuse the existing
278+
* attachment-store columns. Purely additive, NULL for existing rows.
279+
*/
280+
private val MIGRATION_16_17 = object : Migration(16, 17) {
281+
override fun migrate(db: SupportSQLiteDatabase) {
282+
db.execSQL("ALTER TABLE messages ADD COLUMN audioMode INTEGER")
283+
}
284+
}
285+
274286
fun get(context: Context): ReticulumDatabase {
275287
return INSTANCE ?: synchronized(this) {
276288
INSTANCE ?: Room.databaseBuilder(
@@ -289,6 +301,7 @@ internal abstract class ReticulumDatabase : RoomDatabase() {
289301
MIGRATION_13_14,
290302
MIGRATION_14_15,
291303
MIGRATION_15_16,
304+
MIGRATION_16_17,
292305
)
293306
// Pre-v6 alpha installs are still wiped on schema
294307
// mismatch. From v6 forward we add real migrations
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.github.thatsfguy.reticulum.engine
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertTrue
6+
7+
/**
8+
* [audioField] — outbound LXMF `FIELD_AUDIO` (key 7) builder. Wire shape
9+
* is the flat `[mode_byte(int), audio_bytes]` (SPEC §5.9.3). Verified by
10+
* round-tripping through [extractAudioField].
11+
*/
12+
class OutboundAudioFieldTest {
13+
14+
@Test
15+
fun emitsFlatModeAndBytesUnderKey7() {
16+
val bytes = byteArrayOf(1, 2, 3, 4)
17+
val field = audioField(LxmfAudio(AudioMode.OPUS_OGG, bytes))
18+
// Single entry, integer key 7.
19+
assertEquals(setOf<Any?>(7), field.keys)
20+
val value = field[7] as List<*>
21+
// Flat [mode, bytes] — NOT a list of pairs (that's key 5).
22+
assertEquals(2, value.size)
23+
assertEquals(AudioMode.OPUS_OGG, value[0])
24+
assertTrue((value[1] as ByteArray).contentEquals(bytes))
25+
}
26+
27+
@Test
28+
fun roundTripsThroughExtract() {
29+
val bytes = ByteArray(64) { it.toByte() }
30+
val decoded = extractAudioField(audioField(LxmfAudio(AudioMode.CODEC2_3200, bytes)))!!
31+
assertEquals(AudioMode.CODEC2_3200, decoded.mode)
32+
assertTrue(decoded.bytes.contentEquals(bytes))
33+
}
34+
35+
@Test
36+
fun notConfusedWithFileOrImageShape() {
37+
// The flat audio shape must not parse as a file attachment (key 5,
38+
// list-of-pairs) and vice-versa — they share neither key nor shape.
39+
val field = audioField(LxmfAudio(AudioMode.OPUS_PTT, byteArrayOf(9)))
40+
assertTrue(extractFileAttachments(field).isEmpty())
41+
}
42+
}

shared/src/commonMain/kotlin/io/github/thatsfguy/reticulum/engine/ReticulumEngine.kt

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,25 @@ internal fun extractAudioField(fields: Map<Any?, Any?>): LxmfAudio? {
223223
return LxmfAudio(mode, bytes)
224224
}
225225

226+
/**
227+
* Build the outbound LXMF `FIELD_AUDIO` (key 7) map entry for [audio].
228+
* Wire shape (SPEC §5.9.3) is the flat `[mode_byte(int), audio_bytes]` —
229+
* NOT a list of pairs (that's `FIELD_FILE_ATTACHMENTS` key 5). The mode is
230+
* emitted as a Kotlin `Int` so msgpack encodes it as an integer, the form
231+
* [extractAudioField] (and Sideband) expects. `internal` so the test
232+
* source set can pin the round-trip against [extractAudioField].
233+
*/
234+
internal fun audioField(audio: LxmfAudio): Map<Any?, Any?> =
235+
mapOf<Any?, Any?>(7 to listOf(audio.mode, audio.bytes))
236+
237+
/** File-name extension for an audio clip of the given [AudioMode] byte —
238+
* used for the "save clip" suggested name. */
239+
internal fun audioExtension(mode: Int): String = when {
240+
AudioMode.isOpus(mode) -> ".opus"
241+
AudioMode.isCodec2(mode) -> ".c2"
242+
else -> ".bin"
243+
}
244+
226245
/** One decoded LXMF file attachment (`FIELD_FILE_ATTACHMENTS`, §5.9.7). */
227246
internal class LxmfFileAttachment(val name: String, val bytes: ByteArray)
228247

@@ -535,6 +554,31 @@ class ReticulumEngine(
535554
copy(attachmentName = file.name, attachmentBytes = file.bytes)
536555
}
537556

557+
/**
558+
* Off-row twin of [withFile] for a decoded `FIELD_AUDIO` clip (SPEC
559+
* §5.9.3). The bytes reuse the attachment-store columns; [audioMode]
560+
* marks the row as a playable clip and records the codec. A synthetic
561+
* "clip.<ext>" name gives the save-as path something sensible. No-op
562+
* when [audio] is null.
563+
*/
564+
private suspend fun StoredMessage.withAudio(audio: LxmfAudio?): StoredMessage {
565+
if (audio == null) return this
566+
val name = "clip" + audioExtension(audio.mode)
567+
val token = attachmentStore?.let { store ->
568+
runCatching { store.put(audio.bytes) }
569+
.onFailure {
570+
_events.tryEmit(EngineEvent.Log(
571+
"attachment store: audio put failed (${it::class.simpleName}) — keeping bytes on-row"
572+
))
573+
}
574+
.getOrNull()
575+
}
576+
return if (token != null)
577+
copy(attachmentName = name, attachmentToken = token, attachmentSize = audio.bytes.size, audioMode = audio.mode)
578+
else
579+
copy(attachmentName = name, attachmentBytes = audio.bytes, audioMode = audio.mode)
580+
}
581+
538582
/**
539583
* Delete the attachment-store files referenced by [messages] —
540584
* called from the message-delete paths so a cleared conversation
@@ -1954,6 +1998,7 @@ class ReticulumEngine(
19541998
// LXMF FIELD_FILE_ATTACHMENTS (key 5, SPEC §5.9.7) —
19551999
// keep the first file (Sideband sends one per message).
19562000
val propFile = extractFileAttachments(msg.fields).firstOrNull()
2001+
val propAudio = extractAudioField(msg.fields)
19572002
val savedId = messageRepo.save(StoredMessage(
19582003
contactHash = sourceHashHex,
19592004
direction = "incoming",
@@ -1972,7 +2017,7 @@ class ReticulumEngine(
19722017
// via fwdsvc). Same fallback the link path
19732018
// uses when LINKIDENTIFY is absent.
19742019
arrivedViaDest = sourceHashHex,
1975-
).withImage(imageBytes).withFile(propFile))
2020+
).withImage(imageBytes).withFile(propFile).withAudio(propAudio))
19762021
_events.tryEmit(EngineEvent.MessageReceived(
19772022
messageId = savedId,
19782023
contactHash = sourceHashHex,
@@ -4031,6 +4076,7 @@ class ReticulumEngine(
40314076
// LXMF FIELD_FILE_ATTACHMENTS (key 5, SPEC §5.9.7) — keep the
40324077
// first file (Sideband sends one per message).
40334078
val linkFile = extractFileAttachments(msg.fields).firstOrNull()
4079+
val linkAudio = extractAudioField(msg.fields)
40344080
// v1.1.39 — uniform routing rule (fwdsvc maintainer's
40354081
// simplification). arrivedViaDest = LINKIDENTIFY peer when
40364082
// available, else the LXMF body's source_hash. Covers two
@@ -4074,7 +4120,7 @@ class ReticulumEngine(
40744120
messageId = messageIdHex,
40754121
replyToMessageId = replyToMessageId,
40764122
arrivedViaDest = arrivedViaDest,
4077-
).withImage(imageBytes).withFile(linkFile))
4123+
).withImage(imageBytes).withFile(linkFile).withAudio(linkAudio))
40784124
// Diagnostic only when the routing destination actually differs
40794125
// from the conversation peer (i.e. a passthrough relay case via
40804126
// LINKIDENTIFY). For fwdsvc rebroadcast and direct 1:1 chats
@@ -4672,6 +4718,7 @@ class ReticulumEngine(
46724718
// LXMF FIELD_FILE_ATTACHMENTS (key 5, SPEC §5.9.7) — keep the
46734719
// first file (Sideband sends one per message).
46744720
val oppFile = extractFileAttachments(msg.fields).firstOrNull()
4721+
val oppAudio = extractAudioField(msg.fields)
46754722
val savedId = messageRepo.save(StoredMessage(
46764723
contactHash = sourceHashHex,
46774724
direction = "incoming",
@@ -4706,7 +4753,7 @@ class ReticulumEngine(
47064753
// equals the conversation peer, making the override a
47074754
// harmless no-op).
47084755
arrivedViaDest = sourceHashHex,
4709-
).withImage(imageBytes).withFile(oppFile))
4756+
).withImage(imageBytes).withFile(oppFile).withAudio(oppAudio))
47104757
_events.tryEmit(EngineEvent.MessageReceived(
47114758
messageId = savedId,
47124759
contactHash = sourceHashHex,

shared/src/commonMain/kotlin/io/github/thatsfguy/reticulum/store/Models.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ data class StoredMessage(
155155
val attachmentToken: String? = null,
156156
/** Byte length of the [attachmentToken] payload. */
157157
val attachmentSize: Int? = null,
158+
/** LXMF `FIELD_AUDIO` mode byte (`AudioMode.*`, SPEC §5.9.3) when this
159+
* row is an audio clip; null otherwise. The clip bytes reuse the
160+
* attachment-store columns ([attachmentToken] / [attachmentName] /
161+
* [attachmentSize]); this field marks the row as a playable clip and
162+
* records which codec produced it. */
163+
val audioMode: Int? = null,
158164
)
159165

160166
interface IdentityRepository {

0 commit comments

Comments
 (0)