Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 | ||
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d |
@@ -25,16 +25,23 @@ ScreenshotContent.kt (mock data + composables)
|
|||||||
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
||||||
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
||||||
|
|
||||||
|
## Commit policy
|
||||||
|
|
||||||
|
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
|
||||||
|
|
||||||
|
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
|
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||||
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
|
# Use this for local iteration and PRs that touch screenshot content.
|
||||||
./fastlane/generate_screenshots.sh
|
|
||||||
|
|
||||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
|
||||||
./fastlane/generate_screenshots.sh --smoke
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
|
|
||||||
|
# Full run — all 68 locales. Use only when intending to upload to Play Store
|
||||||
|
# (the non-smoke output is .gitignored and should not be committed).
|
||||||
|
./fastlane/generate_screenshots.sh
|
||||||
|
|
||||||
# Copy into fastlane directories (run after generate)
|
# Copy into fastlane directories (run after generate)
|
||||||
./fastlane/copy_screenshots.sh
|
./fastlane/copy_screenshots.sh
|
||||||
|
|
||||||
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
|
|||||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
||||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||||
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
|
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
|
||||||
|
|
||||||
## Removing or Renaming a Screenshot
|
## Removing or Renaming a Screenshot
|
||||||
|
|
||||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||||
2. Run `generate_screenshots.sh`
|
2. Run `generate_screenshots.sh --smoke`
|
||||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/*/images/phoneScreenshots/` and get uploaded to Play Store
|
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||||
|
|
||||||
## After UI Changes
|
## After UI Changes
|
||||||
|
|
||||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./fastlane/generate_screenshots.sh
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
./fastlane/copy_screenshots.sh --clean
|
./fastlane/copy_screenshots.sh --clean
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Refreshing all locales on Play Store
|
||||||
|
|
||||||
|
Periodic, manual operation — not per-PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
|
||||||
|
./fastlane/copy_screenshots.sh --clean
|
||||||
|
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
|
||||||
|
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
|
|
||||||
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
||||||
|
|||||||
@@ -17,6 +17,16 @@
|
|||||||
.kotlin
|
.kotlin
|
||||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||||
app/src/screenshotTest*/reference/
|
app/src/screenshotTest*/reference/
|
||||||
|
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
|
||||||
|
# Full localization is uploaded by occasional manual regen + screenshots_only;
|
||||||
|
# Play Store retains previously-uploaded screenshots for locales not pushed.
|
||||||
|
fastlane/metadata/android/*/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
|
||||||
.codex
|
.codex
|
||||||
protocol-research/
|
protocol-research/
|
||||||
_site/
|
_site/
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ android {
|
|||||||
//noinspection WrongGradleMethod
|
//noinspection WrongGradleMethod
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
maxHeapSize = "4g"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||||
@@ -37,16 +38,25 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
|||||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DashboardContent() = PreviewWrapper {
|
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
||||||
|
val devices = if (showAap) {
|
||||||
|
listOf(
|
||||||
|
MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||||
|
MockPodDataProvider.singlePodMonitoredWithAap(),
|
||||||
|
MockPodDataProvider.unknownMonitored(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||||
|
MockPodDataProvider.singlePodMonitored(),
|
||||||
|
MockPodDataProvider.unknownMonitored(),
|
||||||
|
)
|
||||||
|
}
|
||||||
OverviewScreen(
|
OverviewScreen(
|
||||||
state = OverviewViewModel.State(
|
state = OverviewViewModel.State(
|
||||||
now = MOCK_NOW,
|
now = MOCK_NOW,
|
||||||
permissions = emptySet(),
|
permissions = emptySet(),
|
||||||
devices = listOf(
|
devices = devices,
|
||||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
|
||||||
MockPodDataProvider.singlePodMonitored(),
|
|
||||||
MockPodDataProvider.unknownMonitored(),
|
|
||||||
),
|
|
||||||
isDebugMode = false,
|
isDebugMode = false,
|
||||||
isBluetoothEnabled = true,
|
isBluetoothEnabled = true,
|
||||||
profiles = listOf(
|
profiles = listOf(
|
||||||
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddProfileContent() = PreviewWrapper {
|
internal fun AddProfileContent() = PreviewWrapper {
|
||||||
|
val pairedAirPods = BluetoothDevice2(
|
||||||
|
address = "AA:BB:CC:DD:EE:FF",
|
||||||
|
name = "AirPods Pro",
|
||||||
|
seenFirstAt = MOCK_NOW,
|
||||||
|
)
|
||||||
|
val bondedItems = listOf(
|
||||||
|
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||||
|
device = pairedAirPods,
|
||||||
|
claimedByProfile = null,
|
||||||
|
),
|
||||||
|
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||||
|
device = BluetoothDevice2(
|
||||||
|
address = "11:22:33:44:55:66",
|
||||||
|
name = "Living Room TV",
|
||||||
|
seenFirstAt = MOCK_NOW,
|
||||||
|
),
|
||||||
|
claimedByProfile = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
DeviceProfileCreationScreen(
|
DeviceProfileCreationScreen(
|
||||||
state = DeviceProfileCreationViewModel.State(
|
state = DeviceProfileCreationViewModel.State(
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
name = "",
|
name = "My AirPods Pro",
|
||||||
nameError = null,
|
nameError = null,
|
||||||
selectedModel = null,
|
selectedModel = PodModel.AIRPODS_PRO2,
|
||||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||||
identityKey = null,
|
identityKey = null,
|
||||||
encryptionKey = null,
|
encryptionKey = null,
|
||||||
selectedDevice = null,
|
selectedDevice = pairedAirPods,
|
||||||
bondedDeviceItems = emptyList(),
|
bondedDeviceItems = bondedItems,
|
||||||
minimumSignalQuality = 0.15f,
|
minimumSignalQuality = 0.15f,
|
||||||
canSave = false,
|
canSave = true,
|
||||||
),
|
),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSave = {},
|
onSave = {},
|
||||||
@@ -212,7 +241,7 @@ private fun PreviewDashboardLight() = DashboardContent()
|
|||||||
|
|
||||||
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviewDashboardDark() = DashboardContent()
|
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||||
|
|
||||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -141,6 +141,20 @@
|
|||||||
android:name=".monitor.core.worker.MonitorService"
|
android:name=".monitor.core.worker.MonitorService"
|
||||||
android:foregroundServiceType="connectedDevice"
|
android:foregroundServiceType="connectedDevice"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".main.ui.tile.AncTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_anc_on"
|
||||||
|
android:label="@string/tile_anc_label"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||||
|
android:value="false" />
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
package eu.darken.capod
|
package eu.darken.capod
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Looper
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
import eu.darken.capod.common.debug.Bugs
|
|
||||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||||
import eu.darken.capod.common.debug.logging.LogCatLogger
|
import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||||
import eu.darken.capod.common.debug.logging.Logging
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.throttleLatest
|
import eu.darken.capod.common.flow.throttleLatest
|
||||||
@@ -22,16 +18,16 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
|||||||
|
|
||||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.system.exitProcess
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
open class App : Application() {
|
open class App : Application() {
|
||||||
@@ -46,22 +42,19 @@ open class App : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||||
|
|
||||||
val foregroundExceptionHandled = AtomicBoolean(false)
|
|
||||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler(
|
||||||
val isTimingExc = throwable.isForegroundServiceTimingException()
|
CapodUncaughtExceptionHandler(
|
||||||
val isMain = thread === Looper.getMainLooper().thread
|
previousHandler = oldHandler,
|
||||||
if (isTimingExc && isMain && foregroundExceptionHandled.compareAndSet(false, true)) {
|
cancelBeforeDelegate = { throwable ->
|
||||||
runCatching {
|
// Best-effort shutdown: the system handler may terminate the process immediately,
|
||||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
// but cancellation can still close sockets if it gets a scheduling window.
|
||||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
if (::appScope.isInitialized) {
|
||||||
}
|
appScope.cancel(CancellationException("Uncaught exception", throwable))
|
||||||
Looper.loop()
|
}
|
||||||
return@setDefaultUncaughtExceptionHandler
|
},
|
||||||
}
|
)
|
||||||
runCatching { log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
)
|
||||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
autoReporting.setup(this)
|
autoReporting.setup(this)
|
||||||
|
|
||||||
@@ -90,14 +83,5 @@ open class App : Application() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val TAG = logTag("CAP")
|
internal val TAG = logTag("CAP")
|
||||||
|
|
||||||
private fun Throwable.isForegroundServiceTimingException(): Boolean {
|
|
||||||
var current: Throwable? = this
|
|
||||||
while (current != null) {
|
|
||||||
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
|
||||||
current = current.cause
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package eu.darken.capod
|
||||||
|
|
||||||
|
import android.os.Looper
|
||||||
|
import eu.darken.capod.common.debug.Bugs
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
internal class CapodUncaughtExceptionHandler(
|
||||||
|
private val previousHandler: Thread.UncaughtExceptionHandler?,
|
||||||
|
private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread },
|
||||||
|
private val loopMainThread: () -> Unit = { Looper.loop() },
|
||||||
|
private val reportForegroundServiceTimingException: (Throwable) -> Unit = { throwable ->
|
||||||
|
Bugs.report(
|
||||||
|
tag = App.TAG,
|
||||||
|
message = "Foreground service timing exception suppressed",
|
||||||
|
exception = throwable,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
private val cancelBeforeDelegate: (Throwable) -> Unit = {},
|
||||||
|
private val exit: (Int) -> Unit = { exitProcess(it) },
|
||||||
|
) : Thread.UncaughtExceptionHandler {
|
||||||
|
|
||||||
|
private val foregroundExceptionHandled = AtomicBoolean(false)
|
||||||
|
|
||||||
|
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||||
|
if (shouldSuppress(thread, throwable)) {
|
||||||
|
runCatching {
|
||||||
|
log(App.TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||||
|
reportForegroundServiceTimingException(throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
val loopResult = runCatching { loopMainThread() }
|
||||||
|
if (loopResult.isSuccess) return
|
||||||
|
|
||||||
|
val loopFailure = loopResult.exceptionOrNull()!!
|
||||||
|
runCatching {
|
||||||
|
log(App.TAG, ERROR) {
|
||||||
|
"Main loop failed after foreground service timing exception suppression: ${loopFailure.asLog()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delegate(thread, loopFailure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { log(App.TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
||||||
|
delegate(thread, throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldSuppress(thread: Thread, throwable: Throwable): Boolean {
|
||||||
|
val isMainThread = runCatching { thread === mainThreadProvider() }.getOrDefault(false)
|
||||||
|
return throwable.isForegroundServiceTimingException() &&
|
||||||
|
isMainThread &&
|
||||||
|
foregroundExceptionHandled.compareAndSet(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun delegate(thread: Thread, throwable: Throwable) {
|
||||||
|
runCatching { cancelBeforeDelegate(throwable) }
|
||||||
|
previousHandler?.uncaughtException(thread, throwable) ?: exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Throwable.isForegroundServiceTimingException(): Boolean {
|
||||||
|
var current: Throwable? = this
|
||||||
|
while (current != null) {
|
||||||
|
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -113,22 +114,7 @@ class BleScanner @Inject constructor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val flushJob = if (!disableDirectScanCallback) {
|
var flushJob: Job? = null
|
||||||
launch {
|
|
||||||
log(TAG) { "Flush job launched" }
|
|
||||||
while (isActive) {
|
|
||||||
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
|
||||||
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
|
|
||||||
when (scannerMode) {
|
|
||||||
ScannerMode.LOW_POWER -> break
|
|
||||||
ScannerMode.BALANCED -> delay(2000)
|
|
||||||
ScannerMode.LOW_LATENCY -> delay(500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val filterList = when {
|
val filterList = when {
|
||||||
useOffloadedFiltering -> filters.toList()
|
useOffloadedFiltering -> filters.toList()
|
||||||
@@ -167,26 +153,55 @@ class BleScanner @Inject constructor(
|
|||||||
setReportDelay(delay)
|
setReportDelay(delay)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
if (disableDirectScanCallback) {
|
try {
|
||||||
val callbackIntent = createStartIntent()
|
if (disableDirectScanCallback) {
|
||||||
log(TAG) {
|
val callbackIntent = createStartIntent()
|
||||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
log(TAG) {
|
||||||
|
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||||
|
}
|
||||||
|
scanner.startScan(filterList, scanSettings, callbackIntent)
|
||||||
|
} else {
|
||||||
|
log(TAG) {
|
||||||
|
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||||
|
}
|
||||||
|
scanner.startScan(filterList, scanSettings, callback)
|
||||||
|
flushJob = launch {
|
||||||
|
log(TAG) { "Flush job launched" }
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
||||||
|
scanner.flushPendingScanResults(callback)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
|
||||||
|
close(e)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
when (scannerMode) {
|
||||||
|
ScannerMode.LOW_POWER -> break
|
||||||
|
ScannerMode.BALANCED -> delay(2000)
|
||||||
|
ScannerMode.LOW_LATENCY -> delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
scanner.startScan(filterList, scanSettings, callbackIntent)
|
} catch (e: SecurityException) {
|
||||||
} else {
|
log(TAG, WARN) { "startScan() denied: ${e.message}" }
|
||||||
log(TAG) {
|
forwarderConsumer?.cancel()
|
||||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
close(e)
|
||||||
}
|
return@callbackFlow
|
||||||
scanner.startScan(filterList, scanSettings, callback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
forwarderConsumer?.cancel()
|
forwarderConsumer?.cancel()
|
||||||
flushJob?.cancel()
|
flushJob?.cancel()
|
||||||
if (disableDirectScanCallback) {
|
try {
|
||||||
scanner.stopScan(createStopIntent())
|
if (disableDirectScanCallback) {
|
||||||
} else {
|
scanner.stopScan(createStopIntent())
|
||||||
scanner.stopScan(callback)
|
} else {
|
||||||
|
scanner.stopScan(callback)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
|
||||||
}
|
}
|
||||||
log(TAG) { "BleScanner stopped" }
|
log(TAG) { "BleScanner stopped" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class BluetoothDevice2(
|
data class BluetoothDevice2(
|
||||||
internal val internal: BluetoothDevice,
|
val address: BluetoothAddress,
|
||||||
|
val name: String?,
|
||||||
val seenFirstAt: Instant,
|
val seenFirstAt: Instant,
|
||||||
) {
|
internal val internal: BluetoothDevice? = null,
|
||||||
val address: BluetoothAddress
|
)
|
||||||
get() = internal.address
|
|
||||||
|
|
||||||
val name: String?
|
|
||||||
get() = internal.name
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
}
|
}
|
||||||
.map { device ->
|
.map { device ->
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
val wrappedDevices = rawDevices.map { device ->
|
val wrappedDevices = rawDevices.map { device ->
|
||||||
|
|
||||||
BluetoothDevice2(
|
BluetoothDevice2(
|
||||||
|
address = device.address,
|
||||||
|
name = device.name,
|
||||||
internal = device,
|
internal = device,
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: run {
|
||||||
@@ -408,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
@android.annotation.SuppressLint("MissingPermission")
|
@android.annotation.SuppressLint("MissingPermission")
|
||||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
|
val target = device.internal ?: return false
|
||||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||||
.apply { isAccessible = true }
|
.apply { isAccessible = true }
|
||||||
val result = method.invoke(device.internal, alias) as? Boolean ?: false
|
val result = method.invoke(target, alias) as? Boolean ?: false
|
||||||
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -424,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
log(TAG) { "Nudging Android connection to $device" }
|
||||||
|
|
||||||
|
val target = device.internal ?: return@map false
|
||||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||||
"connect", BluetoothDevice::class.java
|
"connect", BluetoothDevice::class.java
|
||||||
).apply { isAccessible = true }
|
).apply { isAccessible = true }
|
||||||
|
|
||||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
|
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
|
||||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||||
accepted
|
accepted
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-mixed",
|
profileId = "preview-dual-mixed",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProMixed(),
|
ble = airPodsProMixed(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-keys",
|
profileId = "preview-dual-keys",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-dual-aap",
|
profileId = "preview-dual-aap",
|
||||||
|
label = "My AirPods Pro",
|
||||||
ble = airPodsProWithKeys(),
|
ble = airPodsProWithKeys(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
@@ -227,12 +230,14 @@ object MockPodDataProvider {
|
|||||||
|
|
||||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single",
|
profileId = "preview-single",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMax(),
|
ble = airPodsMax(),
|
||||||
aap = null,
|
aap = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||||
profileId = "preview-single-aap",
|
profileId = "preview-single-aap",
|
||||||
|
label = "AirPods Max",
|
||||||
ble = airPodsMaxCharging(),
|
ble = airPodsMaxCharging(),
|
||||||
aap = AapPodState(
|
aap = AapPodState(
|
||||||
connectionState = AapPodState.ConnectionState.READY,
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
|||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.asLogSummary
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
|
||||||
@@ -15,13 +16,20 @@ object Bugs {
|
|||||||
message: String,
|
message: String,
|
||||||
exception: Throwable
|
exception: Throwable
|
||||||
) {
|
) {
|
||||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
|
||||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
|
||||||
|
|
||||||
reporter?.notify(exception) ?: run {
|
val bugReporter = reporter
|
||||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
if (bugReporter == null) {
|
||||||
|
runCatching { log(TAG, WARN) { "Bug tracking not initialized yet." } }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runCatching { bugReporter.notify(exception) }
|
||||||
|
.onFailure { failure ->
|
||||||
|
runCatching { log(TAG, WARN) { "Bug reporter failed: ${failure.asLog()}" } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val TAG = logTag("Bugs")
|
private val TAG = logTag("Bugs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,16 +61,19 @@ object Logging {
|
|||||||
message: String
|
message: String
|
||||||
) {
|
) {
|
||||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||||
snapshot
|
snapshot.forEach {
|
||||||
.filter { it.isLoggable(priority) }
|
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
||||||
.forEach {
|
if (isLoggable) {
|
||||||
it.log(
|
runCatching {
|
||||||
priority = priority,
|
it.log(
|
||||||
tag = tag,
|
priority = priority,
|
||||||
metaData = metaData,
|
tag = tag,
|
||||||
message = message
|
metaData = metaData,
|
||||||
)
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearAll() {
|
fun clearAll() {
|
||||||
@@ -110,14 +113,28 @@ inline fun log(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Throwable.asLog(): String {
|
fun Throwable.asLog(): String = runCatching {
|
||||||
val stringWriter = StringWriter(256)
|
val stringWriter = StringWriter(256)
|
||||||
val printWriter = PrintWriter(stringWriter, false)
|
val printWriter = PrintWriter(stringWriter, false)
|
||||||
printStackTrace(printWriter)
|
printStackTrace(printWriter)
|
||||||
printWriter.flush()
|
printWriter.flush()
|
||||||
return stringWriter.toString()
|
stringWriter.toString()
|
||||||
|
}.getOrElse { renderFailure ->
|
||||||
|
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Throwable.asLogSummary(): String {
|
||||||
|
val throwableClass = javaClass.name
|
||||||
|
val throwableMessage = safeMessage()
|
||||||
|
return if (throwableMessage.isNullOrBlank()) {
|
||||||
|
throwableClass
|
||||||
|
} else {
|
||||||
|
"$throwableClass: $throwableMessage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
|
||||||
|
|
||||||
@PublishedApi
|
@PublishedApi
|
||||||
internal fun Any.logTagViaCallSite(): String {
|
internal fun Any.logTagViaCallSite(): String {
|
||||||
val javaClass = this::class.java
|
val javaClass = this::class.java
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package eu.darken.capod.common.permissions
|
package eu.darken.capod.common.permissions
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.PermissionChecker
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
import eu.darken.capod.common.BuildConfigWrap
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.withinApiLevel
|
import eu.darken.capod.common.withinApiLevel
|
||||||
@@ -18,7 +17,7 @@ enum class Permission(
|
|||||||
val permissionId: String,
|
val permissionId: String,
|
||||||
val isScanBlocking: Boolean = false,
|
val isScanBlocking: Boolean = false,
|
||||||
val isGranted: (Context) -> Boolean = {
|
val isGranted: (Context) -> Boolean = {
|
||||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
BLUETOOTH(
|
BLUETOOTH(
|
||||||
@@ -87,4 +86,4 @@ enum class Permission(
|
|||||||
fun Permission.isRequired(context: Context): Boolean = when {
|
fun Permission.isRequired(context: Context): Boolean = when {
|
||||||
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
||||||
else -> !isGranted(context)
|
else -> !isGranted(context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.common.flow.SingleEventFlow
|
||||||
import eu.darken.capod.common.navigation.LocalNavigationController
|
import eu.darken.capod.common.navigation.LocalNavigationController
|
||||||
import eu.darken.capod.common.navigation.Nav
|
import eu.darken.capod.common.navigation.Nav
|
||||||
import eu.darken.capod.common.navigation.NavigationController
|
import eu.darken.capod.common.navigation.NavigationController
|
||||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
|||||||
@Inject lateinit var popUpWindow: PopUpWindow
|
@Inject lateinit var popUpWindow: PopUpWindow
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
|
|
||||||
|
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
|
||||||
|
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
|
||||||
|
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
|
||||||
|
private val warmIntents = SingleEventFlow<Intent>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
|||||||
navCtrl.setup(backStack)
|
navCtrl.setup(backStack)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
// Cold-start intent (the activity's launching intent).
|
||||||
consumeUpgradeExtra(intent)
|
consumeUpgradeExtra(intent)
|
||||||
|
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
|
||||||
|
// directly from onNewIntent) guarantees navCtrl.setup() has run.
|
||||||
|
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
CapodTheme(state = themeState) {
|
CapodTheme(state = themeState) {
|
||||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
consumeUpgradeExtra(intent)
|
setIntent(intent)
|
||||||
|
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
|
||||||
|
warmIntents.tryEmit(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun consumeUpgradeExtra(intent: Intent?) {
|
private fun consumeUpgradeExtra(intent: Intent?) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package eu.darken.capod.main.ui.components
|
package eu.darken.capod.main.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.twotone.AutoAwesome
|
import androidx.compose.material.icons.twotone.AutoAwesome
|
||||||
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
|
|||||||
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
|
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
||||||
|
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
||||||
|
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
||||||
|
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
||||||
|
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ fun DeviceSettingsScreen(
|
|||||||
val showSoundSection =
|
val showSoundSection =
|
||||||
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
||||||
(features.hasToneVolume && toneVol != null) ||
|
(features.hasToneVolume && toneVol != null) ||
|
||||||
features.hasMicrophoneMode
|
(features.hasMicrophoneMode && device.microphoneMode != null)
|
||||||
if (showSoundSection) {
|
if (showSoundSection) {
|
||||||
item("sound_section") {
|
item("sound_section") {
|
||||||
SoundCard(
|
SoundCard(
|
||||||
@@ -404,7 +404,7 @@ fun DeviceSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Controls ─────────────────────────────────
|
// ── Controls ─────────────────────────────────
|
||||||
val showControlsSection = features.hasStemConfig ||
|
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
|
||||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
||||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
|
|||||||
val volSwipe = device.volumeSwipe
|
val volSwipe = device.volumeSwipe
|
||||||
val volSwipeLen = device.volumeSwipeLength
|
val volSwipeLen = device.volumeSwipeLength
|
||||||
|
|
||||||
val showPressControlsNav = features.hasStemConfig ||
|
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
|
||||||
features.hasPressSpeed ||
|
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||||
features.hasPressHoldDuration ||
|
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||||
features.hasEndCallMuteMic
|
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
||||||
if (showPressControlsNav) {
|
if (showPressControlsNav) {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ internal fun SoundCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (features.hasMicrophoneMode) {
|
if (features.hasMicrophoneMode && device.microphoneMode != null) {
|
||||||
if (isPro) {
|
if (isPro) {
|
||||||
val micMode = device.microphoneMode
|
val micMode = device.microphoneMode
|
||||||
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import eu.darken.capod.main.core.MonitorMode
|
|||||||
import eu.darken.capod.main.core.PermissionTool
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.tierRank
|
||||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
@@ -175,7 +176,7 @@ class OverviewViewModel @Inject constructor(
|
|||||||
|
|
||||||
val profiledDevices: List<PodDevice> by lazy {
|
val profiledDevices: List<PodDevice> by lazy {
|
||||||
devices.filter { it.profileId != null }.sortedWith(
|
devices.filter { it.profileId != null }.sortedWith(
|
||||||
compareBy<PodDevice> { deviceTierRank(it) }
|
compareBy<PodDevice> { it.tierRank() }
|
||||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -284,12 +285,5 @@ class OverviewViewModel @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val FREE_DEVICE_LIMIT = 1
|
private const val FREE_DEVICE_LIMIT = 1
|
||||||
private val TAG = logTag("Overview", "VM")
|
private val TAG = logTag("Overview", "VM")
|
||||||
|
|
||||||
/** Connection tier rank for sorting: lower = higher priority. */
|
|
||||||
internal fun deviceTierRank(device: PodDevice): Int = when {
|
|
||||||
device.isSystemConnected -> 0
|
|
||||||
device.isLive -> 1
|
|
||||||
else -> 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
|||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
) {
|
) {
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
batteryPercent = device.batteryLeft,
|
||||||
chargingState = device.leftPodChargingState
|
chargingState = device.leftPodChargingState
|
||||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isLeftInEar ?: false,
|
isInEar = device.isLeftInEar ?: false,
|
||||||
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
|||||||
|
|
||||||
PodGauge(
|
PodGauge(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
batteryPercent = device.batteryRight,
|
||||||
chargingState = device.rightPodChargingState
|
chargingState = device.rightPodChargingState
|
||||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||||
isInEar = device.isRightInEar ?: false,
|
isInEar = device.isRightInEar ?: false,
|
||||||
@@ -360,7 +358,7 @@ private fun PodGauge(
|
|||||||
|
|
||||||
// Battery percentage
|
// Battery percentage
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (batteryPercent >= 0f) {
|
color = if (batteryPercent >= 0f) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
@@ -412,7 +410,7 @@ private fun CaseRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase.toBatteryFloat(),
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(8.dp),
|
.height(8.dp),
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
val percent = device.batteryHeadset
|
||||||
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "gaugeProgress",
|
label = "gaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Progress ring
|
// Progress ring
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(88.dp),
|
modifier = Modifier.size(88.dp),
|
||||||
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
|||||||
|
|
||||||
// Battery text inside ring
|
// Battery text inside ring
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
text = formatBatteryPercent(context, percent),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = if (device.batteryHeadset != null) {
|
color = if (isKnown) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|||||||
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CompactBatterySummary(
|
fun CompactBatterySummary(
|
||||||
device: PodDevice,
|
device: PodDevice,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hasAnyBattery = device.batteryLeft != null
|
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||||
|| device.batteryRight != null
|
|| isKnownBattery(device.batteryRight)
|
||||||
|| device.batteryHeadset != null
|
|| isKnownBattery(device.batteryHeadset)
|
||||||
|| device.batteryCase != null
|
|| isKnownBattery(device.batteryCase)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
|||||||
percent = device.batteryRight,
|
percent = device.batteryRight,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
|||||||
iconRes = null,
|
iconRes = null,
|
||||||
percent = device.batteryHeadset,
|
percent = device.batteryHeadset,
|
||||||
)
|
)
|
||||||
if (device.hasCase && device.batteryCase != null) {
|
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
MiniCaseCluster(device = device)
|
MiniCaseCluster(device = device)
|
||||||
}
|
}
|
||||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MiniPodRing(
|
private fun MiniPodRing(
|
||||||
iconRes: Int?,
|
iconRes: Int?,
|
||||||
percent: Float?,
|
percent: Float,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clamped = percent?.coerceIn(0f, 1f)
|
val isKnown = isKnownBattery(percent)
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = clamped ?: 0f,
|
targetValue = batteryProgress(percent),
|
||||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||||
label = "miniGaugeProgress",
|
label = "miniGaugeProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ringColor = when {
|
val ringColor = when {
|
||||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
|||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
strokeCap = StrokeCap.Round,
|
strokeCap = StrokeCap.Round,
|
||||||
)
|
)
|
||||||
if (clamped != null) {
|
if (isKnown) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = { animatedProgress },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier.size(28.dp),
|
modifier = Modifier.size(28.dp),
|
||||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
|||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, percent),
|
text = formatBatteryPercent(context, percent),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = if (percent != null) {
|
color = if (isKnown) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
BatteryCapsule(
|
BatteryCapsule(
|
||||||
percent = device.batteryCase ?: -1f,
|
percent = device.batteryCase,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(36.dp)
|
.width(36.dp)
|
||||||
.height(6.dp),
|
.height(6.dp),
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-scoped trailing-edge debouncer for tile-driven `SetAncMode` commands.
|
||||||
|
*
|
||||||
|
* The QS panel collapses after each tap, destroying the [AncTileService] instance.
|
||||||
|
* If the pending-job state lived on the service, taps across consecutive panel
|
||||||
|
* sessions wouldn't cancel each other — every tap would fire a separate
|
||||||
|
* `SetAncMode`, overwhelming the AAP verification loop and triggering
|
||||||
|
* "Rejected after retry" storms that leave the device unresponsive until the
|
||||||
|
* app restarts. Keeping the job here, on a `@Singleton`, means a tap in panel
|
||||||
|
* session B can cancel the deferred send queued by panel session A.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AncTileSendCoordinator @Inject constructor(
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
private val aapManager: AapConnectionManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val pendingJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||||
|
private val timeoutJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||||
|
|
||||||
|
private val _pendingModes = MutableStateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>>(emptyMap())
|
||||||
|
val pendingModes: StateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>> = _pendingModes.asStateFlow()
|
||||||
|
|
||||||
|
fun scheduleSetAncMode(
|
||||||
|
address: BluetoothAddress,
|
||||||
|
mode: AapSetting.AncMode.Value,
|
||||||
|
debounce: Duration,
|
||||||
|
timeout: Duration = 5.seconds,
|
||||||
|
) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val replacing = pendingJobs[address]?.isActive == true
|
||||||
|
log(TAG, VERBOSE) { "scheduleSetAncMode($mode, addr=$address, debounce=$debounce, replacingPending=$replacing)" }
|
||||||
|
_pendingModes.value = _pendingModes.value + (address to mode)
|
||||||
|
|
||||||
|
pendingJobs.remove(address)?.cancel()
|
||||||
|
timeoutJobs.remove(address)?.cancel()
|
||||||
|
|
||||||
|
pendingJobs[address] = appScope.launch {
|
||||||
|
delay(debounce)
|
||||||
|
log(TAG, VERBOSE) { "debounce elapsed, dispatching SetAncMode($mode) to AAP for $address" }
|
||||||
|
try {
|
||||||
|
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
|
||||||
|
log(TAG, VERBOSE) { "sent SetAncMode($mode) to $address" }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
log(TAG, VERBOSE) { "send for $mode cancelled (newer tap superseded it)" }
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, ERROR) { "sendCommand failed: ${e.asLog()}" }
|
||||||
|
clearPendingTarget(address, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutJobs[address] = appScope.launch {
|
||||||
|
delay(timeout)
|
||||||
|
if (clearPendingTargetFromTimeout(address, mode)) {
|
||||||
|
log(TAG, VERBOSE) { "pending tile target $mode timed out before device confirmation" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun applyPendingTarget(state: AncTileState): AncTileState {
|
||||||
|
val active = state as? AncTileState.Active ?: return state
|
||||||
|
val address = active.deviceAddress ?: return active
|
||||||
|
val target = pendingModes.value[address] ?: return active
|
||||||
|
|
||||||
|
if (target !in active.visible) return active
|
||||||
|
|
||||||
|
if (active.isConfirmed(target)) return active
|
||||||
|
|
||||||
|
return active.copy(pending = target)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun acknowledgeDeviceState(state: AncTileState) {
|
||||||
|
val active = state as? AncTileState.Active ?: return
|
||||||
|
val address = active.deviceAddress ?: return
|
||||||
|
val target = pendingModes.value[address] ?: return
|
||||||
|
|
||||||
|
if (target !in active.visible || active.isConfirmed(target)) {
|
||||||
|
clearPendingTarget(address, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AncTileState.Active.isConfirmed(target: AapSetting.AncMode.Value): Boolean =
|
||||||
|
pending == target || (current == target && pending == null)
|
||||||
|
|
||||||
|
private fun clearPendingTarget(
|
||||||
|
address: BluetoothAddress,
|
||||||
|
expectedMode: AapSetting.AncMode.Value,
|
||||||
|
): Boolean = synchronized(lock) {
|
||||||
|
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||||
|
if (current != expectedMode) return@synchronized false
|
||||||
|
|
||||||
|
_pendingModes.value = _pendingModes.value - address
|
||||||
|
pendingJobs.remove(address)?.cancel()
|
||||||
|
timeoutJobs.remove(address)?.cancel()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPendingTargetFromTimeout(
|
||||||
|
address: BluetoothAddress,
|
||||||
|
expectedMode: AapSetting.AncMode.Value,
|
||||||
|
): Boolean = synchronized(lock) {
|
||||||
|
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||||
|
if (current != expectedMode) return@synchronized false
|
||||||
|
|
||||||
|
_pendingModes.value = _pendingModes.value - address
|
||||||
|
pendingJobs.remove(address)?.cancel()
|
||||||
|
timeoutJobs.remove(address)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Tile", "Anc", "Coord")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.service.quicksettings.Tile
|
||||||
|
import android.service.quicksettings.TileService
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||||
|
import eu.darken.capod.common.debug.logging.log
|
||||||
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
|
import eu.darken.capod.common.flow.throttleLatest
|
||||||
|
import eu.darken.capod.common.hasApiLevel
|
||||||
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
|
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||||
|
import eu.darken.capod.main.ui.components.shortLabel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick Settings tile that cycles ANC modes for the user-perceived primary AirPods.
|
||||||
|
* Mirrors [eu.darken.capod.main.ui.widget.AncGlanceWidget] but renders into the system
|
||||||
|
* QS panel instead of a home-screen widget.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AncTileService : TileService() {
|
||||||
|
|
||||||
|
@Inject lateinit var dispatcherProvider: DispatcherProvider
|
||||||
|
@Inject lateinit var sendCoordinator: AncTileSendCoordinator
|
||||||
|
@Inject lateinit var stateStore: AncTileStateStore
|
||||||
|
|
||||||
|
private var listenScope: CoroutineScope? = null
|
||||||
|
private val instanceId = Integer.toHexString(System.identityHashCode(this))
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
log(TAG, VERBOSE) { "onCreate(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTileAdded() {
|
||||||
|
log(TAG, VERBOSE) { "onTileAdded(instance=$instanceId)" }
|
||||||
|
// Render a static placeholder so we never block SystemUI's bind path on a
|
||||||
|
// first-emission DataStore/Billing read. onStartListening will fill in real state.
|
||||||
|
renderTile(AncTileState.Connecting)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartListening() {
|
||||||
|
log(TAG, VERBOSE) { "onStartListening(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||||
|
listenScope?.cancel()
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.Default)
|
||||||
|
listenScope = scope
|
||||||
|
scope.launch {
|
||||||
|
stateStore.state.throttleLatest(250).collect { state ->
|
||||||
|
log(TAG, VERBOSE) { "collector: state=$state" }
|
||||||
|
withContext(dispatcherProvider.Main) { renderTile(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopListening() {
|
||||||
|
log(TAG, VERBOSE) { "onStopListening(instance=$instanceId)" }
|
||||||
|
listenScope?.cancel()
|
||||||
|
listenScope = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
log(TAG, VERBOSE) { "onDestroy(instance=$instanceId)" }
|
||||||
|
lastDestroyAt = now
|
||||||
|
listenScope?.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick() {
|
||||||
|
log(TAG, VERBOSE) { "onClick(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()}) received tap" }
|
||||||
|
val state = stateStore.currentState()
|
||||||
|
log(TAG, VERBOSE) {
|
||||||
|
"onClick: resolved from state store=$state"
|
||||||
|
}
|
||||||
|
dispatchClick(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchClick(state: AncTileState) {
|
||||||
|
log(TAG, VERBOSE) { "dispatchClick($state)" }
|
||||||
|
when (state) {
|
||||||
|
AncTileState.NotPro,
|
||||||
|
AncTileState.PermissionRequired -> openMainActivityWithUpgrade()
|
||||||
|
is AncTileState.Active -> sendNextMode(state)
|
||||||
|
AncTileState.BluetoothOff,
|
||||||
|
AncTileState.NoDevice,
|
||||||
|
AncTileState.NoAncSupport,
|
||||||
|
AncTileState.NotConnected,
|
||||||
|
AncTileState.Connecting -> {
|
||||||
|
// Tile state is STATE_UNAVAILABLE; system shouldn't deliver clicks here,
|
||||||
|
// but defensively no-op so we don't crash on unexpected delivery.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNextMode(state: AncTileState.Active) {
|
||||||
|
val nextMode = pickNextMode(state.visible, state.current, state.pending)
|
||||||
|
log(TAG, VERBOSE) {
|
||||||
|
"sendNextMode: visible=${state.visible} current=${state.current} pending=${state.pending} -> next=$nextMode"
|
||||||
|
}
|
||||||
|
if (nextMode == null || nextMode == (state.pending ?: state.current)) {
|
||||||
|
log(TAG, VERBOSE) { "sendNextMode: no advance possible (visible=${state.visible})" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val address: BluetoothAddress = state.deviceAddress ?: run {
|
||||||
|
log(TAG, WARN) { "sendNextMode: state has no device address" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCoordinator.scheduleSetAncMode(address, nextMode, 1.seconds)
|
||||||
|
|
||||||
|
val optimisticState = state.copy(pending = nextMode)
|
||||||
|
renderTile(optimisticState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openMainActivityWithUpgrade() {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
|
||||||
|
}
|
||||||
|
if (hasApiLevel(34)) {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
startActivityAndCollapse(pendingIntent)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||||
|
startActivityAndCollapse(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderTile(state: AncTileState) {
|
||||||
|
val tile = qsTile ?: run {
|
||||||
|
log(TAG, VERBOSE) { "renderTile: skipped, qsTile is null (state=$state)" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log(TAG, VERBOSE) { "renderTile: $state" }
|
||||||
|
val baseLabel = getString(R.string.tile_anc_label)
|
||||||
|
|
||||||
|
val (subtitle, iconRes, tileState) = when (state) {
|
||||||
|
AncTileState.NotPro -> Triple(
|
||||||
|
getString(R.string.common_upgrade_required_label),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_INACTIVE,
|
||||||
|
)
|
||||||
|
AncTileState.PermissionRequired -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_permission_required),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_INACTIVE,
|
||||||
|
)
|
||||||
|
AncTileState.BluetoothOff -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_bluetooth_off),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.NoDevice -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_no_device),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.NoAncSupport -> Triple(
|
||||||
|
getString(R.string.tile_anc_subtitle_no_anc_support),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.NotConnected -> Triple(
|
||||||
|
getString(R.string.anc_widget_aap_not_connected_label),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
AncTileState.Connecting -> Triple(
|
||||||
|
getString(R.string.anc_widget_aap_connecting_label),
|
||||||
|
R.drawable.ic_anc_off,
|
||||||
|
Tile.STATE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
is AncTileState.Active -> {
|
||||||
|
val displayMode = state.pending?.takeIf { it in state.visible } ?: state.current
|
||||||
|
Triple(
|
||||||
|
displayMode.shortLabel(this),
|
||||||
|
displayMode.iconDrawableRes(),
|
||||||
|
Tile.STATE_ACTIVE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tile.icon = Icon.createWithResource(this, iconRes)
|
||||||
|
tile.state = tileState
|
||||||
|
if (hasApiLevel(29)) {
|
||||||
|
tile.label = baseLabel
|
||||||
|
tile.subtitle = subtitle
|
||||||
|
} else {
|
||||||
|
// Pre-API 29 tiles can't show a subtitle; fold it into the label.
|
||||||
|
tile.label = "$baseLabel · $subtitle"
|
||||||
|
}
|
||||||
|
if (hasApiLevel(30)) {
|
||||||
|
tile.stateDescription = subtitle
|
||||||
|
}
|
||||||
|
tile.updateTile()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = logTag("Tile", "Anc")
|
||||||
|
@Volatile private var lastDestroyAt: Long? = null
|
||||||
|
|
||||||
|
private fun elapsedSinceLastDestroy(): String {
|
||||||
|
val destroyedAt = lastDestroyAt ?: return "n/a"
|
||||||
|
return "${SystemClock.elapsedRealtime() - destroyedAt}ms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.permissions.Permission
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.monitor.core.visibleAncModes
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure data → data mapper. Decides which [AncTileState] to render given a snapshot
|
||||||
|
* of inputs. Mirrors [AncWidgetRenderStateMapper]'s precedence so the two surfaces
|
||||||
|
* stay in sync, while remaining unit-testable without Android resources.
|
||||||
|
*
|
||||||
|
* Note: cached/offline-tier devices land in [AncTileState.NotConnected] (not
|
||||||
|
* [AncTileState.Connecting]) — without an active AAP session, "Connecting…" would
|
||||||
|
* be misleading because nothing is in progress.
|
||||||
|
*/
|
||||||
|
object AncTileStateMapper {
|
||||||
|
|
||||||
|
fun map(
|
||||||
|
device: PodDevice?,
|
||||||
|
isPro: Boolean,
|
||||||
|
isBluetoothEnabled: Boolean,
|
||||||
|
missingPermissions: Set<Permission>,
|
||||||
|
): AncTileState {
|
||||||
|
if (!isPro) return AncTileState.NotPro
|
||||||
|
if (missingPermissions.any { it.isTileBlocking }) return AncTileState.PermissionRequired
|
||||||
|
if (!isBluetoothEnabled) return AncTileState.BluetoothOff
|
||||||
|
if (device == null) return AncTileState.NoDevice
|
||||||
|
if (!device.hasAncControl) return AncTileState.NoAncSupport
|
||||||
|
if (!device.isAapConnected) return AncTileState.NotConnected
|
||||||
|
if (!device.isAapReady) return AncTileState.Connecting
|
||||||
|
|
||||||
|
val ancMode = device.ancMode ?: return AncTileState.Connecting
|
||||||
|
val visible = device.visibleAncModes
|
||||||
|
if (visible.isEmpty()) return AncTileState.Connecting
|
||||||
|
|
||||||
|
return AncTileState.Active(
|
||||||
|
current = ancMode.current,
|
||||||
|
pending = device.pendingAncMode,
|
||||||
|
visible = visible,
|
||||||
|
deviceLabel = device.label,
|
||||||
|
deviceAddress = device.address,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A permission whose absence prevents the tile from working. Scan-blocking permissions
|
||||||
|
* gate BLE; [Permission.BLUETOOTH_CONNECT] gates the AAP L2CAP socket — without either,
|
||||||
|
* the tile cannot do useful work.
|
||||||
|
*/
|
||||||
|
private val Permission.isTileBlocking: Boolean
|
||||||
|
get() = isScanBlocking || this == Permission.BLUETOOTH_CONNECT
|
||||||
|
|
||||||
|
sealed interface AncTileState {
|
||||||
|
data object NotPro : AncTileState
|
||||||
|
data object PermissionRequired : AncTileState
|
||||||
|
data object BluetoothOff : AncTileState
|
||||||
|
data object NoDevice : AncTileState
|
||||||
|
data object NoAncSupport : AncTileState
|
||||||
|
data object NotConnected : AncTileState
|
||||||
|
data object Connecting : AncTileState
|
||||||
|
data class Active(
|
||||||
|
val current: AapSetting.AncMode.Value,
|
||||||
|
val pending: AapSetting.AncMode.Value?,
|
||||||
|
val visible: List<AapSetting.AncMode.Value>,
|
||||||
|
val deviceLabel: String?,
|
||||||
|
val deviceAddress: BluetoothAddress?,
|
||||||
|
) : AncTileState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycles to the next mode in [visible]. Anchors on [pending] when it's still in
|
||||||
|
* [visible] so rapid taps walk forward through the list rather than oscillate against
|
||||||
|
* the device-echoed [current]. A [pending] that has been filtered out (e.g. user toggled
|
||||||
|
* Allow Off mid-cycle) falls through to [current].
|
||||||
|
*/
|
||||||
|
internal fun pickNextMode(
|
||||||
|
visible: List<AapSetting.AncMode.Value>,
|
||||||
|
current: AapSetting.AncMode.Value?,
|
||||||
|
pending: AapSetting.AncMode.Value?,
|
||||||
|
): AapSetting.AncMode.Value? {
|
||||||
|
if (visible.isEmpty()) return null
|
||||||
|
val anchor = pending?.takeIf { it in visible } ?: current ?: return visible.first()
|
||||||
|
val idx = visible.indexOf(anchor)
|
||||||
|
return if (idx < 0) visible.first() else visible[(idx + 1) % visible.size]
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
import eu.darken.capod.common.flow.combine
|
||||||
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.primaryByTier
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.flow.combine as combineFlows
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-scoped state holder for the ANC QS tile.
|
||||||
|
*
|
||||||
|
* TileService instances are short-lived and can disappear between taps. Keeping
|
||||||
|
* the latest state here lets a recreated service resolve clicks synchronously
|
||||||
|
* while the app process is alive. A cold or hydrating process intentionally
|
||||||
|
* reports [AncTileState.Connecting] so the tile UI is honest about not being
|
||||||
|
* ready for input yet.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AncTileStateStore @Inject constructor(
|
||||||
|
@AppScope private val appScope: CoroutineScope,
|
||||||
|
deviceMonitor: DeviceMonitor,
|
||||||
|
profilesRepo: DeviceProfilesRepo,
|
||||||
|
upgradeRepo: UpgradeRepo,
|
||||||
|
bluetoothManager: BluetoothManager2,
|
||||||
|
permissionTool: PermissionTool,
|
||||||
|
private val sendCoordinator: AncTileSendCoordinator,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val rawState: StateFlow<AncTileState> = combine(
|
||||||
|
deviceMonitor.devices,
|
||||||
|
profilesRepo.profiles,
|
||||||
|
upgradeRepo.upgradeInfo.map { it.isPro },
|
||||||
|
bluetoothManager.isBluetoothEnabled,
|
||||||
|
permissionTool.missingPermissions,
|
||||||
|
) { devices, profiles, isPro, isBluetoothEnabled, missingPermissions ->
|
||||||
|
val profileOrder = profiles.mapIndexed { idx, p -> p.id to idx }.toMap()
|
||||||
|
val device = devices.primaryByTier(profileOrder)
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = device,
|
||||||
|
isPro = isPro,
|
||||||
|
isBluetoothEnabled = isBluetoothEnabled,
|
||||||
|
missingPermissions = missingPermissions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(
|
||||||
|
scope = appScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = AncTileState.Connecting,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
appScope.launch {
|
||||||
|
rawState.collect { state -> sendCoordinator.acknowledgeDeviceState(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: StateFlow<AncTileState> = combineFlows(
|
||||||
|
rawState,
|
||||||
|
sendCoordinator.pendingModes,
|
||||||
|
) { rawState, _ ->
|
||||||
|
sendCoordinator.applyPendingTarget(rawState)
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(
|
||||||
|
scope = appScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = AncTileState.Connecting,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun currentState(): AncTileState = sendCoordinator.applyPendingTarget(rawState.value)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
|
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||||
import eu.darken.capod.main.ui.components.shortLabel
|
import eu.darken.capod.main.ui.components.shortLabel
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.visibleAncModes
|
import eu.darken.capod.monitor.core.visibleAncModes
|
||||||
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
|||||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||||
)
|
)
|
||||||
|
|
||||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
!device.isAapConnected -> {
|
||||||
theme = theme,
|
val secondaryRes = if (device.ble != null) {
|
||||||
resolvedBgColor = bgColor,
|
R.string.anc_widget_aap_not_connected_nearby_description
|
||||||
resolvedTextColor = textColor,
|
} else {
|
||||||
resolvedIconColor = iconColor,
|
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
}
|
||||||
)
|
AncWidgetRenderState.Message(
|
||||||
|
theme = theme,
|
||||||
|
resolvedBgColor = bgColor,
|
||||||
|
resolvedTextColor = textColor,
|
||||||
|
resolvedIconColor = iconColor,
|
||||||
|
primaryText = profileLabel ?: device.getLabel(context),
|
||||||
|
secondaryText = context.getString(secondaryRes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
@@ -121,10 +130,3 @@ object AncWidgetRenderStateMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
|
||||||
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
|
||||||
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
|
||||||
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
|
||||||
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.compose.Preview2
|
import eu.darken.capod.common.compose.Preview2
|
||||||
import eu.darken.capod.common.compose.PreviewWrapper
|
import eu.darken.capod.common.compose.PreviewWrapper
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
text = formatPercent(state.percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
@@ -311,7 +311,7 @@ private fun PodItemRow(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent.toBatteryOrNull()),
|
text = formatPercent(percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
@@ -354,7 +354,7 @@ private fun TinyPodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatPercent(percent.toBatteryOrNull()),
|
text = formatPercent(percent),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -382,9 +382,8 @@ private fun DeviceLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatPercent(percent: Float?): String {
|
private fun formatPercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
}
|
|
||||||
|
|
||||||
@Preview2
|
@Preview2
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.main.ui.MainActivity
|
import eu.darken.capod.main.ui.MainActivity
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
text = formatGlancePercent(state.percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||||
)
|
)
|
||||||
@@ -270,7 +270,7 @@ private fun GlancePodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
text = formatGlancePercent(percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||||
)
|
)
|
||||||
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
|
|||||||
colorFilter = iconTint,
|
colorFilter = iconTint,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
text = formatGlancePercent(percent),
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = GlanceModifier.padding(start = 4.dp),
|
modifier = GlanceModifier.padding(start = 4.dp),
|
||||||
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
|
|||||||
|
|
||||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||||
|
|
||||||
private fun formatGlancePercent(percent: Float?): String {
|
private fun formatGlancePercent(percent: Float): String =
|
||||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ internal data class WidgetDeviceKey(
|
|||||||
val profileId: String?,
|
val profileId: String?,
|
||||||
val profileLabel: String?,
|
val profileLabel: String?,
|
||||||
val model: PodModel,
|
val model: PodModel,
|
||||||
val batteryLeft: Float?,
|
val batteryLeft: Float,
|
||||||
val batteryRight: Float?,
|
val batteryRight: Float,
|
||||||
val batteryCase: Float?,
|
val batteryCase: Float,
|
||||||
val batteryHeadset: Float?,
|
val batteryHeadset: Float,
|
||||||
val isLeftPodCharging: Boolean?,
|
val isLeftPodCharging: Boolean?,
|
||||||
val isRightPodCharging: Boolean?,
|
val isRightPodCharging: Boolean?,
|
||||||
val isCaseCharging: Boolean?,
|
val isCaseCharging: Boolean?,
|
||||||
@@ -27,6 +27,7 @@ internal data class WidgetDeviceKey(
|
|||||||
val isBeingWorn: Boolean?,
|
val isBeingWorn: Boolean?,
|
||||||
val isAapConnected: Boolean,
|
val isAapConnected: Boolean,
|
||||||
val isAapReady: Boolean,
|
val isAapReady: Boolean,
|
||||||
|
val hasBleAdvertisement: Boolean,
|
||||||
val ancMode: AapSetting.AncMode.Value?,
|
val ancMode: AapSetting.AncMode.Value?,
|
||||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||||
@@ -49,6 +50,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
|||||||
isBeingWorn = isBeingWorn,
|
isBeingWorn = isBeingWorn,
|
||||||
isAapConnected = isAapConnected,
|
isAapConnected = isAapConnected,
|
||||||
isAapReady = isAapReady,
|
isAapReady = isAapReady,
|
||||||
|
hasBleAdvertisement = ble != null,
|
||||||
ancMode = ancMode?.current,
|
ancMode = ancMode?.current,
|
||||||
pendingAncMode = pendingAncMode,
|
pendingAncMode = pendingAncMode,
|
||||||
visibleAncModes = visibleAncModes,
|
visibleAncModes = visibleAncModes,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import eu.darken.capod.R
|
|||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
|
|
||||||
object WidgetRenderStateMapper {
|
object WidgetRenderStateMapper {
|
||||||
|
|
||||||
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
|
|||||||
layout = layout,
|
layout = layout,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
leftIcon = device.leftPodIcon,
|
leftIcon = device.leftPodIcon,
|
||||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
leftPercent = device.batteryLeft,
|
||||||
leftCharging = device.isLeftPodCharging == true,
|
leftCharging = device.isLeftPodCharging == true,
|
||||||
leftInEar = device.isLeftInEar == true,
|
leftInEar = device.isLeftInEar == true,
|
||||||
rightIcon = device.rightPodIcon,
|
rightIcon = device.rightPodIcon,
|
||||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
rightPercent = device.batteryRight,
|
||||||
rightCharging = device.isRightPodCharging == true,
|
rightCharging = device.isRightPodCharging == true,
|
||||||
rightInEar = device.isRightInEar == true,
|
rightInEar = device.isRightInEar == true,
|
||||||
caseIcon = device.caseIcon,
|
caseIcon = device.caseIcon,
|
||||||
casePercent = device.batteryCase.toBatteryFloat(),
|
casePercent = device.batteryCase,
|
||||||
caseCharging = device.isCaseCharging == true,
|
caseCharging = device.isCaseCharging == true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
|
|||||||
layout = layout,
|
layout = layout,
|
||||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||||
headsetIcon = device.iconRes,
|
headsetIcon = device.iconRes,
|
||||||
percent = device.batteryHeadset.toBatteryFloat(),
|
percent = device.batteryHeadset,
|
||||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||||
charging = device.isHeadsetBeingCharged == true,
|
charging = device.isHeadsetBeingCharged == true,
|
||||||
worn = device.isBeingWorn == true,
|
worn = device.isBeingWorn == true,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
|||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
import eu.darken.capod.common.coroutine.AppScope
|
||||||
|
import eu.darken.capod.common.debug.Bugs
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.replayingShare
|
import eu.darken.capod.common.flow.replayingShare
|
||||||
@@ -20,6 +23,7 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
|
|||||||
import eu.darken.capod.profiles.core.toReactionConfig
|
import eu.darken.capod.profiles.core.toReactionConfig
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
|
|||||||
connectedAddresses = connectedAddresses,
|
connectedAddresses = connectedAddresses,
|
||||||
)
|
)
|
||||||
}.onEach { liveState ->
|
}.onEach { liveState ->
|
||||||
persistLiveDevices(liveState.liveDevices)
|
persistLiveDevices(liveState.liveDevices + aapOnlyForPersistence(liveState))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AAP-only profiles whose state didn't make it into [LiveMergeState.liveDevices] (no BLE pod
|
||||||
|
* in the current scan). Without this, [persistLiveDevices] would only ever see BLE-backed
|
||||||
|
* devices, and AAP-delivered DeviceInfo (earbud serials, marketing version) for an out-of-BLE
|
||||||
|
* pod would never reach the cache even though the AAP socket is alive.
|
||||||
|
*/
|
||||||
|
private fun aapOnlyForPersistence(state: LiveMergeState): List<PodDevice> {
|
||||||
|
val coveredProfileIds = state.liveDevices.mapNotNull { it.profileId }.toSet()
|
||||||
|
return state.profiles.mapNotNull { profile ->
|
||||||
|
if (profile.id in coveredProfileIds) return@mapNotNull null
|
||||||
|
val aap = state.aapStates.forProfile(profile) ?: return@mapNotNull null
|
||||||
|
PodDevice(
|
||||||
|
profileId = profile.id,
|
||||||
|
label = profile.label,
|
||||||
|
ble = null,
|
||||||
|
aap = aap,
|
||||||
|
profileAddress = profile.address,
|
||||||
|
profileModel = profile.model,
|
||||||
|
profileKeyState = profile.toBleKeyState(),
|
||||||
|
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||||
|
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||||
|
reactions = profile.toReactionConfig(),
|
||||||
|
isSystemConnected = profile.address in state.connectedAddresses,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val devices: Flow<List<PodDevice>> = combine(
|
val devices: Flow<List<PodDevice>> = combine(
|
||||||
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
|
|||||||
dedupedLiveDevices + nonLiveDevices
|
dedupedLiveDevices + nonLiveDevices
|
||||||
}.replayingShare(appScope)
|
}.replayingShare(appScope)
|
||||||
|
|
||||||
|
private val reportedPersistFailures = mutableSetOf<String>()
|
||||||
|
|
||||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||||
for (device in devices) {
|
for (device in devices) {
|
||||||
val profileId = device.profileId ?: continue
|
val profileId = device.profileId ?: continue
|
||||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
try {
|
||||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||||
|
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||||
|
|
||||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||||
deviceStateCache.save(profileId, newState)
|
deviceStateCache.save(profileId, newState)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log(TAG, ERROR) { "Failed to persist state for $profileId: ${e.asLog()}" }
|
||||||
|
if (reportedPersistFailures.add(profileId)) {
|
||||||
|
runCatching { Bugs.report(tag = TAG, message = "persistLiveDevices failed for $profileId", exception = e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
|||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
@@ -138,18 +139,24 @@ data class PodDevice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Battery — AAP preferred, BLE fallback, then cached
|
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
|
||||||
val batteryLeft: Float?
|
// for unknown to keep the type primitive; this is the boundary that previously emitted
|
||||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
// Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
|
||||||
|
val batteryLeft: Float
|
||||||
|
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||||
|
?: cached?.left?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryRight: Float?
|
val batteryRight: Float
|
||||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||||
|
?: cached?.right?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryCase: Float?
|
val batteryCase: Float
|
||||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||||
|
?: cached?.case?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val batteryHeadset: Float?
|
val batteryHeadset: Float
|
||||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||||
|
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
/** True when at least one displayed battery value was filled from cache (not live). */
|
/** True when at least one displayed battery value was filled from cache (not live). */
|
||||||
val isBatteryCached: Boolean
|
val isBatteryCached: Boolean
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection tier rank used to sort devices by user-perceived "primary":
|
||||||
|
* lower rank = higher priority. System-connected devices come first, then
|
||||||
|
* any live device (BLE or AAP), then profiled-but-offline.
|
||||||
|
*
|
||||||
|
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
|
||||||
|
* non-tier-ranked for reaction flows that want "any profiled device".
|
||||||
|
*/
|
||||||
|
fun PodDevice.tierRank(): Int = when {
|
||||||
|
isSystemConnected -> 0
|
||||||
|
isLive -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the user-perceived primary profiled device: lowest [tierRank], with
|
||||||
|
* the user's profile-list order as the tiebreaker.
|
||||||
|
*/
|
||||||
|
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
|
||||||
|
filter { it.profileId != null }
|
||||||
|
.minWithOrNull(
|
||||||
|
compareBy<PodDevice> { it.tierRank() }
|
||||||
|
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||||
|
)
|
||||||
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.mapLatest
|
|||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||||
try {
|
try {
|
||||||
withTimeout(CONNECT_TIMEOUT) {
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
|
||||||
}
|
|
||||||
log(TAG) { "AAP connected to $address" }
|
log(TAG) { "AAP connected to $address" }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||||
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||||
withTimeout(CONNECT_TIMEOUT) {
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
|
||||||
}
|
|
||||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||||
break
|
break
|
||||||
} catch (retryException: Exception) {
|
} catch (retryException: Exception) {
|
||||||
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||||
withTimeout(CONNECT_TIMEOUT) {
|
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||||
aapManager.connect(address, bonded.internal, profile.model)
|
|
||||||
}
|
|
||||||
log(TAG) { "AAP reconnected to $address" }
|
log(TAG) { "AAP reconnected to $address" }
|
||||||
break
|
break
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -218,7 +211,7 @@ class AapAutoConnect @Inject constructor(
|
|||||||
val bonded = bluetoothManager.bondedDevices().first()
|
val bonded = bluetoothManager.bondedDevices().first()
|
||||||
.firstOrNull { it.address == address }
|
.firstOrNull { it.address == address }
|
||||||
if (bonded != null) {
|
if (bonded != null) {
|
||||||
aapManager.connect(address, bonded.internal, detectedModel)
|
aapManager.connect(address, bonded.internal!!, detectedModel)
|
||||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Monitor", "AapAutoConnect")
|
private val TAG = logTag("Monitor", "AapAutoConnect")
|
||||||
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||||
private val CONNECT_TIMEOUT = 5.seconds
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
@@ -51,7 +53,7 @@ class BlePodMonitor @Inject constructor(
|
|||||||
private val generalSettings: GeneralSettings,
|
private val generalSettings: GeneralSettings,
|
||||||
bluetoothManager: BluetoothManager2,
|
bluetoothManager: BluetoothManager2,
|
||||||
private val debugSettings: DebugSettings,
|
private val debugSettings: DebugSettings,
|
||||||
permissionTool: PermissionTool,
|
private val permissionTool: PermissionTool,
|
||||||
private val profilesRepo: DeviceProfilesRepo,
|
private val profilesRepo: DeviceProfilesRepo,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -89,8 +91,10 @@ class BlePodMonitor @Inject constructor(
|
|||||||
log(
|
log(
|
||||||
TAG,
|
TAG,
|
||||||
Logging.Priority.WARN
|
Logging.Priority.WARN
|
||||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
|
||||||
false
|
permissionTool.recheck()
|
||||||
|
delay(3000)
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||||
delay(3000)
|
delay(3000)
|
||||||
@@ -160,13 +164,27 @@ class BlePodMonitor @Inject constructor(
|
|||||||
else -> ProximityPairing.getBleScanFilter()
|
else -> ProximityPairing.getBleScanFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
bleScanner.scan(
|
flow {
|
||||||
filters = filters,
|
emitAll(
|
||||||
scannerMode = options.scannerMode,
|
bleScanner.scan(
|
||||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
filters = filters,
|
||||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
scannerMode = options.scannerMode,
|
||||||
disableDirectScanCallback = options.disableDirectCallback,
|
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||||
)
|
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||||
|
disableDirectScanCallback = options.disableDirectCallback,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.catch { cause ->
|
||||||
|
if (cause is SecurityException) {
|
||||||
|
log(TAG, Logging.Priority.WARN) {
|
||||||
|
"BLE scanner failed due to missing permission, rechecking permissions: ${cause.asLog()}"
|
||||||
|
}
|
||||||
|
permissionTool.recheck()
|
||||||
|
emit(emptyList())
|
||||||
|
} else {
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.map { it.onlyNewAndUnique() }
|
.map { it.onlyNewAndUnique() }
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ data class CachedDeviceState(
|
|||||||
) {
|
) {
|
||||||
val deviceInfo: AapDeviceInfo?
|
val deviceInfo: AapDeviceInfo?
|
||||||
get() {
|
get() {
|
||||||
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
|
if (deviceName == null && serialNumber == null && firmwareVersion == null
|
||||||
|
&& leftEarbudSerial == null && rightEarbudSerial == null && marketingVersion == null
|
||||||
|
) return null
|
||||||
return AapDeviceInfo(
|
return AapDeviceInfo(
|
||||||
name = deviceName ?: "",
|
name = deviceName ?: "",
|
||||||
modelNumber = "",
|
modelNumber = "",
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
|
|||||||
import eu.darken.capod.common.SystemTimeSource
|
import eu.darken.capod.common.SystemTimeSource
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ import java.time.Instant
|
|||||||
* Returns null if:
|
* Returns null if:
|
||||||
* - The device is not live (cached-only)
|
* - The device is not live (cached-only)
|
||||||
* - The device has no profile
|
* - The device has no profile
|
||||||
* - All live battery values are null
|
* - All live battery values AND live DeviceInfo are null (nothing fresh to persist)
|
||||||
* - The state hasn't changed from [existing] (dedup)
|
* - The state hasn't changed from [existing] (dedup)
|
||||||
*/
|
*/
|
||||||
fun PodDevice.toCachedState(
|
fun PodDevice.toCachedState(
|
||||||
@@ -24,15 +26,18 @@ fun PodDevice.toCachedState(
|
|||||||
if (!isLive) return null
|
if (!isLive) return null
|
||||||
val pid = profileId ?: return null
|
val pid = profileId ?: return null
|
||||||
|
|
||||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
|
||||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
|
||||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
|
||||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
|
||||||
|
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
|
||||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
|
||||||
|
|
||||||
val liveDeviceInfo = aap?.deviceInfo
|
val liveDeviceInfo = aap?.deviceInfo
|
||||||
|
|
||||||
|
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
|
||||||
|
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
|
||||||
|
) return null
|
||||||
|
|
||||||
val newState = CachedDeviceState(
|
val newState = CachedDeviceState(
|
||||||
profileId = pid,
|
profileId = pid,
|
||||||
model = model,
|
model = model,
|
||||||
@@ -60,40 +65,37 @@ fun PodDevice.toCachedState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeBatterySlot(
|
private fun mergeBatterySlot(
|
||||||
livePercent: Float?,
|
livePercent: Float,
|
||||||
existing: CachedBatterySlot?,
|
existing: CachedBatterySlot?,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
): CachedBatterySlot? {
|
): CachedBatterySlot? {
|
||||||
if (livePercent == null) return existing
|
if (!isKnownBattery(livePercent)) return existing
|
||||||
if (existing == null) return CachedBatterySlot(livePercent, now)
|
val current = existing ?: return CachedBatterySlot(livePercent, now)
|
||||||
|
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||||
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
|
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
|
||||||
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||||
if (hasSlotTimestampChanged(old.left, new.left)) return true
|
if (hasSlotChanged(old.left, new.left)) return true
|
||||||
if (hasSlotTimestampChanged(old.right, new.right)) return true
|
if (hasSlotChanged(old.right, new.right)) return true
|
||||||
if (hasSlotTimestampChanged(old.case, new.case)) return true
|
if (hasSlotChanged(old.case, new.case)) return true
|
||||||
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
|
if (hasSlotChanged(old.headset, new.headset)) return true
|
||||||
return old.left?.percent != new.left?.percent
|
return old.isLeftCharging != new.isLeftCharging
|
||||||
|| old.right?.percent != new.right?.percent
|
|
||||||
|| old.case?.percent != new.case?.percent
|
|
||||||
|| old.headset?.percent != new.headset?.percent
|
|
||||||
|| old.isLeftCharging != new.isLeftCharging
|
|
||||||
|| old.isRightCharging != new.isRightCharging
|
|| old.isRightCharging != new.isRightCharging
|
||||||
|| old.isCaseCharging != new.isCaseCharging
|
|| old.isCaseCharging != new.isCaseCharging
|
||||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||||
|| old.deviceName != new.deviceName
|
|| old.deviceName != new.deviceName
|
||||||
|| old.serialNumber != new.serialNumber
|
|| old.serialNumber != new.serialNumber
|
||||||
|| old.firmwareVersion != new.firmwareVersion
|
|| old.firmwareVersion != new.firmwareVersion
|
||||||
|
|| old.leftEarbudSerial != new.leftEarbudSerial
|
||||||
|
|| old.rightEarbudSerial != new.rightEarbudSerial
|
||||||
|
|| old.marketingVersion != new.marketingVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasSlotTimestampChanged(
|
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
|
||||||
old: CachedBatterySlot?,
|
if (old == null && new == null) return false
|
||||||
new: CachedBatterySlot?,
|
if (old == null || new == null) return true
|
||||||
): Boolean {
|
if (old.percent != new.percent) return true
|
||||||
if (old == null || new == null) return false
|
|
||||||
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
|
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
|||||||
setTextViewText(R.id.device, device.getLabel(context))
|
setTextViewText(R.id.device, device.getLabel(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun percentToInt(percent: Float?): Int {
|
private fun percentToInt(percent: Float): Int =
|
||||||
if (percent == null) return 0
|
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
|||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -26,7 +27,11 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a single AAP L2CAP connection to a device.
|
* Manages a single AAP L2CAP connection to a device.
|
||||||
@@ -39,6 +44,7 @@ internal class AapConnection(
|
|||||||
private val profile: AapDeviceProfile,
|
private val profile: AapDeviceProfile,
|
||||||
private val socketFactory: L2capSocketFactory,
|
private val socketFactory: L2capSocketFactory,
|
||||||
timeSource: TimeSource,
|
timeSource: TimeSource,
|
||||||
|
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val engine = AapSessionEngine(profile, timeSource)
|
private val engine = AapSessionEngine(profile, timeSource)
|
||||||
@@ -68,7 +74,7 @@ internal class AapConnection(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val sock = socketFactory.createSocket(device, PSM)
|
val sock = socketFactory.createSocket(device, PSM)
|
||||||
sock.connect()
|
sock.connectCancellable()
|
||||||
socket = sock
|
socket = sock
|
||||||
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
||||||
|
|
||||||
@@ -212,15 +218,47 @@ internal class AapConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanupSocket() {
|
private fun cleanupSocket() {
|
||||||
|
socket?.closeQuietly()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun BluetoothSocket.connectCancellable() {
|
||||||
|
val result = CompletableDeferred<Result<Unit>>()
|
||||||
|
val cancelled = AtomicBoolean(false)
|
||||||
|
val connectThread = Thread(
|
||||||
|
{
|
||||||
|
val outcome = runCatching { connect() }
|
||||||
|
result.complete(outcome)
|
||||||
|
if (cancelled.get() && outcome.isSuccess) closeQuietly()
|
||||||
|
},
|
||||||
|
"AAP-L2CAP-connect-${device.address}",
|
||||||
|
).apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket?.close()
|
withTimeout(connectTimeout) {
|
||||||
|
result.await().getOrThrow()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cancelled.set(true)
|
||||||
|
closeQuietly()
|
||||||
|
connectThread.interrupt()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BluetoothSocket.closeQuietly() {
|
||||||
|
try {
|
||||||
|
close()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
socket = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PSM = 0x1001
|
private const val PSM = 0x1001
|
||||||
|
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||||
private val TAG = logTag("AAP", "Connection")
|
private val TAG = logTag("AAP", "Connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
const val BATTERY_UNKNOWN = -1f
|
const val BATTERY_UNKNOWN = -1f
|
||||||
|
|
||||||
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
|
fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f
|
||||||
|
|
||||||
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
|
fun batteryProgress(percent: Float): Float =
|
||||||
|
if (isKnownBattery(percent)) percent.coerceIn(0f, 1f) else 0f
|
||||||
|
|
||||||
fun formatBatteryPercent(context: Context, percent: Float?): String =
|
fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
else context.getString(R.string.general_value_not_available_label)
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
fun getBatteryDrawable(percent: Float): Int = when {
|
||||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
||||||
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
||||||
@@ -42,8 +43,8 @@ fun getBatteryDrawable(percent: Float?): Int = when {
|
|||||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
else -> R.drawable.ic_baseline_battery_0_bar_24
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBatteryIcon(percent: Float?): ImageVector = when {
|
fun getBatteryIcon(percent: Float): ImageVector = when {
|
||||||
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||||
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
||||||
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
||||||
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ import eu.darken.capod.monitor.core.PodDevice
|
|||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
|
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
|
||||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PopUpContent(
|
fun PopUpContent(
|
||||||
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
// Left pod
|
// Left pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.leftPodIcon,
|
iconRes = device.leftPodIcon,
|
||||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
batteryPercent = device.batteryLeft,
|
||||||
isCharging = device.isLeftPodCharging ?: false,
|
isCharging = device.isLeftPodCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
if (device.hasCase) {
|
if (device.hasCase) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.caseIcon,
|
iconRes = device.caseIcon,
|
||||||
batteryPercent = device.batteryCase.toBatteryFloat(),
|
batteryPercent = device.batteryCase,
|
||||||
isCharging = device.isCaseCharging ?: false,
|
isCharging = device.isCaseCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
// Right pod
|
// Right pod
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.rightPodIcon,
|
iconRes = device.rightPodIcon,
|
||||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
batteryPercent = device.batteryRight,
|
||||||
isCharging = device.isRightPodCharging ?: false,
|
isCharging = device.isRightPodCharging ?: false,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
|
|||||||
private fun SinglePodContent(device: PodDevice) {
|
private fun SinglePodContent(device: PodDevice) {
|
||||||
BatteryColumn(
|
BatteryColumn(
|
||||||
iconRes = device.iconRes,
|
iconRes = device.iconRes,
|
||||||
batteryPercent = device.batteryHeadset.toBatteryFloat(),
|
batteryPercent = device.batteryHeadset,
|
||||||
isCharging = device.isHeadsetBeingCharged ?: false,
|
isCharging = device.isHeadsetBeingCharged ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,7 +155,6 @@ private fun BatteryColumn(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val nullablePercent = batteryPercent.toBatteryOrNull()
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -184,14 +181,14 @@ private fun BatteryColumn(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = getBatteryIcon(nullablePercent),
|
imageVector = getBatteryIcon(batteryPercent),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = formatBatteryPercent(context, nullablePercent),
|
text = formatBatteryPercent(context, batteryPercent),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -157,7 +157,14 @@
|
|||||||
<string name="anc_widget_config_aap_required_hint">This widget requires a direct connection (AAP) to your device. Make sure your device is paired and connected in the app before using this widget.</string>
|
<string name="anc_widget_config_aap_required_hint">This widget requires a direct connection (AAP) to your device. Make sure your device is paired and connected in the app before using this widget.</string>
|
||||||
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
|
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
|
||||||
<string name="anc_widget_aap_not_connected_label">Not connected</string>
|
<string name="anc_widget_aap_not_connected_label">Not connected</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_nearby_description">Nearby but not connected</string>
|
||||||
|
<string name="anc_widget_aap_not_connected_not_nearby_description">Not nearby</string>
|
||||||
<string name="anc_widget_aap_connecting_label">Connecting…</string>
|
<string name="anc_widget_aap_connecting_label">Connecting…</string>
|
||||||
|
<string name="tile_anc_label">Noise Control</string>
|
||||||
|
<string name="tile_anc_subtitle_permission_required">Permission required</string>
|
||||||
|
<string name="tile_anc_subtitle_no_device">No device</string>
|
||||||
|
<string name="tile_anc_subtitle_no_anc_support">No noise control</string>
|
||||||
|
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth off</string>
|
||||||
<string name="widget_config_screen_title">Widget Configuration</string>
|
<string name="widget_config_screen_title">Widget Configuration</string>
|
||||||
<string name="widget_configuration_title">Select Device</string>
|
<string name="widget_configuration_title">Select Device</string>
|
||||||
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
|
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ fun DashboardLight() = DashboardContent()
|
|||||||
@PreviewTest
|
@PreviewTest
|
||||||
@PlayStoreLocalesDark
|
@PlayStoreLocalesDark
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardDark() = DashboardContent()
|
fun DashboardDark() = DashboardContent(showAap = true)
|
||||||
|
|
||||||
@PreviewTest
|
@PreviewTest
|
||||||
@PlayStoreLocales
|
@PlayStoreLocales
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package eu.darken.capod
|
||||||
|
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
class CapodUncaughtExceptionHandlerTest : BaseTest() {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `suppresses first main thread foreground service timing exception`() {
|
||||||
|
val mainThread = Thread.currentThread()
|
||||||
|
val previousHandler = RecordingHandler()
|
||||||
|
val reports = mutableListOf<Throwable>()
|
||||||
|
var loopCalls = 0
|
||||||
|
val handler = CapodUncaughtExceptionHandler(
|
||||||
|
previousHandler = previousHandler,
|
||||||
|
mainThreadProvider = { mainThread },
|
||||||
|
loopMainThread = { loopCalls++ },
|
||||||
|
reportForegroundServiceTimingException = { reports += it },
|
||||||
|
exit = { throw AssertionError("exitProcess($it)") },
|
||||||
|
)
|
||||||
|
val throwable = ForegroundServiceDidNotStartInTimeException()
|
||||||
|
|
||||||
|
handler.uncaughtException(mainThread, throwable)
|
||||||
|
|
||||||
|
loopCalls shouldBe 1
|
||||||
|
reports shouldBe listOf(throwable)
|
||||||
|
previousHandler.throwables shouldBe emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delegates repeated main thread foreground service timing exception`() {
|
||||||
|
val mainThread = Thread.currentThread()
|
||||||
|
val previousHandler = RecordingHandler()
|
||||||
|
val reports = mutableListOf<Throwable>()
|
||||||
|
var loopCalls = 0
|
||||||
|
val handler = CapodUncaughtExceptionHandler(
|
||||||
|
previousHandler = previousHandler,
|
||||||
|
mainThreadProvider = { mainThread },
|
||||||
|
loopMainThread = { loopCalls++ },
|
||||||
|
reportForegroundServiceTimingException = { reports += it },
|
||||||
|
exit = { throw AssertionError("exitProcess($it)") },
|
||||||
|
)
|
||||||
|
val first = ForegroundServiceDidNotStartInTimeException()
|
||||||
|
val second = ForegroundServiceDidNotStartInTimeException()
|
||||||
|
|
||||||
|
handler.uncaughtException(mainThread, first)
|
||||||
|
handler.uncaughtException(mainThread, second)
|
||||||
|
|
||||||
|
loopCalls shouldBe 1
|
||||||
|
reports shouldBe listOf(first)
|
||||||
|
previousHandler.throwables shouldBe listOf(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delegates foreground service timing exception from non-main thread`() {
|
||||||
|
val mainThread = Thread.currentThread()
|
||||||
|
val workerThread = Thread()
|
||||||
|
val previousHandler = RecordingHandler()
|
||||||
|
val handler = CapodUncaughtExceptionHandler(
|
||||||
|
previousHandler = previousHandler,
|
||||||
|
mainThreadProvider = { mainThread },
|
||||||
|
loopMainThread = { throw AssertionError("loopMainThread should not run") },
|
||||||
|
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
|
||||||
|
exit = { throw AssertionError("exitProcess($it)") },
|
||||||
|
)
|
||||||
|
val throwable = ForegroundServiceDidNotStartInTimeException()
|
||||||
|
|
||||||
|
handler.uncaughtException(workerThread, throwable)
|
||||||
|
|
||||||
|
previousHandler.throwables shouldBe listOf(throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delegates unrelated main thread exception`() {
|
||||||
|
val mainThread = Thread.currentThread()
|
||||||
|
val previousHandler = RecordingHandler()
|
||||||
|
val handler = CapodUncaughtExceptionHandler(
|
||||||
|
previousHandler = previousHandler,
|
||||||
|
mainThreadProvider = { mainThread },
|
||||||
|
loopMainThread = { throw AssertionError("loopMainThread should not run") },
|
||||||
|
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
|
||||||
|
exit = { throw AssertionError("exitProcess($it)") },
|
||||||
|
)
|
||||||
|
val throwable = IllegalStateException("boom")
|
||||||
|
|
||||||
|
handler.uncaughtException(mainThread, throwable)
|
||||||
|
|
||||||
|
previousHandler.throwables shouldBe listOf(throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cancels before delegating fatal exception`() {
|
||||||
|
val mainThread = Thread.currentThread()
|
||||||
|
val events = mutableListOf<String>()
|
||||||
|
val previousHandler = object : Thread.UncaughtExceptionHandler {
|
||||||
|
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||||
|
events += "delegate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val throwable = IllegalStateException("boom")
|
||||||
|
val handler = CapodUncaughtExceptionHandler(
|
||||||
|
previousHandler = previousHandler,
|
||||||
|
mainThreadProvider = { mainThread },
|
||||||
|
loopMainThread = { throw AssertionError("loopMainThread should not run") },
|
||||||
|
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
|
||||||
|
cancelBeforeDelegate = { events += "cancel" },
|
||||||
|
exit = { throw AssertionError("exitProcess($it)") },
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.uncaughtException(mainThread, throwable)
|
||||||
|
|
||||||
|
events shouldBe listOf("cancel", "delegate")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delegates loop failure after suppression`() {
|
||||||
|
val mainThread = Thread.currentThread()
|
||||||
|
val previousHandler = RecordingHandler()
|
||||||
|
val loopFailure = IllegalStateException("loop failed")
|
||||||
|
val handler = CapodUncaughtExceptionHandler(
|
||||||
|
previousHandler = previousHandler,
|
||||||
|
mainThreadProvider = { mainThread },
|
||||||
|
loopMainThread = { throw loopFailure },
|
||||||
|
reportForegroundServiceTimingException = {},
|
||||||
|
exit = { throw AssertionError("exitProcess($it)") },
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.uncaughtException(mainThread, ForegroundServiceDidNotStartInTimeException())
|
||||||
|
|
||||||
|
previousHandler.throwables shouldBe listOf(loopFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingHandler : Thread.UncaughtExceptionHandler {
|
||||||
|
val throwables = mutableListOf<Throwable>()
|
||||||
|
|
||||||
|
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||||
|
throwables += throwable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ForegroundServiceDidNotStartInTimeException : RuntimeException("timed out")
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package eu.darken.capod.common.debug
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
|
import io.kotest.assertions.throwables.shouldNotThrowAny
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
class BugsTest : BaseTest() {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanup() {
|
||||||
|
Bugs.reporter = null
|
||||||
|
Logging.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `report does not throw if logging fails`() {
|
||||||
|
Logging.clearAll()
|
||||||
|
Logging.install(ThrowingLogger())
|
||||||
|
|
||||||
|
shouldNotThrowAny {
|
||||||
|
Bugs.report(TAG, "Something failed", HostileThrowable())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `report does not throw if reporter fails`() {
|
||||||
|
var notified = false
|
||||||
|
Bugs.reporter = object : AutomaticBugReporter {
|
||||||
|
override fun setup(application: Application) = Unit
|
||||||
|
|
||||||
|
override fun notify(throwable: Throwable) {
|
||||||
|
notified = true
|
||||||
|
throw IllegalStateException("reporter failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldNotThrowAny {
|
||||||
|
Bugs.report(TAG, "Something failed", IllegalStateException("boom"))
|
||||||
|
}
|
||||||
|
notified shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HostileThrowable : Throwable() {
|
||||||
|
override val message: String?
|
||||||
|
get() = throw IllegalStateException("message failed")
|
||||||
|
|
||||||
|
override fun toString(): String = throw IllegalStateException("toString failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ThrowingLogger : Logging.Logger {
|
||||||
|
override fun isLoggable(priority: Logging.Priority): Boolean = true
|
||||||
|
|
||||||
|
override fun log(
|
||||||
|
priority: Logging.Priority,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
metaData: Map<String, Any>?
|
||||||
|
) {
|
||||||
|
throw IllegalStateException("log failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TEST"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package eu.darken.capod.common.debug.logging
|
||||||
|
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
class LoggingTest : BaseTest() {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `asLog renders normal throwable`() {
|
||||||
|
val log = IllegalStateException("boom").asLog()
|
||||||
|
|
||||||
|
log.contains("java.lang.IllegalStateException: boom") shouldBe true
|
||||||
|
log.contains("LoggingTest") shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `asLog falls back when throwable rendering fails`() {
|
||||||
|
val log = HostileThrowable().asLog()
|
||||||
|
|
||||||
|
log.contains("HostileThrowable") shouldBe true
|
||||||
|
log.contains("stacktrace unavailable") shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logInternal ignores logger failures`() {
|
||||||
|
Logging.install(ThrowingLogger())
|
||||||
|
|
||||||
|
log("TEST") { "message" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HostileThrowable : Throwable() {
|
||||||
|
override val message: String?
|
||||||
|
get() = throw IllegalStateException("message failed")
|
||||||
|
|
||||||
|
override fun toString(): String = throw IllegalStateException("toString failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ThrowingLogger : Logging.Logger {
|
||||||
|
override fun isLoggable(priority: Logging.Priority): Boolean {
|
||||||
|
throw IllegalStateException("isLoggable failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun log(
|
||||||
|
priority: Logging.Priority,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
metaData: Map<String, Any>?
|
||||||
|
) {
|
||||||
|
throw IllegalStateException("log failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
class AncTileCycleTest : BaseTest() {
|
||||||
|
|
||||||
|
private val off = AapSetting.AncMode.Value.OFF
|
||||||
|
private val on = AapSetting.AncMode.Value.ON
|
||||||
|
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
|
||||||
|
private val ad = AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty visible list returns null`() {
|
||||||
|
pickNextMode(visible = emptyList(), current = on, pending = null) shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single visible mode returns same mode`() {
|
||||||
|
pickNextMode(visible = listOf(on), current = on, pending = null) shouldBe on
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `current null and non-empty visible returns first`() {
|
||||||
|
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = null) shouldBe off
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wraps around at end of list`() {
|
||||||
|
pickNextMode(visible = listOf(off, tx, ad), current = ad, pending = null) shouldBe off
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `advances through middle of list`() {
|
||||||
|
pickNextMode(visible = listOf(off, tx, ad), current = tx, pending = null) shouldBe ad
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending wins over current when pending is in visible`() {
|
||||||
|
// current=off, pending=tx → next is ad (anchor on pending so rapid taps walk forward)
|
||||||
|
pickNextMode(visible = listOf(off, tx, ad), current = off, pending = tx) shouldBe ad
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending falls through to current when pending was filtered out`() {
|
||||||
|
// pending=off but visible no longer contains off → fall through to current=tx → next is ad
|
||||||
|
pickNextMode(visible = listOf(tx, ad), current = tx, pending = off) shouldBe ad
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `current not in visible falls through to first`() {
|
||||||
|
// current=on but visible doesn't contain it → start at first (off)
|
||||||
|
pickNextMode(visible = listOf(off, tx, ad), current = on, pending = null) shouldBe off
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending not visible and current null falls back to first`() {
|
||||||
|
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = on) shouldBe off
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class AncTileSendCoordinatorTest : BaseTest() {
|
||||||
|
|
||||||
|
private val address = "00:11:22:33:44:55"
|
||||||
|
private val off = AapSetting.AncMode.Value.OFF
|
||||||
|
private val on = AapSetting.AncMode.Value.ON
|
||||||
|
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
|
||||||
|
private val ad = AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
private val visible = listOf(off, on, tx, ad)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending target is visible immediately and survives service restart state`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
|
||||||
|
val rendered = coordinator.applyPendingTarget(active(current = off, pending = null))
|
||||||
|
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
rendered.pending shouldBe tx
|
||||||
|
pickNextMode(rendered.visible, rendered.current, rendered.pending) shouldBe ad
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replacing pending send dispatches only latest target`() = runTest {
|
||||||
|
val aapManager = mockk<AapConnectionManager>(relaxed = true)
|
||||||
|
val coordinator = coordinator(aapManager)
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
coordinator.pendingModes.value[address] shouldBe tx
|
||||||
|
advanceTimeBy(999)
|
||||||
|
runCurrent()
|
||||||
|
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
|
||||||
|
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, ad, debounce = 1.seconds)
|
||||||
|
coordinator.pendingModes.value[address] shouldBe ad
|
||||||
|
advanceTimeBy(999)
|
||||||
|
runCurrent()
|
||||||
|
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
|
||||||
|
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
|
||||||
|
|
||||||
|
advanceTimeBy(1)
|
||||||
|
runCurrent()
|
||||||
|
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
|
||||||
|
coVerify(exactly = 1) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applying device pending confirmation is pure and keeps rendered pending mode`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
|
||||||
|
val rendered = coordinator.applyPendingTarget(active(current = off, pending = tx))
|
||||||
|
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
rendered.pending shouldBe tx
|
||||||
|
coordinator.pendingModes.value[address] shouldBe tx
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `acknowledging device pending confirmation clears process target`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
|
||||||
|
coordinator.acknowledgeDeviceState(active(current = off, pending = tx))
|
||||||
|
|
||||||
|
coordinator.pendingModes.value[address] shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applying device current confirmation is pure`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
|
||||||
|
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = null))
|
||||||
|
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
rendered.pending shouldBe null
|
||||||
|
coordinator.pendingModes.value[address] shouldBe tx
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `acknowledging device current confirmation clears process target`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
|
||||||
|
coordinator.acknowledgeDeviceState(active(current = tx, pending = null))
|
||||||
|
|
||||||
|
coordinator.pendingModes.value[address] shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `target matching current is kept while device reports different pending mode`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
|
||||||
|
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = off))
|
||||||
|
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
rendered.pending shouldBe tx
|
||||||
|
coordinator.pendingModes.value[address] shouldBe tx
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applying target filtered out of visible modes is pure`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
|
||||||
|
|
||||||
|
val rendered = coordinator.applyPendingTarget(active(current = on, pending = null, visible = listOf(on, tx, ad)))
|
||||||
|
rendered.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
rendered.pending shouldBe null
|
||||||
|
coordinator.pendingModes.value[address] shouldBe off
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `acknowledging target filtered out of visible modes clears process target`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
|
||||||
|
|
||||||
|
coordinator.acknowledgeDeviceState(active(current = on, pending = null, visible = listOf(on, tx, ad)))
|
||||||
|
|
||||||
|
coordinator.pendingModes.value[address] shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending target clears after timeout without confirmation`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds, timeout = 5.seconds)
|
||||||
|
coordinator.pendingModes.value[address] shouldBe tx
|
||||||
|
|
||||||
|
advanceTimeBy(5_000)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
coordinator.pendingModes.value[address] shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send failure clears pending target`() = runTest {
|
||||||
|
val aapManager = mockk<AapConnectionManager>(relaxed = true)
|
||||||
|
coEvery {
|
||||||
|
aapManager.sendCommand(address, AapCommand.SetAncMode(tx))
|
||||||
|
} throws IllegalStateException("not connected")
|
||||||
|
val coordinator = coordinator(aapManager)
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
|
||||||
|
coordinator.pendingModes.value[address] shouldBe tx
|
||||||
|
|
||||||
|
advanceTimeBy(1_000)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
coordinator.pendingModes.value[address] shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.coordinator(
|
||||||
|
aapManager: AapConnectionManager = mockk(relaxed = true),
|
||||||
|
): AncTileSendCoordinator = AncTileSendCoordinator(
|
||||||
|
appScope = backgroundScope,
|
||||||
|
aapManager = aapManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun active(
|
||||||
|
current: AapSetting.AncMode.Value,
|
||||||
|
pending: AapSetting.AncMode.Value?,
|
||||||
|
visible: List<AapSetting.AncMode.Value> = this.visible,
|
||||||
|
) = AncTileState.Active(
|
||||||
|
current = current,
|
||||||
|
pending = pending,
|
||||||
|
visible = visible,
|
||||||
|
deviceLabel = "Pods",
|
||||||
|
deviceAddress = address,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.permissions.Permission
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
class AncTileStateMapperTest : BaseTest() {
|
||||||
|
|
||||||
|
private val noPermissions = emptySet<Permission>()
|
||||||
|
private val supportedModes = listOf(
|
||||||
|
AapSetting.AncMode.Value.OFF,
|
||||||
|
AapSetting.AncMode.Value.ON,
|
||||||
|
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||||
|
AapSetting.AncMode.Value.ADAPTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun activeDevice(
|
||||||
|
currentMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON,
|
||||||
|
pendingMode: AapSetting.AncMode.Value? = null,
|
||||||
|
connectionState: AapPodState.ConnectionState = AapPodState.ConnectionState.READY,
|
||||||
|
model: PodModel = PodModel.AIRPODS_PRO,
|
||||||
|
): PodDevice {
|
||||||
|
val ancSetting = AapSetting.AncMode(current = currentMode, supported = supportedModes)
|
||||||
|
return PodDevice(
|
||||||
|
profileId = "p1",
|
||||||
|
ble = null,
|
||||||
|
aap = AapPodState(
|
||||||
|
connectionState = connectionState,
|
||||||
|
settings = mapOf(AapSetting.AncMode::class to ancSetting),
|
||||||
|
pendingAncMode = pendingMode,
|
||||||
|
),
|
||||||
|
profileModel = model,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `not pro returns NotPro regardless of other state`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = activeDevice(),
|
||||||
|
isPro = false,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.NotPro
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing scan permission returns PermissionRequired`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = activeDevice(),
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
|
||||||
|
) shouldBe AncTileState.PermissionRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing BLUETOOTH_CONNECT returns PermissionRequired`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = activeDevice(),
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = setOf(Permission.BLUETOOTH_CONNECT),
|
||||||
|
) shouldBe AncTileState.PermissionRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing non-blocking permission does not flip state`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = activeDevice(),
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = setOf(Permission.POST_NOTIFICATIONS),
|
||||||
|
).shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `bluetooth disabled returns BluetoothOff`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = activeDevice(),
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = false,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.BluetoothOff
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null device returns NoDevice`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = null,
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.NoDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `device without ANC support returns NoAncSupport`() {
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "p1",
|
||||||
|
ble = null,
|
||||||
|
aap = null,
|
||||||
|
profileModel = PodModel.AIRPODS_GEN1,
|
||||||
|
)
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = device,
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.NoAncSupport
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cached device with no AAP session returns NotConnected`() {
|
||||||
|
// ANC-capable model but aap == null → user sees "Disconnected", not "Connecting forever".
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "p1",
|
||||||
|
ble = null,
|
||||||
|
aap = null,
|
||||||
|
profileModel = PodModel.AIRPODS_PRO,
|
||||||
|
)
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = device,
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.NotConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `aap connected but not ready returns Connecting`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = activeDevice(connectionState = AapPodState.ConnectionState.HANDSHAKING),
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.Connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `aap ready without AncMode setting returns Connecting`() {
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "p1",
|
||||||
|
ble = null,
|
||||||
|
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
|
||||||
|
profileModel = PodModel.AIRPODS_PRO,
|
||||||
|
)
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = device,
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.Connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fully ready device returns Active with current and visible modes`() {
|
||||||
|
val state = AncTileStateMapper.map(
|
||||||
|
device = activeDevice(currentMode = AapSetting.AncMode.Value.TRANSPARENCY),
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
)
|
||||||
|
state.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
state.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||||
|
state.visible shouldBe supportedModes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending mode is propagated to Active`() {
|
||||||
|
val state = AncTileStateMapper.map(
|
||||||
|
device = activeDevice(
|
||||||
|
currentMode = AapSetting.AncMode.Value.OFF,
|
||||||
|
pendingMode = AapSetting.AncMode.Value.TRANSPARENCY,
|
||||||
|
),
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = true,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
)
|
||||||
|
state.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
state.pending shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `precedence pro gating wins over bluetooth off`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = activeDevice(),
|
||||||
|
isPro = false,
|
||||||
|
isBluetoothEnabled = false,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.NotPro
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `precedence bluetooth off wins over no device`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = null,
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = false,
|
||||||
|
missingPermissions = noPermissions,
|
||||||
|
) shouldBe AncTileState.BluetoothOff
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `precedence permission required wins over bluetooth off`() {
|
||||||
|
AncTileStateMapper.map(
|
||||||
|
device = null,
|
||||||
|
isPro = true,
|
||||||
|
isBluetoothEnabled = false,
|
||||||
|
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
|
||||||
|
) shouldBe AncTileState.PermissionRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package eu.darken.capod.main.ui.tile
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.permissions.Permission
|
||||||
|
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||||
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
|
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||||
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class AncTileStateStoreTest : BaseTest() {
|
||||||
|
|
||||||
|
private val address = "00:11:22:33:44:55"
|
||||||
|
private val off = AapSetting.AncMode.Value.OFF
|
||||||
|
private val on = AapSetting.AncMode.Value.ON
|
||||||
|
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
|
||||||
|
private val ad = AapSetting.AncMode.Value.ADAPTIVE
|
||||||
|
private val visible = listOf(off, on, tx, ad)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `current state survives service listener gap`() = runTest {
|
||||||
|
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
|
||||||
|
val store = store(devices = devices)
|
||||||
|
val listener = collectState(store)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val activeBeforeGap = store.currentState()
|
||||||
|
activeBeforeGap.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
activeBeforeGap.current shouldBe off
|
||||||
|
|
||||||
|
listener.cancel()
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val activeAfterGap = store.currentState()
|
||||||
|
activeAfterGap.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
activeAfterGap.current shouldBe off
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `current state warms without service listener`() = runTest {
|
||||||
|
val store = store(devices = MutableStateFlow(listOf(activeDevice(currentMode = off))))
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
val current = store.currentState()
|
||||||
|
current.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
current.current shouldBe off
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `current state reports connecting while live device is not ready`() = runTest {
|
||||||
|
val devices = MutableStateFlow(listOf(connectingDevice()))
|
||||||
|
val store = store(devices = devices)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
store.currentState() shouldBe AncTileState.Connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `current state overlays coordinator target before collected state updates`() = runTest {
|
||||||
|
val coordinator = coordinator()
|
||||||
|
val store = store(
|
||||||
|
devices = MutableStateFlow(listOf(activeDevice(currentMode = off))),
|
||||||
|
sendCoordinator = coordinator,
|
||||||
|
)
|
||||||
|
val listener = collectState(store)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
|
||||||
|
|
||||||
|
val current = store.currentState()
|
||||||
|
current.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
current.pending shouldBe on
|
||||||
|
|
||||||
|
listener.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `device confirmation clears coordinator target through state store`() = runTest {
|
||||||
|
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
|
||||||
|
val coordinator = coordinator()
|
||||||
|
val store = store(
|
||||||
|
devices = devices,
|
||||||
|
sendCoordinator = coordinator,
|
||||||
|
)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
|
||||||
|
coordinator.pendingModes.value[address] shouldBe on
|
||||||
|
|
||||||
|
devices.value = listOf(activeDevice(currentMode = on))
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
coordinator.pendingModes.value[address] shouldBe null
|
||||||
|
val current = store.currentState()
|
||||||
|
current.shouldBeInstanceOf<AncTileState.Active>()
|
||||||
|
current.current shouldBe on
|
||||||
|
current.pending shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.collectState(store: AncTileStateStore): Job = launch {
|
||||||
|
store.state.collect {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.store(
|
||||||
|
devices: MutableStateFlow<List<PodDevice>>,
|
||||||
|
profiles: MutableStateFlow<List<DeviceProfile>> = MutableStateFlow(emptyList()),
|
||||||
|
isPro: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
|
bluetoothEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
|
missingPermissions: MutableStateFlow<Set<Permission>> = MutableStateFlow(emptySet()),
|
||||||
|
sendCoordinator: AncTileSendCoordinator = coordinator(),
|
||||||
|
): AncTileStateStore {
|
||||||
|
val deviceMonitor = mockk<DeviceMonitor> {
|
||||||
|
every { this@mockk.devices } returns devices
|
||||||
|
}
|
||||||
|
val profilesRepo = mockk<DeviceProfilesRepo> {
|
||||||
|
every { this@mockk.profiles } returns profiles
|
||||||
|
}
|
||||||
|
val upgradeRepo = mockk<UpgradeRepo> {
|
||||||
|
every { upgradeInfo } returns MutableStateFlow(upgradeInfo(isPro.value))
|
||||||
|
}
|
||||||
|
val bluetoothManager = mockk<BluetoothManager2> {
|
||||||
|
every { isBluetoothEnabled } returns bluetoothEnabled
|
||||||
|
}
|
||||||
|
val permissionTool = mockk<PermissionTool> {
|
||||||
|
every { this@mockk.missingPermissions } returns missingPermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
return AncTileStateStore(
|
||||||
|
appScope = backgroundScope,
|
||||||
|
deviceMonitor = deviceMonitor,
|
||||||
|
profilesRepo = profilesRepo,
|
||||||
|
upgradeRepo = upgradeRepo,
|
||||||
|
bluetoothManager = bluetoothManager,
|
||||||
|
permissionTool = permissionTool,
|
||||||
|
sendCoordinator = sendCoordinator,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.coordinator(
|
||||||
|
aapManager: AapConnectionManager = mockk(relaxed = true),
|
||||||
|
): AncTileSendCoordinator = AncTileSendCoordinator(
|
||||||
|
appScope = backgroundScope,
|
||||||
|
aapManager = aapManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun activeDevice(
|
||||||
|
currentMode: AapSetting.AncMode.Value,
|
||||||
|
pendingMode: AapSetting.AncMode.Value? = null,
|
||||||
|
): PodDevice {
|
||||||
|
val ancSetting = AapSetting.AncMode(current = currentMode, supported = visible)
|
||||||
|
return PodDevice(
|
||||||
|
profileId = "p1",
|
||||||
|
ble = null,
|
||||||
|
aap = AapPodState(
|
||||||
|
connectionState = AapPodState.ConnectionState.READY,
|
||||||
|
settings = mapOf(AapSetting.AncMode::class to ancSetting),
|
||||||
|
pendingAncMode = pendingMode,
|
||||||
|
),
|
||||||
|
profileAddress = address,
|
||||||
|
profileModel = PodModel.AIRPODS_PRO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectingDevice(): PodDevice = PodDevice(
|
||||||
|
profileId = "p1",
|
||||||
|
ble = null,
|
||||||
|
aap = AapPodState(connectionState = AapPodState.ConnectionState.HANDSHAKING),
|
||||||
|
profileAddress = address,
|
||||||
|
profileModel = PodModel.AIRPODS_PRO,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun upgradeInfo(isPro: Boolean) = object : UpgradeRepo.Info {
|
||||||
|
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
|
||||||
|
override val isPro: Boolean = isPro
|
||||||
|
override val upgradedAt: Instant? = null
|
||||||
|
override val error: Throwable? = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package eu.darken.capod.monitor.core
|
|||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.debug.Bugs
|
||||||
|
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||||
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
|
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
|
||||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||||
@@ -22,6 +24,7 @@ import io.mockk.coEvery
|
|||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
@@ -31,6 +34,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import testhelpers.BaseTest
|
import testhelpers.BaseTest
|
||||||
import testhelpers.TestTimeSource
|
import testhelpers.TestTimeSource
|
||||||
@@ -536,6 +540,96 @@ class DeviceMonitorTest : BaseTest() {
|
|||||||
devices.size shouldBe 2
|
devices.size shouldBe 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun resetBugsReporter() {
|
||||||
|
Bugs.reporter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: a NPE in the cache merge path used to throw out of `onEach { persistLiveDevices }`,
|
||||||
|
* cancelling the upstream `combine` and freezing every downstream observer (overview, widgets,
|
||||||
|
* etc.) for the rest of the process lifetime. The persist loop now catches and reports.
|
||||||
|
*
|
||||||
|
* Throws from inside `toCachedState` (via a BLE getter) rather than from `save()` so the test
|
||||||
|
* locks in that the catch covers the actual NPE boundary, not just the cache I/O boundary.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `flow keeps emitting after persist failure and only reports the bug once per profile`() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val reporter = mockk<AutomaticBugReporter>(relaxed = true)
|
||||||
|
Bugs.reporter = reporter
|
||||||
|
|
||||||
|
val bleFlow = MutableStateFlow(listOf(mockThrowingDualBlePodWithProfile(testProfile)))
|
||||||
|
val aapFlow = MutableStateFlow(emptyMap<BluetoothAddress, AapPodState>())
|
||||||
|
val cacheFlow = MutableStateFlow<Map<String, CachedDeviceState>>(emptyMap())
|
||||||
|
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(listOf(testProfile))
|
||||||
|
|
||||||
|
val blePodMonitor: BlePodMonitor = mockk { every { devices } returns bleFlow }
|
||||||
|
val aapManager: AapConnectionManager = mockk { every { allStates } returns aapFlow }
|
||||||
|
val deviceStateCache: DeviceStateCache = mockk(relaxed = true) {
|
||||||
|
every { cachedStates } returns cacheFlow
|
||||||
|
coEvery { load(any()) } answers { cacheFlow.value[firstArg<String>()] }
|
||||||
|
}
|
||||||
|
val profilesRepo: DeviceProfilesRepo = mockk { every { profiles } returns profilesFlow }
|
||||||
|
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
|
||||||
|
val bluetoothManager: BluetoothManager2 = mockk {
|
||||||
|
every { connectedDevices } returns MutableStateFlow(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val monitor = DeviceMonitor(
|
||||||
|
appScope = backgroundScope,
|
||||||
|
blePodMonitor = blePodMonitor,
|
||||||
|
aapManager = aapManager,
|
||||||
|
bluetoothManager = bluetoothManager,
|
||||||
|
deviceStateCache = deviceStateCache,
|
||||||
|
profilesRepo = profilesRepo,
|
||||||
|
aapLifecycleManager = aapLifecycleManager,
|
||||||
|
timeSource = timeSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
val received = mutableListOf<List<PodDevice>>()
|
||||||
|
val collector = backgroundScope.launch {
|
||||||
|
monitor.devices.collect { received += it }
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
val initialEmissionCount = received.size
|
||||||
|
initialEmissionCount shouldNotBe 0
|
||||||
|
|
||||||
|
// Trigger more emissions; toCachedState keeps throwing inside the persist loop, but
|
||||||
|
// the flow must survive instead of cancelling its upstream combine.
|
||||||
|
bleFlow.value = listOf(mockThrowingDualBlePodWithProfile(testProfile))
|
||||||
|
advanceUntilIdle()
|
||||||
|
bleFlow.value = listOf(mockThrowingDualBlePodWithProfile(testProfile))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// The flow survived: at least two more emissions arrived after the failing merge.
|
||||||
|
(received.size - initialEmissionCount) shouldBe 2
|
||||||
|
// The cache write must NOT have been attempted — the failure was upstream of save().
|
||||||
|
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
|
||||||
|
// Dedup: even though merge failed on every emission for the same profile, only one report.
|
||||||
|
verify(exactly = 1) { reporter.notify(any()) }
|
||||||
|
|
||||||
|
collector.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live BLE pod whose battery getter throws on read. Used to simulate the NPE that R8/JIT
|
||||||
|
* was producing inside the cache merge path — the throw originates inside `toCachedState`,
|
||||||
|
* before `save()` is called.
|
||||||
|
*/
|
||||||
|
private fun mockThrowingDualBlePodWithProfile(profile: DeviceProfile): BlePodSnapshot {
|
||||||
|
val bleMeta = object : BlePodSnapshot.Meta {
|
||||||
|
override val profile: DeviceProfile? = profile
|
||||||
|
}
|
||||||
|
return mockk<DualBlePodSnapshot>(relaxed = true) {
|
||||||
|
every { meta } returns bleMeta
|
||||||
|
every { this@mockk.model } returns profile.model
|
||||||
|
every { seenFirstAt } returns Instant.parse("2026-04-05T17:50:00Z")
|
||||||
|
every { seenLastAt } returns Instant.parse("2026-04-05T18:00:00Z")
|
||||||
|
every { batteryLeftPodPercent } throws NullPointerException("synthetic merge failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `cache-only refresh does not trigger another persist cycle`() = runTest(testDispatcher) {
|
fun `cache-only refresh does not trigger another persist cycle`() = runTest(testDispatcher) {
|
||||||
val bleFlow = MutableStateFlow(listOf(mockLiveDualBlePodWithProfile(testProfile)))
|
val bleFlow = MutableStateFlow(listOf(mockLiveDualBlePodWithProfile(testProfile)))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package eu.darken.capod.monitor.core
|
|||||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import io.kotest.matchers.nulls.shouldBeNull
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
@@ -57,7 +58,7 @@ class PodDeviceCacheTest : BaseTest() {
|
|||||||
device.batteryLeft shouldBe 0.8f
|
device.batteryLeft shouldBe 0.8f
|
||||||
device.batteryRight shouldBe 0.7f
|
device.batteryRight shouldBe 0.7f
|
||||||
device.batteryCase shouldBe 0.5f
|
device.batteryCase shouldBe 0.5f
|
||||||
device.batteryHeadset.shouldBeNull()
|
device.batteryHeadset shouldBe BATTERY_UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
class PodDeviceTierTest : BaseTest() {
|
||||||
|
|
||||||
|
private fun device(
|
||||||
|
profileId: String?,
|
||||||
|
isSystemConnected: Boolean = false,
|
||||||
|
isLive: Boolean = false,
|
||||||
|
): PodDevice {
|
||||||
|
// isLive is derived from ble != null || aap != null. Use a minimal AapPodState so we
|
||||||
|
// don't need to fabricate a BLE snapshot for the live case.
|
||||||
|
val aap = if (isLive) AapPodState() else null
|
||||||
|
return PodDevice(
|
||||||
|
profileId = profileId,
|
||||||
|
ble = null,
|
||||||
|
aap = aap,
|
||||||
|
profileModel = PodModel.AIRPODS_PRO,
|
||||||
|
isSystemConnected = isSystemConnected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `system connected ranks above live which ranks above offline`() {
|
||||||
|
device(profileId = "a", isSystemConnected = true).tierRank() shouldBe 0
|
||||||
|
device(profileId = "a", isLive = true).tierRank() shouldBe 1
|
||||||
|
device(profileId = "a").tierRank() shouldBe 2
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `system connected wins regardless of live`() {
|
||||||
|
// isSystemConnected true takes precedence even if also live
|
||||||
|
device(profileId = "a", isSystemConnected = true, isLive = true).tierRank() shouldBe 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primaryByTier picks system-connected device first`() {
|
||||||
|
val live = device(profileId = "a", isLive = true)
|
||||||
|
val systemConnected = device(profileId = "b", isSystemConnected = true)
|
||||||
|
val devices = listOf(live, systemConnected)
|
||||||
|
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe systemConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primaryByTier uses profile order as tiebreaker within a tier`() {
|
||||||
|
val first = device(profileId = "a", isLive = true)
|
||||||
|
val second = device(profileId = "b", isLive = true)
|
||||||
|
val devices = listOf(second, first)
|
||||||
|
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe first
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primaryByTier ignores profileless devices`() {
|
||||||
|
val anonymous = device(profileId = null, isSystemConnected = true)
|
||||||
|
val profiled = device(profileId = "a", isLive = true)
|
||||||
|
val devices = listOf(anonymous, profiled)
|
||||||
|
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe profiled
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primaryByTier returns null when no profiled devices`() {
|
||||||
|
val devices = listOf(device(profileId = null, isLive = true))
|
||||||
|
devices.primaryByTier(profileOrder = emptyMap()) shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primaryByTier returns null on empty list`() {
|
||||||
|
emptyList<PodDevice>().primaryByTier(profileOrder = emptyMap()) shouldBe null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `primaryByTier handles missing profile order with stable fallback`() {
|
||||||
|
// Profile id not present in map → falls back to Int.MAX_VALUE (last)
|
||||||
|
val withOrder = device(profileId = "a", isLive = true)
|
||||||
|
val withoutOrder = device(profileId = "z", isLive = true)
|
||||||
|
val devices = listOf(withoutOrder, withOrder)
|
||||||
|
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe withOrder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package eu.darken.capod.monitor.core.ble
|
||||||
|
|
||||||
|
import eu.darken.capod.common.TimeSource
|
||||||
|
import eu.darken.capod.common.bluetooth.BleScanResult
|
||||||
|
import eu.darken.capod.common.bluetooth.BleScanner
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||||
|
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||||
|
import eu.darken.capod.common.debug.DebugSettings
|
||||||
|
import eu.darken.capod.common.permissions.Permission
|
||||||
|
import eu.darken.capod.main.core.GeneralSettings
|
||||||
|
import eu.darken.capod.main.core.PermissionTool
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.PodFactory
|
||||||
|
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.mockk.Runs
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
import testhelpers.TestTimeSource
|
||||||
|
import testhelpers.datastore.FakeDataStoreValue
|
||||||
|
|
||||||
|
class BlePodMonitorTest : BaseTest() {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scan security exception emits empty devices and rechecks permissions`() = runTest {
|
||||||
|
val fixture = createFixture {
|
||||||
|
flow<Collection<BleScanResult>> { throw SecurityException("scan denied") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture.monitor.devices.drop(1).first() shouldBe emptyList()
|
||||||
|
|
||||||
|
verify(exactly = 1) { fixture.permissionTool.recheck() }
|
||||||
|
verify(exactly = 1) {
|
||||||
|
fixture.bleScanner.scan(
|
||||||
|
filters = any(),
|
||||||
|
scannerMode = any(),
|
||||||
|
disableOffloadFiltering = any(),
|
||||||
|
disableOffloadBatching = any(),
|
||||||
|
disableDirectScanCallback = any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non security scan failure retries`() = runTest {
|
||||||
|
var attempts = 0
|
||||||
|
val fixture = createFixture {
|
||||||
|
attempts += 1
|
||||||
|
if (attempts == 1) {
|
||||||
|
flow<Collection<BleScanResult>> { throw IllegalStateException("temporary scanner failure") }
|
||||||
|
} else {
|
||||||
|
flowOf(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture.monitor.devices.drop(1).first() shouldBe emptyList()
|
||||||
|
|
||||||
|
attempts shouldBe 2
|
||||||
|
verify(exactly = 0) { fixture.permissionTool.recheck() }
|
||||||
|
verify(exactly = 2) {
|
||||||
|
fixture.bleScanner.scan(
|
||||||
|
filters = any(),
|
||||||
|
scannerMode = any(),
|
||||||
|
disableOffloadFiltering = any(),
|
||||||
|
disableOffloadBatching = any(),
|
||||||
|
disableDirectScanCallback = any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createFixture(
|
||||||
|
scanFlowFactory: () -> Flow<Collection<BleScanResult>>,
|
||||||
|
): Fixture {
|
||||||
|
mockkObject(ProximityPairing)
|
||||||
|
every { ProximityPairing.getBleScanFilter() } returns emptySet()
|
||||||
|
|
||||||
|
val bleScanner = mockk<BleScanner>().apply {
|
||||||
|
every {
|
||||||
|
scan(
|
||||||
|
filters = any(),
|
||||||
|
scannerMode = any(),
|
||||||
|
disableOffloadFiltering = any(),
|
||||||
|
disableOffloadBatching = any(),
|
||||||
|
disableDirectScanCallback = any(),
|
||||||
|
)
|
||||||
|
} answers { scanFlowFactory() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val scanModeController = mockk<BleScanModeController>().apply {
|
||||||
|
every { scannerMode } returns MutableStateFlow(ScannerMode.BALANCED)
|
||||||
|
}
|
||||||
|
val generalSettings = mockk<GeneralSettings>().apply {
|
||||||
|
every { isOffloadedBatchingDisabled } returns FakeDataStoreValue(false).mock
|
||||||
|
every { isOffloadedFilteringDisabled } returns FakeDataStoreValue(false).mock
|
||||||
|
every { useIndirectScanResultCallback } returns FakeDataStoreValue(false).mock
|
||||||
|
}
|
||||||
|
val debugSettings = mockk<DebugSettings>().apply {
|
||||||
|
every { showUnfiltered } returns FakeDataStoreValue(false).mock
|
||||||
|
}
|
||||||
|
val permissionTool = mockk<PermissionTool>().apply {
|
||||||
|
every { missingScanPermissions } returns MutableStateFlow<Set<Permission>>(emptySet())
|
||||||
|
every { recheck() } just Runs
|
||||||
|
}
|
||||||
|
val bluetoothManager = mockk<BluetoothManager2>().apply {
|
||||||
|
every { isBluetoothEnabled } returns MutableStateFlow(true)
|
||||||
|
}
|
||||||
|
val profilesRepo = mockk<DeviceProfilesRepo>().apply {
|
||||||
|
every { profiles } returns MutableStateFlow(emptyList())
|
||||||
|
}
|
||||||
|
val timeSource: TimeSource = TestTimeSource()
|
||||||
|
|
||||||
|
return Fixture(
|
||||||
|
monitor = BlePodMonitor(
|
||||||
|
appScope = backgroundScope,
|
||||||
|
bleScanner = bleScanner,
|
||||||
|
bleScanModeController = scanModeController,
|
||||||
|
podFactory = mockk<PodFactory>(relaxed = true),
|
||||||
|
timeSource = timeSource,
|
||||||
|
generalSettings = generalSettings,
|
||||||
|
bluetoothManager = bluetoothManager,
|
||||||
|
debugSettings = debugSettings,
|
||||||
|
permissionTool = permissionTool,
|
||||||
|
profilesRepo = profilesRepo,
|
||||||
|
),
|
||||||
|
bleScanner = bleScanner,
|
||||||
|
permissionTool = permissionTool,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(
|
||||||
|
val monitor: BlePodMonitor,
|
||||||
|
val bleScanner: BleScanner,
|
||||||
|
val permissionTool: PermissionTool,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -71,4 +71,22 @@ class CachedDeviceStateMigrationTest : BaseTest() {
|
|||||||
val state = json.decodeFromString(CachedDeviceState.serializer(), legacy)
|
val state = json.decodeFromString(CachedDeviceState.serializer(), legacy)
|
||||||
state.deviceInfo!!.marketingVersion shouldBe "8454480"
|
state.deviceInfo!!.marketingVersion shouldBe "8454480"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deviceInfo is non-null when only new earbud-serial fields are present`() {
|
||||||
|
val state = CachedDeviceState(
|
||||||
|
profileId = "earbud-only",
|
||||||
|
model = PodModel.AIRPODS_PRO3,
|
||||||
|
leftEarbudSerial = "L-9",
|
||||||
|
rightEarbudSerial = "R-9",
|
||||||
|
marketingVersion = "8888",
|
||||||
|
lastSeenAt = java.time.Instant.ofEpochMilli(1767364074000L),
|
||||||
|
)
|
||||||
|
|
||||||
|
val info = state.deviceInfo!!
|
||||||
|
info.leftEarbudSerial shouldBe "L-9"
|
||||||
|
info.rightEarbudSerial shouldBe "R-9"
|
||||||
|
info.marketingVersion shouldBe "8888"
|
||||||
|
info.name shouldBe ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package eu.darken.capod.monitor.core.cache
|
|||||||
|
|
||||||
import eu.darken.capod.monitor.core.PodDevice
|
import eu.darken.capod.monitor.core.PodDevice
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||||
import io.kotest.matchers.nulls.shouldBeNull
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
@@ -154,5 +156,150 @@ class ToCachedStateTest : BaseTest() {
|
|||||||
.toCachedState(existing, now)
|
.toCachedState(existing, now)
|
||||||
.shouldNotBeNull()
|
.shouldNotBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns new state when only earbud serial changes`() {
|
||||||
|
val existing = CachedDeviceState(
|
||||||
|
profileId = "test-profile",
|
||||||
|
model = PodModel.AIRPODS_PRO3,
|
||||||
|
left = CachedDeviceState.CachedBatterySlot(0.8f, now),
|
||||||
|
right = CachedDeviceState.CachedBatterySlot(0.7f, now),
|
||||||
|
case = CachedDeviceState.CachedBatterySlot(0.5f, now),
|
||||||
|
isLeftCharging = false,
|
||||||
|
isRightCharging = false,
|
||||||
|
isCaseCharging = false,
|
||||||
|
isHeadsetCharging = false,
|
||||||
|
deviceName = "AirPods",
|
||||||
|
serialNumber = "",
|
||||||
|
firmwareVersion = "",
|
||||||
|
leftEarbudSerial = "OLD-LEFT",
|
||||||
|
rightEarbudSerial = "R-1",
|
||||||
|
marketingVersion = "1234",
|
||||||
|
lastSeenAt = now,
|
||||||
|
)
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "test-profile",
|
||||||
|
ble = mockDualPod(leftBattery = 0.8f, rightBattery = 0.7f, caseBattery = 0.5f),
|
||||||
|
aap = AapPodState(
|
||||||
|
deviceInfo = deviceInfo(
|
||||||
|
leftEarbudSerial = "NEW-LEFT",
|
||||||
|
rightEarbudSerial = "R-1",
|
||||||
|
marketingVersion = "1234",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = device.toCachedState(existing, now).shouldNotBeNull()
|
||||||
|
result.leftEarbudSerial shouldBe "NEW-LEFT"
|
||||||
|
result.rightEarbudSerial shouldBe "R-1"
|
||||||
|
result.marketingVersion shouldBe "1234"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns new state when only marketing version changes`() {
|
||||||
|
val existing = CachedDeviceState(
|
||||||
|
profileId = "test-profile",
|
||||||
|
model = PodModel.AIRPODS_PRO3,
|
||||||
|
left = CachedDeviceState.CachedBatterySlot(0.8f, now),
|
||||||
|
isLeftCharging = false,
|
||||||
|
isRightCharging = false,
|
||||||
|
isCaseCharging = false,
|
||||||
|
isHeadsetCharging = false,
|
||||||
|
deviceName = "AirPods",
|
||||||
|
serialNumber = "",
|
||||||
|
firmwareVersion = "",
|
||||||
|
marketingVersion = "1234",
|
||||||
|
lastSeenAt = now,
|
||||||
|
)
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "test-profile",
|
||||||
|
ble = mockDualPod(leftBattery = 0.8f),
|
||||||
|
aap = AapPodState(deviceInfo = deviceInfo(marketingVersion = "5678")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = device.toCachedState(existing, now).shouldNotBeNull()
|
||||||
|
result.marketingVersion shouldBe "5678"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class DeviceInfoOnly {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `persists DeviceInfo when no live battery is available`() {
|
||||||
|
// Mirror how DeviceMonitor.aapOnlyForPersistence builds an AAP-only PodDevice:
|
||||||
|
// profileModel and profileAddress carry the model/address since `ble` is null.
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "test-profile",
|
||||||
|
ble = null,
|
||||||
|
aap = AapPodState(
|
||||||
|
deviceInfo = deviceInfo(
|
||||||
|
name = "Pro 3",
|
||||||
|
leftEarbudSerial = "L-1",
|
||||||
|
rightEarbudSerial = "R-1",
|
||||||
|
marketingVersion = "9999",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
profileModel = PodModel.AIRPODS_PRO3,
|
||||||
|
profileAddress = "AA:BB:CC:DD:EE:FF",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = device.toCachedState(existing = null, now = now).shouldNotBeNull()
|
||||||
|
result.model shouldBe PodModel.AIRPODS_PRO3
|
||||||
|
result.address shouldBe "AA:BB:CC:DD:EE:FF"
|
||||||
|
result.deviceName shouldBe "Pro 3"
|
||||||
|
result.leftEarbudSerial shouldBe "L-1"
|
||||||
|
result.rightEarbudSerial shouldBe "R-1"
|
||||||
|
result.marketingVersion shouldBe "9999"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `DeviceInfo-only update preserves existing slot timestamps (no live refresh)`() {
|
||||||
|
// Regression: if toCachedState consults the unified PodDevice.batteryX getter (which
|
||||||
|
// falls back to cache), an AAP-only DeviceInfo update would re-stamp every slot with
|
||||||
|
// `now`, refreshing stale cached readings indefinitely. Raw live extraction prevents
|
||||||
|
// that.
|
||||||
|
val oldStamp = now.minusSeconds(3600)
|
||||||
|
val existing = CachedDeviceState(
|
||||||
|
profileId = "test-profile",
|
||||||
|
model = PodModel.AIRPODS_PRO3,
|
||||||
|
left = CachedDeviceState.CachedBatterySlot(0.8f, oldStamp),
|
||||||
|
right = CachedDeviceState.CachedBatterySlot(0.7f, oldStamp),
|
||||||
|
case = CachedDeviceState.CachedBatterySlot(0.5f, oldStamp),
|
||||||
|
marketingVersion = "OLD",
|
||||||
|
lastSeenAt = oldStamp,
|
||||||
|
)
|
||||||
|
val device = PodDevice(
|
||||||
|
profileId = "test-profile",
|
||||||
|
ble = null,
|
||||||
|
aap = AapPodState(deviceInfo = deviceInfo(marketingVersion = "NEW")),
|
||||||
|
profileModel = PodModel.AIRPODS_PRO3,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = device.toCachedState(existing, now).shouldNotBeNull()
|
||||||
|
result.marketingVersion shouldBe "NEW"
|
||||||
|
result.left?.updatedAt shouldBe oldStamp
|
||||||
|
result.right?.updatedAt shouldBe oldStamp
|
||||||
|
result.case?.updatedAt shouldBe oldStamp
|
||||||
|
result.left?.percent shouldBe 0.8f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deviceInfo(
|
||||||
|
name: String = "AirPods",
|
||||||
|
serialNumber: String = "",
|
||||||
|
firmwareVersion: String = "",
|
||||||
|
leftEarbudSerial: String? = null,
|
||||||
|
rightEarbudSerial: String? = null,
|
||||||
|
marketingVersion: String? = null,
|
||||||
|
) = AapDeviceInfo(
|
||||||
|
name = name,
|
||||||
|
modelNumber = "",
|
||||||
|
manufacturer = "",
|
||||||
|
serialNumber = serialNumber,
|
||||||
|
firmwareVersion = firmwareVersion,
|
||||||
|
leftEarbudSerial = leftEarbudSerial,
|
||||||
|
rightEarbudSerial = rightEarbudSerial,
|
||||||
|
marketingVersion = marketingVersion,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import android.bluetooth.BluetoothSocket
|
|||||||
import eu.darken.capod.common.TimeSource
|
import eu.darken.capod.common.TimeSource
|
||||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||||
import io.kotest.assertions.throwables.shouldThrow
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
import io.kotest.matchers.maps.shouldBeEmpty
|
import io.kotest.matchers.maps.shouldBeEmpty
|
||||||
@@ -13,6 +15,7 @@ import io.kotest.matchers.shouldBe
|
|||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -24,6 +27,10 @@ import testhelpers.TestTimeSource
|
|||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class AapConnectionManagerTest : BaseTest() {
|
class AapConnectionManagerTest : BaseTest() {
|
||||||
|
|
||||||
@@ -89,6 +96,45 @@ class AapConnectionManagerTest : BaseTest() {
|
|||||||
manager.allStates.value.shouldBeEmpty()
|
manager.allStates.value.shouldBeEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `connect timeout closes in-flight socket and allows reconnect`() = testScope.runTest {
|
||||||
|
val closeCalled = CountDownLatch(1)
|
||||||
|
val closeCalls = AtomicInteger(0)
|
||||||
|
val blockingSocket = mockk<BluetoothSocket>(relaxed = true) {
|
||||||
|
every { connect() } answers {
|
||||||
|
closeCalled.await(1, TimeUnit.SECONDS)
|
||||||
|
throw IOException("closed")
|
||||||
|
}
|
||||||
|
every { close() } answers {
|
||||||
|
closeCalls.incrementAndGet()
|
||||||
|
closeCalled.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val reconnectSocket = mockk<BluetoothSocket>(relaxed = true) {
|
||||||
|
every { outputStream } returns ByteArrayOutputStream()
|
||||||
|
every { inputStream } returns ByteArrayInputStream(byteArrayOf())
|
||||||
|
}
|
||||||
|
every { socketFactory.createSocket(any(), any()) } returnsMany listOf(blockingSocket, reconnectSocket)
|
||||||
|
val connection = AapConnection(
|
||||||
|
device = testDevice,
|
||||||
|
profile = AapDeviceProfile.forModel(PodModel.AIRPODS_PRO3),
|
||||||
|
socketFactory = socketFactory,
|
||||||
|
timeSource = timeSource,
|
||||||
|
connectTimeout = 50.milliseconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
shouldThrow<TimeoutCancellationException> {
|
||||||
|
connection.connect(testScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCalled.await(1, TimeUnit.SECONDS) shouldBe true
|
||||||
|
closeCalls.get() shouldBe 1
|
||||||
|
connection.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
|
||||||
|
|
||||||
|
connection.connect(testScope)
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `remote disconnect cleans up allStates`() = testScope.runTest {
|
fun `remote disconnect cleans up allStates`() = testScope.runTest {
|
||||||
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED
|
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ platform :android do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
lane :screenshots_only do
|
||||||
|
sh "bash ./remove_unsupported_languages.sh"
|
||||||
|
supply(
|
||||||
|
track: 'production',
|
||||||
|
package_name: 'eu.darken.capod',
|
||||||
|
skip_upload_apk: 'true',
|
||||||
|
skip_upload_aab: 'true',
|
||||||
|
skip_upload_metadata: 'true',
|
||||||
|
skip_upload_changelogs: 'true',
|
||||||
|
skip_upload_images: 'true',
|
||||||
|
skip_upload_screenshots: 'false',
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
after_all do |lane|
|
after_all do |lane|
|
||||||
# This block is called, only if the executed lane was successful
|
# This block is called, only if the executed lane was successful
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ GEM
|
|||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.8)
|
CFPropertyList (3.0.8)
|
||||||
abbrev (0.1.2)
|
abbrev (0.1.2)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1216.0)
|
aws-partitions (1.1244.0)
|
||||||
aws-sdk-core (3.242.0)
|
aws-sdk-core (3.246.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
@@ -17,11 +17,11 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.122.0)
|
aws-sdk-kms (1.124.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.213.0)
|
aws-sdk-s3 (1.220.0)
|
||||||
aws-sdk-core (~> 3, >= 3.241.4)
|
aws-sdk-core (~> 3, >= 3.244.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
@@ -29,7 +29,7 @@ GEM
|
|||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.1.2)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
@@ -68,11 +68,11 @@ GEM
|
|||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.4)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
fastlane (2.232.1)
|
fastlane (2.233.1)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
abbrev (~> 0.1.2)
|
abbrev (~> 0.1.2)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
@@ -92,7 +92,7 @@ GEM
|
|||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
@@ -122,10 +122,9 @@ GEM
|
|||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.1)
|
xcpretty (~> 0.4.1)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.0.0)
|
fastlane-sirp (1.1.0)
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.96.0)
|
google-apis-androidpublisher_v3 (0.99.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.18.0)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
@@ -135,19 +134,19 @@ GEM
|
|||||||
mutex_m
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
google-apis-iamcredentials_v1 (0.26.0)
|
google-apis-iamcredentials_v1 (0.27.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-playcustomapp_v1 (0.17.0)
|
google-apis-playcustomapp_v1 (0.17.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-storage_v1 (0.60.0)
|
google-apis-storage_v1 (0.62.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-cloud-core (1.8.0)
|
google-cloud-core (1.8.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (2.1.1)
|
google-cloud-env (2.1.1)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.5.0)
|
google-cloud-errors (1.6.0)
|
||||||
google-cloud-storage (1.58.0)
|
google-cloud-storage (1.59.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-core (>= 0.18, < 2)
|
google-apis-core (>= 0.18, < 2)
|
||||||
@@ -169,13 +168,13 @@ GEM
|
|||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.18.1)
|
json (2.19.5)
|
||||||
jwt (2.10.2)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.19.1)
|
multi_json (1.21.0)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
@@ -185,13 +184,13 @@ GEM
|
|||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (7.0.2)
|
public_suffix (7.0.5)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.2.0)
|
retriable (3.4.1)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
@@ -205,7 +204,6 @@ GEM
|
|||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 96 KiB |