From dd45fdd4247ab3761b5a0e6e94bf39d221f1aa96 Mon Sep 17 00:00:00 2001 From: guko Date: Fri, 28 Aug 2026 20:20:57 +0800 Subject: [PATCH 1/3] Use the library's calibration file implementations The four File*/Android* classes moved into capullo-audio next to the interfaces they implement. QuantumCast drops its copies and imports them instead. No behaviour change - same classes, same package-private-to-nobody API, just no longer duplicated per app. --- .../quantumcast/player/AndroidOsVolume.kt | 81 ------------------- .../player/FileCalibrationHistory.kt | 31 ------- .../player/FileCalibrationJournal.kt | 68 ---------------- .../quantumcast/player/FileVolumeUndo.kt | 63 --------------- .../quantumcast/player/PlaybackService.kt | 5 ++ 5 files changed, 5 insertions(+), 243 deletions(-) delete mode 100644 app/src/main/java/tech/capullo/quantumcast/player/AndroidOsVolume.kt delete mode 100644 app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationHistory.kt delete mode 100644 app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationJournal.kt delete mode 100644 app/src/main/java/tech/capullo/quantumcast/player/FileVolumeUndo.kt diff --git a/app/src/main/java/tech/capullo/quantumcast/player/AndroidOsVolume.kt b/app/src/main/java/tech/capullo/quantumcast/player/AndroidOsVolume.kt deleted file mode 100644 index a72c538..0000000 --- a/app/src/main/java/tech/capullo/quantumcast/player/AndroidOsVolume.kt +++ /dev/null @@ -1,81 +0,0 @@ -package tech.capullo.quantumcast.player - -import android.content.Context -import android.media.AudioManager -import android.util.Log -import tech.capullo.audio.calibration.DeviceVolume -import tech.capullo.audio.calibration.OsVolumeJournal -import java.io.File - -/** - * [DeviceVolume] backed by this device's media stream. `flags = 0` so a calibration boost adjusts - * the volume silently instead of popping the system volume HUD on every step. - * - * `setStreamVolume` can throw when a Do-Not-Disturb policy owns the stream (that needs - * ACCESS_NOTIFICATION_POLICY, which we deliberately do not request for a calibration nicety), so - * writes are swallowed and logged: a boost that cannot be applied must degrade to "this speaker - * stays quiet and is reported unmeasurable", never to a crash. - */ -class AndroidOsVolume(context: Context) : DeviceVolume { - - private val audioManager = - context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - override val maxIndex: Int - get() = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - override fun current(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - - override fun set(index: Int) { - try { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0) - } catch (e: Exception) { - Log.w(TAG, "setStreamVolume($index) refused: ${e.message}") - } - } - - private companion object { - private const val TAG = "OsVolumeBoost" - } -} - -/** - * File-backed [OsVolumeJournal] in the app's private storage: one line holding the pre-boost - * media-volume index. Survives process death and reboots, so a boost the OS killed mid-run is - * undone on the next start. [save] reports failure so the caller can refuse to boost rather than - * strand the user's phone loud with no record of the original. - */ -class FileOsVolumeJournal(context: Context) : OsVolumeJournal { - - private val file = File(context.filesDir, "os_volume_journal") - - override fun save(preBoostIndex: Int): Boolean = try { - file.writeText(preBoostIndex.toString()) - true - } catch (e: Exception) { - Log.e(TAG, "save failed: ${e.message}") - false - } - - override fun load(): Int? { - if (!file.exists()) return null - return try { - file.readText().trim().toInt() - } catch (e: Exception) { - Log.e(TAG, "load failed, discarding journal: ${e.message}") - clear() - null - } - } - - override fun clear() { - try { - file.delete() - } catch (_: Exception) { - } - } - - private companion object { - private const val TAG = "OsVolumeBoost" - } -} diff --git a/app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationHistory.kt b/app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationHistory.kt deleted file mode 100644 index 4d0d270..0000000 --- a/app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationHistory.kt +++ /dev/null @@ -1,31 +0,0 @@ -package tech.capullo.quantumcast.player - -import android.content.Context -import android.util.Log -import tech.capullo.audio.calibration.CalibrationHistory -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Append-only [CalibrationHistory] as a TSV in app storage: `iso8601\tclientId\tdelta\tlatency`. - * Data for a future per-sink damping policy; never throws into a run. - */ -class FileCalibrationHistory(context: Context) : CalibrationHistory { - - private val file = File(context.filesDir, "calibration_history.tsv") - private val stamp = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) - - override fun record(clientId: String, deltaMs: Int, newLatencyMs: Int) { - try { - file.appendText("${stamp.format(Date())}\t$clientId\t$deltaMs\t$newLatencyMs\n") - } catch (e: Exception) { - Log.e(TAG, "history append failed: ${e.message}") - } - } - - companion object { - private const val TAG = "CalibrationHistory" - } -} diff --git a/app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationJournal.kt b/app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationJournal.kt deleted file mode 100644 index c6a1184..0000000 --- a/app/src/main/java/tech/capullo/quantumcast/player/FileCalibrationJournal.kt +++ /dev/null @@ -1,68 +0,0 @@ -package tech.capullo.quantumcast.player - -import android.content.Context -import android.util.Log -import tech.capullo.audio.calibration.CalibrationJournal -import tech.capullo.audio.calibration.ClientSnapshot -import java.io.File - -/** - * File-backed [CalibrationJournal] in the app's private storage. One - * `id=latency,percent,muted` line per client; survives process death and reboots, so - * [tech.capullo.audio.calibration.SyncCalibrator.recover] can undo a run the OS killed - * mid-flight (restoring both latency and volume). [save] reports failure so the caller can - * abort rather than mutate un-recoverably; [load]/[clear] swallow-and-log — a read/delete - * failure must never crash a calibration. - */ -class FileCalibrationJournal(context: Context) : CalibrationJournal { - - private val file = File(context.filesDir, "calibration_journal") - - override fun save(originals: Map): Boolean = try { - // writeText flushes to the OS before returning, so the bytes survive an app kill - // (our actual threat — ColorOS terminating the process, not power loss). - file.writeText( - originals.entries.joinToString("\n") { (id, s) -> - "$id=${s.latencyMs},${s.volumePercent},${s.volumeMuted}" - }, - ) - true - } catch (e: Exception) { - Log.e(TAG, "save failed: ${e.message}") - false - } - - override fun load(): Map? { - if (!file.exists()) return null - return try { - file.readLines() - .filter { it.isNotBlank() } - .associate { - // ids never contain '='; split on the last one to be safe regardless. - val i = it.lastIndexOf('=') - val parts = it.substring(i + 1).trim().split(',') - it.substring(0, i) to ClientSnapshot( - latencyMs = parts[0].toInt(), - volumePercent = parts[1].toInt(), - volumeMuted = parts[2].toBoolean(), - ) - } - .takeIf { it.isNotEmpty() } - } catch (e: Exception) { - Log.e(TAG, "load failed, discarding journal: ${e.message}") - clear() - null - } - } - - override fun clear() { - try { - file.delete() - } catch (_: Exception) { - } - } - - companion object { - private const val TAG = "CalibrationJournal" - } -} diff --git a/app/src/main/java/tech/capullo/quantumcast/player/FileVolumeUndo.kt b/app/src/main/java/tech/capullo/quantumcast/player/FileVolumeUndo.kt deleted file mode 100644 index ea83032..0000000 --- a/app/src/main/java/tech/capullo/quantumcast/player/FileVolumeUndo.kt +++ /dev/null @@ -1,63 +0,0 @@ -package tech.capullo.quantumcast.player - -import android.content.Context -import android.util.Log -import tech.capullo.audio.calibration.VolumeUndo -import java.io.File - -/** - * File-backed [VolumeUndo] in the app's private storage: one `id=percent` line per client. - * - * Deliberately a SEPARATE file from the calibration journal, not another field in it. The journal is - * deleted the moment a run finishes, because a completed run's writes are intentional and blindly - * reverting them would undo the calibration itself. This record has to survive exactly that moment — - * it exists so an intentional write can still be taken back afterwards — so sharing storage with the - * journal would mean sharing its lifetime and losing the record every time. - * - * Every failure is swallowed and logged. A storage problem must not crash a calibration, and it must - * not stop the balance either: the worst case is losing the undo, which is where the feature was - * before this existed. - */ -class FileVolumeUndo(context: Context) : VolumeUndo { - - private val file = File(context.filesDir, "calibration_volume_undo") - - override fun save(previous: Map) { - try { - file.writeText(previous.entries.joinToString("\n") { (id, p) -> "$id=$p" }) - } catch (e: Exception) { - Log.e(TAG, "save failed, this balance will not be undoable: ${e.message}") - } - } - - override fun load(): Map? { - if (!file.exists()) return null - return try { - file.readLines() - .filter { it.isNotBlank() } - .associate { - // Client ids never contain '=', but split on the last one regardless. - val i = it.lastIndexOf('=') - it.substring(0, i) to it.substring(i + 1).trim().toInt() - } - .takeIf { it.isNotEmpty() } - } catch (e: Exception) { - // A record that cannot be parsed cannot be applied, and keeping it would offer the user - // an undo that does nothing. Drop it. - Log.e(TAG, "load failed, discarding undo record: ${e.message}") - clear() - null - } - } - - override fun clear() { - try { - file.delete() - } catch (_: Exception) { - } - } - - companion object { - private const val TAG = "CalibrationUndo" - } -} diff --git a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt index 47de9d5..5b1b56e 100755 --- a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt +++ b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt @@ -66,6 +66,11 @@ import tech.capullo.audio.player.FifoAudioBufferSink import tech.capullo.audio.snapcast.SnapclientProcess import tech.capullo.audio.snapcast.SnapcontrolPlugin import tech.capullo.audio.snapcast.SnapserverPorts +import tech.capullo.audio.calibration.AndroidOsVolume +import tech.capullo.audio.calibration.FileCalibrationHistory +import tech.capullo.audio.calibration.FileCalibrationJournal +import tech.capullo.audio.calibration.FileOsVolumeJournal +import tech.capullo.audio.calibration.FileVolumeUndo import tech.capullo.audio.snapcast.SnapserverProcess import tech.capullo.audio.snapcast.firstArtist import tech.capullo.audio.snapcast.withoutReferenceTaps From 9db05a573c4b0db91709c0231439f129004bcda6 Mon Sep 17 00:00:00 2001 From: guko Date: Fri, 28 Aug 2026 20:41:45 +0800 Subject: [PATCH 2/3] Pick up the moved calibration implementations; versionCode 17 Pins capullo-audio 1efb53f, which carries the File*/Android* calibration classes now that capullo-audio#6 is merged. Verified against the real pin, not the sibling: capullo-audio moved aside, --refresh-dependencies, arm64 assembleDebug green with QC's own copies deleted. --- app/build.gradle.kts | 2 +- gradle/pins.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8900fa..cac3b5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ android { applicationId = "tech.capullo.quantumcast" minSdk = 26 targetSdk = 36 - versionCode = 16 + versionCode = 17 versionName = "1.0" } diff --git a/gradle/pins.versions.toml b/gradle/pins.versions.toml index 98dfb38..f386ec7 100644 --- a/gradle/pins.versions.toml +++ b/gradle/pins.versions.toml @@ -7,7 +7,7 @@ # jitpack caches -SNAPSHOT unreliably). Composite build substitutes the sibling locally. capulloSourceRadiobrowser = "ce836da8fefc03f1e12f83910738217b1df24aa0" # capullo-audio - immutable jitpack commit; the engine + capullo-audio-ui share it. -capulloAudio = "ac21187a44bb1e3cb9991e49d95e10c1a013bf1e" +capulloAudio = "1efb53fc2c2b2ac0a7a23595e80c52f528098795" # QC's own single-app deps (used by no other repo). media = "1.7.0" coil = "2.6.0" From 70d820e76155707ac96c361eb60398b85108e046 Mon Sep 17 00:00:00 2001 From: guko Date: Fri, 28 Aug 2026 21:08:21 +0800 Subject: [PATCH 3/3] Sort the calibration imports (spotless) --- .../tech/capullo/quantumcast/player/PlaybackService.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt index 5b1b56e..d066f29 100755 --- a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt +++ b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt @@ -58,6 +58,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import tech.capullo.audio.calibration.AndroidOsVolume +import tech.capullo.audio.calibration.FileCalibrationHistory +import tech.capullo.audio.calibration.FileCalibrationJournal +import tech.capullo.audio.calibration.FileOsVolumeJournal +import tech.capullo.audio.calibration.FileVolumeUndo import tech.capullo.audio.contracts.NowPlaying import tech.capullo.audio.contracts.PlaybackController import tech.capullo.audio.player.AudioFocusController @@ -66,11 +71,6 @@ import tech.capullo.audio.player.FifoAudioBufferSink import tech.capullo.audio.snapcast.SnapclientProcess import tech.capullo.audio.snapcast.SnapcontrolPlugin import tech.capullo.audio.snapcast.SnapserverPorts -import tech.capullo.audio.calibration.AndroidOsVolume -import tech.capullo.audio.calibration.FileCalibrationHistory -import tech.capullo.audio.calibration.FileCalibrationJournal -import tech.capullo.audio.calibration.FileOsVolumeJournal -import tech.capullo.audio.calibration.FileVolumeUndo import tech.capullo.audio.snapcast.SnapserverProcess import tech.capullo.audio.snapcast.firstArtist import tech.capullo.audio.snapcast.withoutReferenceTaps