Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions clients/android-mic/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ android {

defaultConfig {
applicationId = "com.genesis.meetingmic"
minSdk = 26
// 30: from Android 11 no other app can read this app's external files folder, where
// recordings live; on older releases an app with storage permission could.
minSdk = 30
targetSdk = 34
versionCode = 4
versionName = "0.4.0"
versionCode = 5
versionName = "0.5.0"

// Surfaced to Kotlin as BuildConfig.MEETING_WS_URL / MEETING_TOKEN.
buildConfigField("String", "MEETING_WS_URL", "\"$meetingWsUrl\"")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.genesis.meetingmic

/**
* Raw ADTS framing for AAC-LC, 16 kHz mono.
*
* Each frame carries its own 7-byte header with its length, so a file written frame by frame is
* self-delimiting: a crash or a full disk loses at most the torn last frame, never the file (unlike
* MP4, whose index is written only at the end). Speech transcription services accept raw ADTS `.aac`
* directly, so the same files serve as the local backup and as gap-fill uploads.
*/
object Adts {
const val HEADER_BYTES = 7
const val SAMPLES_PER_FRAME = 1024
private const val PROFILE_LC = 1 // audio object type 2 (AAC-LC) minus 1
private const val FREQ_INDEX_16K = 8 // ADTS sampling-frequency index for 16000 Hz
private const val CHANNELS_MONO = 1

/** The 7-byte header (no CRC) for a frame whose raw AAC payload is [payloadBytes] long. */
fun header(payloadBytes: Int): ByteArray {
val len = payloadBytes + HEADER_BYTES
require(len < (1 shl 13)) { "ADTS frame too large: $len" }
return byteArrayOf(
0xFF.toByte(),
0xF1.toByte(), // MPEG-4, layer 0, no CRC
((PROFILE_LC shl 6) or (FREQ_INDEX_16K shl 2) or (CHANNELS_MONO shr 2)).toByte(),
(((CHANNELS_MONO and 3) shl 6) or (len shr 11)).toByte(),
((len shr 3) and 0xFF).toByte(),
(((len and 7) shl 5) or 0x1F).toByte(),
0xFC.toByte(),
)
}

/**
* Byte offsets of the complete frames in [data], in order. Stops at the first byte that is not a
* valid frame start or at a torn last frame, so readers never pass on a partial frame.
*/
fun frameOffsets(data: ByteArray): List<Int> {
val out = ArrayList<Int>()
var i = 0
while (i + HEADER_BYTES <= data.size) {
if ((data[i].toInt() and 0xFF) != 0xFF || (data[i + 1].toInt() and 0xF6) != 0xF0) break
val len = ((data[i + 3].toInt() and 0x03) shl 11) or
((data[i + 4].toInt() and 0xFF) shl 3) or
((data[i + 5].toInt() and 0xE0) shr 5)
if (len < HEADER_BYTES || i + len > data.size) break
out.add(i)
i += len
}
return out
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.genesis.meetingmic

data class LocalReading(
val signal: MicSignal,
val reason: String,
val bytesWritten: Long = 0,
/** Result of re-reading the last closed file (e.g. "last file verified: 5:00"); empty until one closes. */
val lastCheck: String = "",
)

/**
* Fail-closed judgement of the local backup: GREEN only while bytes written to disk have advanced
* within [freshMs]. A writer that stops making progress (encoder stall, full disk, crash) turns grey,
* then red, even if it never reports an error itself.
*
* Each [start] opens a new generation; writer callbacks carry the generation they belong to, and
* callbacks from an older writer that is still draining are ignored, so they can neither fake
* progress nor hide a stall in the current run.
*/
class LocalBackupMonitor(
private val freshMs: Long = 10_000,
private val redAfterMs: Long = 20_000,
) {
private var generation = 0L
private var startedAtMs = -1L
private var lastProgressMs = -1L
private var bytes = 0L
private var failure: String? = null
private var lastCheck = ""

/** Starts a new generation and returns it; pass it to every writer callback. */
@Synchronized
fun start(nowMs: Long): Long {
generation += 1
startedAtMs = nowMs
lastProgressMs = -1L
bytes = 0L
failure = null
lastCheck = ""
return generation
}

@Synchronized
fun stop() {
startedAtMs = -1L
}

@Synchronized
fun onBytesWritten(gen: Long, total: Long, nowMs: Long) {
if (gen != generation || startedAtMs < 0) return
if (total > bytes) {
bytes = total
lastProgressMs = nowMs
}
}

/** A known reason the backup is off (storage full, encoder failed); the writer clears it on resume. */
@Synchronized
fun setFailure(gen: Long, reason: String?) {
if (gen == generation) failure = reason
}

@Synchronized
fun setLastCheck(gen: Long, text: String) {
if (gen == generation) lastCheck = text
}

@Synchronized
fun read(nowMs: Long): LocalReading {
if (startedAtMs < 0) return LocalReading(MicSignal.GREY, "off", bytes, lastCheck)
failure?.let { return LocalReading(MicSignal.RED, it, bytes, lastCheck) }
if (lastProgressMs >= 0 && nowMs - lastProgressMs <= freshMs) {
return LocalReading(MicSignal.GREEN, "saving on this phone", bytes, lastCheck)
}
val since = nowMs - (if (lastProgressMs >= 0) lastProgressMs else startedAtMs)
if (since >= redAfterMs) {
return LocalReading(MicSignal.RED, "Local backup is not being written", bytes, lastCheck)
}
return LocalReading(MicSignal.GREY, "starting local backup", bytes, lastCheck)
}
}
Loading
Loading