From 105ea5ae533a08839a907dc153ba9a15514770d4 Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:29 +0900 Subject: [PATCH 1/2] android(fix): persist device metadata/settings/cache atomically updateSettings(), updateMetadata() and saveCache() read the whole AppleEntity and upsert it back. The cache is saved on every state change, so these ran concurrently and overwrote each other with stale columns. In practice the metadata written right after the information packet was lost almost every time, and the db kept an empty AppleMetadata. Mark them @Transaction so each read-modify-write is serialized. Co-Authored-By: Claude Opus 5.5 --- .../me/kavishdevar/librepods/database/apple/AppleDao.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt index 5af4510c8..d9cb56b02 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt @@ -3,6 +3,7 @@ package me.kavishdevar.librepods.database.apple import android.util.Log import androidx.room3.Dao import androidx.room3.Query +import androidx.room3.Transaction import androidx.room3.Upsert import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.data.apple.AppleCache @@ -23,18 +24,23 @@ interface AppleDao { @Upsert suspend fun upsert(device: AppleEntity) + // read-modify-write: without a transaction, concurrent saves (cache is saved on every state change) + // overwrite each other with stale columns, which is how metadata ended up empty in the db + @Transaction suspend fun updateSettings(macAddress: MacAddress, settings: AppleSettings) { Log.d(TAG, "Updating settings for $macAddress: $settings") val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = settings, metadata = AppleMetadata(), cache = AppleCache()) upsert(device.copy(settings = settings)) } + @Transaction suspend fun updateMetadata(macAddress: MacAddress, metadata: AppleMetadata) { Log.d(TAG, "Updating metadata for $macAddress: $metadata") val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = metadata, cache = AppleCache()) upsert(device.copy(metadata = metadata)) } + @Transaction suspend fun saveCache(macAddress: MacAddress, cache: AppleCache) { Log.d(TAG, "Saving cache for $macAddress: $cache") val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = AppleMetadata(), cache = cache) From e27ed7710a65599e316e7902c1a54534a177415b Mon Sep 17 00:00:00 2001 From: QuerTeal <11648267+QuerTeal@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:26:29 +0900 Subject: [PATCH 2/2] android(fix): don't crash when the firmware version isn't known yet startHr()/stopHr() and head tracking used metadata.version3.first(), which throws on an empty string. With the heart rate alert enabled, onDeviceConnected() calls startHr() right after loading the (empty, see previous commit) metadata from the db, so the app crashed on every reconnect. Parse the firmware major version null-safely and skip starting the sensor service while it is unknown; start heart rate monitoring once the information packet provides it if the alert is enabled. Co-Authored-By: Claude Opus 5.5 --- .../librepods/devices/AppleDevice.kt | 32 +++++++++++++++---- .../librepods/services/LibrePodsService.kt | 8 +++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt index 595a791b9..6192e28a3 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt @@ -261,9 +261,16 @@ class AppleDevice( } } + // null until the AirPods send their information packet (or it is restored from the db) + private fun firmwareMajorVersion(): Int? = metadata.value.version3.firstOrNull()?.digitToIntOrNull() + fun startHeadTracking() { + val firmwareMajor = firmwareMajorVersion() ?: run { + Log.w(TAG, "firmware version unknown, not starting head tracking") + return + } aacp.setSensorServiceReportInterval( - sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY, + sensorServiceType = if (firmwareMajor >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY, interval = _settings.value.headTrackingInterval ) _state.update { @@ -274,10 +281,13 @@ class AppleDevice( } fun stopHeadTracking() { - aacp.setSensorServiceReportInterval( - sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY, - interval = Duration.ZERO - ) + val firmwareMajor = firmwareMajorVersion() + if (firmwareMajor != null) { + aacp.setSensorServiceReportInterval( + sensorServiceType = if (firmwareMajor >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY, + interval = Duration.ZERO + ) + } _state.update { it.copy( headTrackingState = BuddyState.INACTIVE, @@ -338,8 +348,12 @@ class AppleDevice( // AACPManager sets hrmActive true when a valid reading is received fun startHr(): Boolean { + val firmwareMajor = firmwareMajorVersion() ?: run { + Log.w(TAG, "firmware version unknown, not starting heart rate monitoring") + return false + } val success = aacp.setSensorServiceReportInterval( - sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, + sensorServiceType = if (firmwareMajor >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, interval = 1.seconds ) if (success) { @@ -352,8 +366,12 @@ class AppleDevice( return success } fun stopHr(): Boolean { + val firmwareMajor = firmwareMajorVersion() ?: run { + Log.w(TAG, "firmware version unknown, can't stop heart rate monitoring") + return false + } val success = aacp.setSensorServiceReportInterval( - sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, + sensorServiceType = if (firmwareMajor >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, interval = Duration.ZERO ) if (state.value.hrmState != BuddyState.INACTIVE && success) _state.update { diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index df87fe626..92b3fb55d 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -54,6 +54,7 @@ import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType import me.kavishdevar.librepods.bluetooth.verifyRPA +import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.data.heartrate.HeartRateSample import me.kavishdevar.librepods.data.recording.Recording import me.kavishdevar.librepods.database.app.AppSettingsEntity @@ -876,6 +877,13 @@ class LibrePodsService: Service() { appleRepository.saveMetadata(device.macAddress, metadata) + // startHr() needs the firmware version; on connect it may not have been known yet + if (previousMetadata.version3.isEmpty() && metadata.version3.isNotEmpty() && + device.settings.value.hrmAlertEnabled && device.state.value.hrmState == BuddyState.INACTIVE + ) { + device.startHr() + } + setAppleBluetoothMetadata(device) val notificationManager = getSystemService(NotificationManager::class.java)