Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 | ||
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d | ||
|
|
39922df792 | ||
|
|
5a8c4b94ef | ||
|
|
0ffa87deea | ||
|
|
fc98db09a2 | ||
|
|
e1c0a702dd | ||
|
|
3f310016b1 | ||
|
|
d399a6d908 |
@@ -12,7 +12,7 @@ gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
|
||||
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
|
||||
```
|
||||
|
||||
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes/dispatches (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
|
||||
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -60,7 +60,7 @@ bats tools/release/bump.bats
|
||||
|
||||
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
|
||||
|
||||
- `RELEASE_APP_ID` — numeric ID of the `d4rken-org-releaser` GitHub App
|
||||
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
|
||||
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
|
||||
|
||||
The App is installed on this repo and added as a bypass actor to:
|
||||
@@ -75,4 +75,4 @@ Other apps in the org can reuse the same App + secrets — just install the App
|
||||
|
||||
## Stuck-dispatch recovery
|
||||
|
||||
If Job 2's atomic push lands but `gh workflow run release-tag.yml` fails (rare — Job 1's auth precheck should prevent it), the tag is public but no pipeline runs. Re-dispatch: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
|
||||
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
|
||||
@@ -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/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
|
||||
|
||||
```bash
|
||||
# Full run — iterates over ALL_LOCALES in batches of BATCH_SIZE.
|
||||
# The script prints "Locales: N | Batch size: B | Batches: ceil(N/B)" at startup.
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
||||
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||
# Use this for local iteration and PRs that touch screenshot content.
|
||||
./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)
|
||||
./fastlane/copy_screenshots.sh
|
||||
|
||||
@@ -48,23 +55,36 @@ ScreenshotContent.kt (mock data + composables)
|
||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||
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)
|
||||
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
|
||||
|
||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||
2. Run `generate_screenshots.sh`
|
||||
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
|
||||
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/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||
|
||||
## 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
|
||||
./fastlane/generate_screenshots.sh
|
||||
./fastlane/generate_screenshots.sh --smoke
|
||||
./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
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Code tests & eval
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- VERSION
|
||||
- version.properties
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
@@ -101,4 +104,4 @@ jobs:
|
||||
run: bats tools/release/bump.bats
|
||||
|
||||
- name: Verify version.properties + VERSION are consistent
|
||||
run: ./tools/release/bump.sh --mode=check
|
||||
run: ./tools/release/bump.sh --mode=check
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- VERSION
|
||||
- version.properties
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -3,6 +3,14 @@ name: Deploy GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- _config.yml
|
||||
- _layouts/**
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
- PRIVACY_POLICY.md
|
||||
- CNAME
|
||||
- .github/workflows/pages.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -39,13 +39,11 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
new_name: ${{ steps.plan.outputs.new_name }}
|
||||
new_code: ${{ steps.plan.outputs.new_code }}
|
||||
current_name: ${{ steps.plan.outputs.current_name }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
|
||||
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
|
||||
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
|
||||
@@ -65,9 +63,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify gh auth and dispatch capability
|
||||
run: gh workflow view release-tag.yml > /dev/null
|
||||
|
||||
- name: Compute and validate
|
||||
id: plan
|
||||
run: |
|
||||
@@ -147,7 +142,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Resolve bot identity
|
||||
@@ -169,12 +164,12 @@ jobs:
|
||||
persist-credentials: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Re-validate after approval wait
|
||||
- name: Verify state still matches plan from Job 1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
|
||||
|
||||
- name: Re-check tag collision (state may have moved during approval)
|
||||
- name: Re-check tag collision (state may have moved between jobs)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||
@@ -223,13 +218,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
|
||||
|
||||
- name: Dispatch release-tag.yml
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run release-tag.yml --ref "v${NEW_NAME}" -f dry_run=false
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
.kotlin
|
||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||
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
|
||||
protocol-research/
|
||||
_site/
|
||||
|
||||
@@ -130,6 +130,7 @@ android {
|
||||
//noinspection WrongGradleMethod
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "4g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.preview.MOCK_NOW
|
||||
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"
|
||||
|
||||
@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(
|
||||
state = OverviewViewModel.State(
|
||||
now = MOCK_NOW,
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
devices = devices,
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
||||
|
||||
@Composable
|
||||
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(
|
||||
state = DeviceProfileCreationViewModel.State(
|
||||
isEditMode = false,
|
||||
name = "",
|
||||
name = "My AirPods Pro",
|
||||
nameError = null,
|
||||
selectedModel = null,
|
||||
selectedModel = PodModel.AIRPODS_PRO2,
|
||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||
identityKey = null,
|
||||
encryptionKey = null,
|
||||
selectedDevice = null,
|
||||
bondedDeviceItems = emptyList(),
|
||||
selectedDevice = pairedAirPods,
|
||||
bondedDeviceItems = bondedItems,
|
||||
minimumSignalQuality = 0.15f,
|
||||
canSave = false,
|
||||
canSave = true,
|
||||
),
|
||||
onBack = {},
|
||||
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)
|
||||
@Composable
|
||||
private fun PreviewDashboardDark() = DashboardContent()
|
||||
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||
|
||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||
@Composable
|
||||
|
||||
@@ -141,6 +141,20 @@
|
||||
android:name=".monitor.core.worker.MonitorService"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
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.logging.LogCatLogger
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
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.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
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 kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@HiltAndroidApp
|
||||
open class App : Application() {
|
||||
@@ -46,22 +42,19 @@ open class App : Application() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||
|
||||
val foregroundExceptionHandled = AtomicBoolean(false)
|
||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
val isTimingExc = throwable.isForegroundServiceTimingException()
|
||||
val isMain = thread === Looper.getMainLooper().thread
|
||||
if (isTimingExc && isMain && foregroundExceptionHandled.compareAndSet(false, true)) {
|
||||
runCatching {
|
||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
||||
}
|
||||
Looper.loop()
|
||||
return@setDefaultUncaughtExceptionHandler
|
||||
}
|
||||
runCatching { log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
||||
}
|
||||
Thread.setDefaultUncaughtExceptionHandler(
|
||||
CapodUncaughtExceptionHandler(
|
||||
previousHandler = oldHandler,
|
||||
cancelBeforeDelegate = { throwable ->
|
||||
// Best-effort shutdown: the system handler may terminate the process immediately,
|
||||
// but cancellation can still close sockets if it gets a scheduling window.
|
||||
if (::appScope.isInitialized) {
|
||||
appScope.cancel(CancellationException("Uncaught exception", throwable))
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
autoReporting.setup(this)
|
||||
|
||||
@@ -90,14 +83,5 @@ open class App : Application() {
|
||||
|
||||
companion object {
|
||||
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.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -113,22 +114,7 @@ class BleScanner @Inject constructor(
|
||||
null
|
||||
}
|
||||
|
||||
val flushJob = if (!disableDirectScanCallback) {
|
||||
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
|
||||
}
|
||||
var flushJob: Job? = null
|
||||
|
||||
val filterList = when {
|
||||
useOffloadedFiltering -> filters.toList()
|
||||
@@ -167,26 +153,55 @@ class BleScanner @Inject constructor(
|
||||
setReportDelay(delay)
|
||||
}.build()
|
||||
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
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)
|
||||
} else {
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callback)
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "startScan() denied: ${e.message}" }
|
||||
forwarderConsumer?.cancel()
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
forwarderConsumer?.cancel()
|
||||
flushJob?.cancel()
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
|
||||
}
|
||||
log(TAG) { "BleScanner stopped" }
|
||||
}
|
||||
|
||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
||||
import java.time.Instant
|
||||
|
||||
data class BluetoothDevice2(
|
||||
internal val internal: BluetoothDevice,
|
||||
val address: BluetoothAddress,
|
||||
val name: String?,
|
||||
val seenFirstAt: Instant,
|
||||
) {
|
||||
val address: BluetoothAddress
|
||||
get() = internal.address
|
||||
|
||||
val name: String?
|
||||
get() = internal.name
|
||||
}
|
||||
internal val internal: BluetoothDevice? = null,
|
||||
)
|
||||
|
||||
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
.map { device ->
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
val wrappedDevices = rawDevices.map { device ->
|
||||
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -408,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||
return try {
|
||||
val target = device.internal ?: return false
|
||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||
.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" }
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
@@ -424,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
|
||||
val target = device.internal ?: return@map false
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).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" }
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
||||
|
||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-mixed",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProMixed(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-keys",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-aap",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
@@ -227,12 +230,14 @@ object MockPodDataProvider {
|
||||
|
||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||
profileId = "preview-single",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMax(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-single-aap",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMaxCharging(),
|
||||
aap = AapPodState(
|
||||
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.WARN
|
||||
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.logTag
|
||||
|
||||
@@ -15,13 +16,20 @@ object Bugs {
|
||||
message: String,
|
||||
exception: Throwable
|
||||
) {
|
||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
||||
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
|
||||
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
|
||||
|
||||
reporter?.notify(exception) ?: run {
|
||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
||||
val bugReporter = reporter
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,19 @@ object Logging {
|
||||
message: String
|
||||
) {
|
||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||
snapshot
|
||||
.filter { it.isLoggable(priority) }
|
||||
.forEach {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
snapshot.forEach {
|
||||
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
||||
if (isLoggable) {
|
||||
runCatching {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
@@ -110,14 +113,28 @@ inline fun log(
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.asLog(): String {
|
||||
fun Throwable.asLog(): String = runCatching {
|
||||
val stringWriter = StringWriter(256)
|
||||
val printWriter = PrintWriter(stringWriter, false)
|
||||
printStackTrace(printWriter)
|
||||
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
|
||||
internal fun Any.logTagViaCallSite(): String {
|
||||
val javaClass = this::class.java
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package eu.darken.capod.common.permissions
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.PermissionChecker
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.withinApiLevel
|
||||
@@ -18,7 +17,7 @@ enum class Permission(
|
||||
val permissionId: String,
|
||||
val isScanBlocking: Boolean = false,
|
||||
val isGranted: (Context) -> Boolean = {
|
||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
||||
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
|
||||
},
|
||||
) {
|
||||
BLUETOOTH(
|
||||
@@ -87,4 +86,4 @@ enum class Permission(
|
||||
fun Permission.isRequired(context: Context): Boolean = when {
|
||||
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
||||
else -> !isGranted(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
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.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationController
|
||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
||||
navCtrl.setup(backStack)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Cold-start intent (the activity's launching 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) {
|
||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
||||
|
||||
override fun onNewIntent(intent: 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?) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
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
|
||||
}
|
||||
|
||||
@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 =
|
||||
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
||||
(features.hasToneVolume && toneVol != null) ||
|
||||
features.hasMicrophoneMode
|
||||
(features.hasMicrophoneMode && device.microphoneMode != null)
|
||||
if (showSoundSection) {
|
||||
item("sound_section") {
|
||||
SoundCard(
|
||||
@@ -404,7 +404,7 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
|
||||
// ── Controls ─────────────────────────────────
|
||||
val showControlsSection = features.hasStemConfig ||
|
||||
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
|
||||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
|
||||
val volSwipe = device.volumeSwipe
|
||||
val volSwipeLen = device.volumeSwipeLength
|
||||
|
||||
val showPressControlsNav = features.hasStemConfig ||
|
||||
features.hasPressSpeed ||
|
||||
features.hasPressHoldDuration ||
|
||||
features.hasEndCallMuteMic
|
||||
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
||||
if (showPressControlsNav) {
|
||||
|
||||
@@ -81,7 +81,7 @@ internal fun SoundCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (features.hasMicrophoneMode) {
|
||||
if (features.hasMicrophoneMode && device.microphoneMode != null) {
|
||||
if (isPro) {
|
||||
val micMode = device.microphoneMode
|
||||
?: 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.monitor.core.DeviceMonitor
|
||||
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.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
@@ -175,7 +176,7 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
val profiledDevices: List<PodDevice> by lazy {
|
||||
devices.filter { it.profileId != null }.sortedWith(
|
||||
compareBy<PodDevice> { deviceTierRank(it) }
|
||||
compareBy<PodDevice> { it.tierRank() }
|
||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||
)
|
||||
}
|
||||
@@ -284,12 +285,5 @@ class OverviewViewModel @Inject constructor(
|
||||
companion object {
|
||||
private const val FREE_DEVICE_LIMIT = 1
|
||||
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.HasPodStyle
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -227,7 +225,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
) {
|
||||
PodGauge(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
batteryPercent = device.batteryLeft,
|
||||
chargingState = device.leftPodChargingState
|
||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isLeftInEar ?: false,
|
||||
@@ -239,7 +237,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
|
||||
PodGauge(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
batteryPercent = device.batteryRight,
|
||||
chargingState = device.rightPodChargingState
|
||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isRightInEar ?: false,
|
||||
@@ -360,7 +358,7 @@ private fun PodGauge(
|
||||
|
||||
// Battery percentage
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
@@ -412,7 +410,7 @@ private fun CaseRow(
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase.toBatteryFloat(),
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.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.pods.core.apple.aap.AapPodState
|
||||
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.isKnownBattery
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -192,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
||||
val percent = device.batteryHeadset
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "gaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -237,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(88.dp),
|
||||
@@ -250,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
|
||||
// Battery text inside ring
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (device.batteryHeadset != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
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.isKnownBattery
|
||||
|
||||
@Composable
|
||||
fun CompactBatterySummary(
|
||||
device: PodDevice,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasAnyBattery = device.batteryLeft != null
|
||||
|| device.batteryRight != null
|
||||
|| device.batteryHeadset != null
|
||||
|| device.batteryCase != null
|
||||
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||
|| isKnownBattery(device.batteryRight)
|
||||
|| isKnownBattery(device.batteryHeadset)
|
||||
|| isKnownBattery(device.batteryCase)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
||||
percent = device.batteryRight,
|
||||
)
|
||||
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
||||
iconRes = null,
|
||||
percent = device.batteryHeadset,
|
||||
)
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
||||
@Composable
|
||||
private fun MiniPodRing(
|
||||
iconRes: Int?,
|
||||
percent: Float?,
|
||||
percent: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = percent?.coerceIn(0f, 1f)
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "miniGaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(28.dp),
|
||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (percent != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase ?: -1f,
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.width(36.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 eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||
import eu.darken.capod.main.ui.components.shortLabel
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||
)
|
||||
|
||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
||||
)
|
||||
!device.isAapConnected -> {
|
||||
val secondaryRes = if (device.ble != null) {
|
||||
R.string.anc_widget_aap_not_connected_nearby_description
|
||||
} else {
|
||||
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||
}
|
||||
AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = profileLabel ?: device.getLabel(context),
|
||||
secondaryText = context.getString(secondaryRes),
|
||||
)
|
||||
}
|
||||
|
||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||
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.common.compose.Preview2
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -160,7 +160,7 @@ private fun SinglePodPreview(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||
text = formatPercent(state.percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
@@ -311,7 +311,7 @@ private fun PodItemRow(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
@@ -354,7 +354,7 @@ private fun TinyPodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
@@ -382,9 +382,8 @@ private fun DeviceLabel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
private fun formatPercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
|
||||
@@ -27,7 +27,7 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import eu.darken.capod.R
|
||||
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
|
||||
|
||||
@Composable
|
||||
@@ -122,7 +122,7 @@ private fun GlanceSinglePod(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(state.percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
@@ -270,7 +270,7 @@ private fun GlancePodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
@@ -312,7 +312,7 @@ private fun GlanceTinyPodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(start = 4.dp),
|
||||
@@ -341,6 +341,5 @@ private fun GlanceDeviceLabel(
|
||||
|
||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||
|
||||
private fun formatGlancePercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
private fun formatGlancePercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@@ -14,10 +14,10 @@ internal data class WidgetDeviceKey(
|
||||
val profileId: String?,
|
||||
val profileLabel: String?,
|
||||
val model: PodModel,
|
||||
val batteryLeft: Float?,
|
||||
val batteryRight: Float?,
|
||||
val batteryCase: Float?,
|
||||
val batteryHeadset: Float?,
|
||||
val batteryLeft: Float,
|
||||
val batteryRight: Float,
|
||||
val batteryCase: Float,
|
||||
val batteryHeadset: Float,
|
||||
val isLeftPodCharging: Boolean?,
|
||||
val isRightPodCharging: Boolean?,
|
||||
val isCaseCharging: Boolean?,
|
||||
@@ -27,6 +27,7 @@ internal data class WidgetDeviceKey(
|
||||
val isBeingWorn: Boolean?,
|
||||
val isAapConnected: Boolean,
|
||||
val isAapReady: Boolean,
|
||||
val hasBleAdvertisement: Boolean,
|
||||
val ancMode: AapSetting.AncMode.Value?,
|
||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||
@@ -49,6 +50,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
isBeingWorn = isBeingWorn,
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
hasBleAdvertisement = ble != null,
|
||||
ancMode = ancMode?.current,
|
||||
pendingAncMode = pendingAncMode,
|
||||
visibleAncModes = visibleAncModes,
|
||||
|
||||
@@ -7,7 +7,6 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
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.toBatteryFloat
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
|
||||
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||
leftPercent = device.batteryLeft,
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
||||
rightPercent = device.batteryRight,
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase.toBatteryFloat(),
|
||||
casePercent = device.batteryCase,
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
)
|
||||
|
||||
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadset.toBatteryFloat(),
|
||||
percent = device.batteryHeadset,
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == 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.TimeSource
|
||||
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.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
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.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
|
||||
connectedAddresses = connectedAddresses,
|
||||
)
|
||||
}.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(
|
||||
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}.replayingShare(appScope)
|
||||
|
||||
private val reportedPersistFailures = mutableSetOf<String>()
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
val profileId = device.profileId ?: continue
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
try {
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
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.protocol.AapDeviceInfo
|
||||
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.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
@@ -138,18 +139,24 @@ data class PodDevice(
|
||||
}
|
||||
}
|
||||
|
||||
// Battery — AAP preferred, BLE fallback, then cached
|
||||
val batteryLeft: Float?
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
||||
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
|
||||
// for unknown to keep the type primitive; this is the boundary that previously emitted
|
||||
// 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?
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
||||
val batteryRight: Float
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
?: cached?.right?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryCase: Float?
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
||||
val batteryCase: Float
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
?: cached?.case?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryHeadset: Float?
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
||||
val batteryHeadset: Float
|
||||
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). */
|
||||
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.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
try {
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
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}" }
|
||||
break
|
||||
} catch (retryException: Exception) {
|
||||
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
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" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
@@ -218,7 +211,7 @@ class AapAutoConnect @Inject constructor(
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded != null) {
|
||||
aapManager.connect(address, bonded.internal, detectedModel)
|
||||
aapManager.connect(address, bonded.internal!!, detectedModel)
|
||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
|
||||
companion object {
|
||||
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)
|
||||
private val CONNECT_TIMEOUT = 5.seconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -51,7 +53,7 @@ class BlePodMonitor @Inject constructor(
|
||||
private val generalSettings: GeneralSettings,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
permissionTool: PermissionTool,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
|
||||
@@ -89,8 +91,10 @@ class BlePodMonitor @Inject constructor(
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.WARN
|
||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
false
|
||||
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
|
||||
permissionTool.recheck()
|
||||
delay(3000)
|
||||
true
|
||||
} else {
|
||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
delay(3000)
|
||||
@@ -160,13 +164,27 @@ class BlePodMonitor @Inject constructor(
|
||||
else -> ProximityPairing.getBleScanFilter()
|
||||
}
|
||||
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||
disableDirectScanCallback = options.disableDirectCallback,
|
||||
)
|
||||
flow {
|
||||
emitAll(
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
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() }
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ data class CachedDeviceState(
|
||||
) {
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
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(
|
||||
name = deviceName ?: "",
|
||||
modelNumber = "",
|
||||
|
||||
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
|
||||
import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
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.SingleBlePodSnapshot
|
||||
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.Instant
|
||||
|
||||
@@ -14,7 +16,7 @@ import java.time.Instant
|
||||
* Returns null if:
|
||||
* - The device is not live (cached-only)
|
||||
* - 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)
|
||||
*/
|
||||
fun PodDevice.toCachedState(
|
||||
@@ -24,15 +26,18 @@ fun PodDevice.toCachedState(
|
||||
if (!isLive) return null
|
||||
val pid = profileId ?: return null
|
||||
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
||||
|
||||
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
|
||||
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
|
||||
val liveDeviceInfo = aap?.deviceInfo
|
||||
|
||||
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
|
||||
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
|
||||
) return null
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
@@ -60,40 +65,37 @@ fun PodDevice.toCachedState(
|
||||
}
|
||||
|
||||
private fun mergeBatterySlot(
|
||||
livePercent: Float?,
|
||||
livePercent: Float,
|
||||
existing: CachedBatterySlot?,
|
||||
now: Instant,
|
||||
): CachedBatterySlot? {
|
||||
if (livePercent == null) return existing
|
||||
if (existing == null) return CachedBatterySlot(livePercent, now)
|
||||
|
||||
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
|
||||
if (!isKnownBattery(livePercent)) return existing
|
||||
val current = existing ?: return CachedBatterySlot(livePercent, now)
|
||||
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
|
||||
}
|
||||
|
||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||
if (hasSlotTimestampChanged(old.left, new.left)) return true
|
||||
if (hasSlotTimestampChanged(old.right, new.right)) return true
|
||||
if (hasSlotTimestampChanged(old.case, new.case)) return true
|
||||
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
|
||||
return old.left?.percent != new.left?.percent
|
||||
|| old.right?.percent != new.right?.percent
|
||||
|| old.case?.percent != new.case?.percent
|
||||
|| old.headset?.percent != new.headset?.percent
|
||||
|| old.isLeftCharging != new.isLeftCharging
|
||||
if (hasSlotChanged(old.left, new.left)) return true
|
||||
if (hasSlotChanged(old.right, new.right)) return true
|
||||
if (hasSlotChanged(old.case, new.case)) return true
|
||||
if (hasSlotChanged(old.headset, new.headset)) return true
|
||||
return old.isLeftCharging != new.isLeftCharging
|
||||
|| old.isRightCharging != new.isRightCharging
|
||||
|| old.isCaseCharging != new.isCaseCharging
|
||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||
|| old.deviceName != new.deviceName
|
||||
|| old.serialNumber != new.serialNumber
|
||||
|| old.firmwareVersion != new.firmwareVersion
|
||||
|| old.leftEarbudSerial != new.leftEarbudSerial
|
||||
|| old.rightEarbudSerial != new.rightEarbudSerial
|
||||
|| old.marketingVersion != new.marketingVersion
|
||||
}
|
||||
|
||||
private fun hasSlotTimestampChanged(
|
||||
old: CachedBatterySlot?,
|
||||
new: CachedBatterySlot?,
|
||||
): Boolean {
|
||||
if (old == null || new == null) return false
|
||||
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
|
||||
if (old == null && new == null) return false
|
||||
if (old == null || new == null) return true
|
||||
if (old.percent != new.percent) return true
|
||||
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.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
private fun percentToInt(percent: Float?): Int {
|
||||
if (percent == null) return 0
|
||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
private fun percentToInt(percent: Float): Int =
|
||||
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
@@ -37,7 +39,9 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
@@ -52,10 +56,13 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
@@ -74,6 +81,7 @@ enum class PodModel(
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
@@ -314,7 +322,6 @@ enum class PodModel(
|
||||
"Beats Solo Pro",
|
||||
R.drawable.device_beats_headphones,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1881"), // headphones
|
||||
@@ -343,7 +350,6 @@ enum class PodModel(
|
||||
"Beats Studio 3",
|
||||
R.drawable.device_beats_studio3,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1914"), // headphones
|
||||
@@ -410,6 +416,7 @@ enum class PodModel(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
|
||||
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
|
||||
@@ -426,6 +433,7 @@ enum class PodModel(
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
@@ -444,6 +452,8 @@ enum class PodModel(
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
|
||||
leftPodIconRes = R.drawable.device_beats_fitpro_left,
|
||||
@@ -458,6 +468,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -471,6 +482,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -484,6 +496,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
|
||||
@@ -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.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -26,7 +27,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
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.
|
||||
@@ -39,6 +44,7 @@ internal class AapConnection(
|
||||
private val profile: AapDeviceProfile,
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
timeSource: TimeSource,
|
||||
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||
) {
|
||||
|
||||
private val engine = AapSessionEngine(profile, timeSource)
|
||||
@@ -68,7 +74,7 @@ internal class AapConnection(
|
||||
|
||||
try {
|
||||
val sock = socketFactory.createSocket(device, PSM)
|
||||
sock.connect()
|
||||
sock.connectCancellable()
|
||||
socket = sock
|
||||
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
||||
|
||||
@@ -212,15 +218,47 @@ internal class AapConnection(
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
}
|
||||
socket = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PSM = 0x1001
|
||||
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||
private val TAG = logTag("AAP", "Connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
|
||||
|
||||
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 =
|
||||
percent?.let { "${(it * 100).roundToInt()}%" }
|
||||
?: context.getString(R.string.general_value_not_available_label)
|
||||
fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||
else context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
@DrawableRes
|
||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
||||
fun getBatteryDrawable(percent: Float): Int = when {
|
||||
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_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
|
||||
}
|
||||
|
||||
fun getBatteryIcon(percent: Float?): ImageVector = when {
|
||||
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||
fun getBatteryIcon(percent: Float): ImageVector = when {
|
||||
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
|
||||
percent > 0.95f -> Icons.TwoTone.BatteryFull
|
||||
percent > 0.80f -> Icons.TwoTone.Battery6Bar
|
||||
percent > 0.65f -> Icons.TwoTone.Battery5Bar
|
||||
|
||||
@@ -44,8 +44,18 @@ data class AirPodsMax2(
|
||||
return pubFlags.isBitSet(0)
|
||||
}
|
||||
|
||||
// Aggregate "any wear sensor active" — NOT "both earcups worn".
|
||||
// Observed on A3454: bit 5 of pubStatus is always set while advertising and
|
||||
// no longer carries the wear flag (unlike Max gen 1). Bits 1 and 3 of
|
||||
// pubStatus reflect the two earcup sensors (same byte positions used by
|
||||
// DualApplePods, but without the primary/flip semantics).
|
||||
//
|
||||
// We OR them because some Android pairings only see one of the two bits
|
||||
// reliably (issue #548: "both worn" advertises as 0x23 — bit 1 only —
|
||||
// while macOS sees 0x2B with both bits set). AND-ing would falsely report
|
||||
// "not worn" during normal use on those phones.
|
||||
override val isBeingWorn: Boolean
|
||||
get() = pubStatus.isBitSet(5)
|
||||
get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3)
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val repo: PodHistoryRepo,
|
||||
|
||||
@@ -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.ble.formatBatteryPercent
|
||||
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
|
||||
fun PopUpContent(
|
||||
@@ -115,7 +113,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
// Left pod
|
||||
BatteryColumn(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
batteryPercent = device.batteryLeft,
|
||||
isCharging = device.isLeftPodCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -124,7 +122,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
if (device.hasCase) {
|
||||
BatteryColumn(
|
||||
iconRes = device.caseIcon,
|
||||
batteryPercent = device.batteryCase.toBatteryFloat(),
|
||||
batteryPercent = device.batteryCase,
|
||||
isCharging = device.isCaseCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -133,7 +131,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
// Right pod
|
||||
BatteryColumn(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
batteryPercent = device.batteryRight,
|
||||
isCharging = device.isRightPodCharging ?: false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -144,7 +142,7 @@ private fun DualPodContent(device: PodDevice) {
|
||||
private fun SinglePodContent(device: PodDevice) {
|
||||
BatteryColumn(
|
||||
iconRes = device.iconRes,
|
||||
batteryPercent = device.batteryHeadset.toBatteryFloat(),
|
||||
batteryPercent = device.batteryHeadset,
|
||||
isCharging = device.isHeadsetBeingCharged ?: false,
|
||||
)
|
||||
}
|
||||
@@ -157,7 +155,6 @@ private fun BatteryColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val nullablePercent = batteryPercent.toBatteryOrNull()
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
@@ -184,14 +181,14 @@ private fun BatteryColumn(
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = getBatteryIcon(nullablePercent),
|
||||
imageVector = getBatteryIcon(batteryPercent),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = formatBatteryPercent(context, nullablePercent),
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
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_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_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="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_configuration_title">Select Device</string>
|
||||
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
|
||||
@@ -515,7 +522,7 @@
|
||||
<!-- New device settings -->
|
||||
<string name="device_settings_category_general_label">General</string>
|
||||
<string name="device_settings_microphone_mode_label">Microphone</string>
|
||||
<string name="device_settings_microphone_mode_description">Which AirPod is used as the microphone</string>
|
||||
<string name="device_settings_microphone_mode_description">Which earbud is used as the microphone</string>
|
||||
<string name="device_settings_microphone_mode_auto">Auto</string>
|
||||
<string name="device_settings_microphone_mode_right">Right</string>
|
||||
<string name="device_settings_microphone_mode_left">Left</string>
|
||||
|
||||
@@ -12,7 +12,7 @@ fun DashboardLight() = DashboardContent()
|
||||
@PreviewTest
|
||||
@PlayStoreLocalesDark
|
||||
@Composable
|
||||
fun DashboardDark() = DashboardContent()
|
||||
fun DashboardDark() = DashboardContent(showAap = true)
|
||||
|
||||
@PreviewTest
|
||||
@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.bluetooth.BluetoothAddress
|
||||
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.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||
@@ -22,6 +24,7 @@ import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
@@ -31,6 +34,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.TestTimeSource
|
||||
@@ -536,6 +540,96 @@ class DeviceMonitorTest : BaseTest() {
|
||||
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
|
||||
fun `cache-only refresh does not trigger another persist cycle`() = runTest(testDispatcher) {
|
||||
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.pods.core.apple.PodModel
|
||||
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 io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
@@ -57,7 +58,7 @@ class PodDeviceCacheTest : BaseTest() {
|
||||
device.batteryLeft shouldBe 0.8f
|
||||
device.batteryRight shouldBe 0.7f
|
||||
device.batteryCase shouldBe 0.5f
|
||||
device.batteryHeadset.shouldBeNull()
|
||||
device.batteryHeadset shouldBe BATTERY_UNKNOWN
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
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.ble.devices.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.SingleApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
@@ -200,6 +201,23 @@ class PodDeviceTest : BaseTest() {
|
||||
device.isEitherPodInEar shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BLE isBeingWorn delegates to HasEarDetection on single-pod devices`() {
|
||||
val mock = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
|
||||
every { model } returns PodModel.AIRPODS_MAX2
|
||||
every { (this@mockk as HasEarDetection).isBeingWorn } returns true
|
||||
}
|
||||
val device = PodDevice(profileId = null, ble = mock, aap = null)
|
||||
device.isBeingWorn shouldBe true
|
||||
|
||||
val mockNotWorn = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
|
||||
every { model } returns PodModel.AIRPODS_MAX2
|
||||
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
|
||||
}
|
||||
val deviceNotWorn = PodDevice(profileId = null, ble = mockNotWorn, aap = null)
|
||||
deviceNotWorn.isBeingWorn shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP ear detection preferred over BLE`() {
|
||||
val mock = mockk<DualApplePods>(relaxed = true) {
|
||||
|
||||
@@ -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)
|
||||
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.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 io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
@@ -154,5 +156,150 @@ class ToCachedStateTest : BaseTest() {
|
||||
.toCachedState(existing, now)
|
||||
.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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package eu.darken.capod.pods.core.apple
|
||||
|
||||
import io.kotest.assertions.withClue
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
@@ -7,79 +8,364 @@ import testhelpers.BaseTest
|
||||
class ModelFeaturesTest : BaseTest() {
|
||||
|
||||
@Test
|
||||
fun `AirPods Pro 3 has all features`() {
|
||||
val f = PodModel.AIRPODS_PRO3.features
|
||||
f.hasDualPods shouldBe true
|
||||
f.hasCase shouldBe true
|
||||
f.hasEarDetection shouldBe true
|
||||
f.hasAncControl shouldBe true
|
||||
fun `feature flags match supported model matrix`() {
|
||||
featureExpectations.forEach { expectation ->
|
||||
withClue(expectation.name) {
|
||||
modelsWith(expectation.predicate) shouldBe expectation.models
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AirPods Gen 1 has dual pods and case but no ear detection or ANC`() {
|
||||
val f = PodModel.AIRPODS_GEN1.features
|
||||
f.hasDualPods shouldBe true
|
||||
f.hasCase shouldBe true
|
||||
f.hasEarDetection shouldBe false
|
||||
f.hasAncControl shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AirPods Max is single device with ANC but no dual pods or case`() {
|
||||
val f = PodModel.AIRPODS_MAX.features
|
||||
f.hasDualPods shouldBe false
|
||||
f.hasCase shouldBe false
|
||||
f.hasEarDetection shouldBe true
|
||||
f.hasAncControl shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Beats Solo 3 has no features`() {
|
||||
val f = PodModel.BEATS_SOLO_3.features
|
||||
f.hasDualPods shouldBe false
|
||||
f.hasCase shouldBe false
|
||||
f.hasEarDetection shouldBe false
|
||||
f.hasAncControl shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PowerBeats Pro has dual pods, case, ear detection but no ANC`() {
|
||||
val f = PodModel.POWERBEATS_PRO.features
|
||||
f.hasDualPods shouldBe true
|
||||
f.hasCase shouldBe true
|
||||
f.hasEarDetection shouldBe true
|
||||
f.hasAncControl shouldBe false
|
||||
fun `models without ear detection match intentional matrix`() {
|
||||
modelsWithout { it.hasEarDetection } shouldBe setOf(
|
||||
PodModel.BEATS_FLEX,
|
||||
PodModel.BEATS_SOLO_3,
|
||||
PodModel.BEATS_SOLO_PRO,
|
||||
PodModel.BEATS_SOLO_4,
|
||||
PodModel.BEATS_SOLO_BUDS,
|
||||
PodModel.BEATS_STUDIO_3,
|
||||
PodModel.BEATS_STUDIO_BUDS,
|
||||
PodModel.BEATS_STUDIO_BUDS_PLUS,
|
||||
PodModel.BEATS_STUDIO_PRO,
|
||||
PodModel.BEATS_X,
|
||||
PodModel.POWERBEATS_3,
|
||||
PodModel.POWERBEATS_4,
|
||||
PodModel.UNKNOWN,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UNKNOWN model has no features`() {
|
||||
val f = PodModel.UNKNOWN.features
|
||||
f.hasDualPods shouldBe false
|
||||
f.hasCase shouldBe false
|
||||
f.hasEarDetection shouldBe false
|
||||
f.hasAncControl shouldBe false
|
||||
PodModel.UNKNOWN.features shouldBe PodModel.Features()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all ANC-capable dual-pod models have ear detection except Studio Buds`() {
|
||||
val noEarDetectionAncModels = setOf(
|
||||
PodModel.BEATS_STUDIO_BUDS,
|
||||
PodModel.BEATS_STUDIO_BUDS_PLUS,
|
||||
)
|
||||
fun `ear detection toggle implies ear detection`() {
|
||||
PodModel.entries
|
||||
.filter { it.features.hasAncControl && it.features.hasDualPods }
|
||||
.filter { it !in noEarDetectionAncModels }
|
||||
.filter { it.features.hasEarDetectionToggle }
|
||||
.forEach { model ->
|
||||
model.features.hasEarDetection shouldBe true
|
||||
withClue(model.name) {
|
||||
model.features.hasEarDetection shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all models with case also have dual pods`() {
|
||||
fun `sleep detection implies ear detection`() {
|
||||
PodModel.entries
|
||||
.filter { it.features.hasSleepDetection }
|
||||
.forEach { model ->
|
||||
withClue(model.name) {
|
||||
model.features.hasEarDetection shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case implies dual pods`() {
|
||||
PodModel.entries
|
||||
.filter { it.features.hasCase }
|
||||
.forEach { model ->
|
||||
model.features.hasDualPods shouldBe true
|
||||
withClue(model.name) {
|
||||
model.features.hasDualPods shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adaptive ANC implies ANC control`() {
|
||||
PodModel.entries
|
||||
.filter { it.features.hasAdaptiveAnc }
|
||||
.forEach { model ->
|
||||
withClue(model.name) {
|
||||
model.features.hasAncControl shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adaptive audio noise implies adaptive ANC`() {
|
||||
PodModel.entries
|
||||
.filter { it.features.hasAdaptiveAudioNoise }
|
||||
.forEach { model ->
|
||||
withClue(model.name) {
|
||||
model.features.hasAdaptiveAnc shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listening mode cycle implies ANC control`() {
|
||||
PodModel.entries
|
||||
.filter { it.features.hasListeningModeCycle }
|
||||
.forEach { model ->
|
||||
withClue(model.name) {
|
||||
model.features.hasAncControl shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `allow off option implies listening mode cycle`() {
|
||||
PodModel.entries
|
||||
.filter { it.features.hasAllowOffOption }
|
||||
.forEach { model ->
|
||||
withClue(model.name) {
|
||||
model.features.hasListeningModeCycle shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
|
||||
.filter { predicate(it.features) }
|
||||
.toSet()
|
||||
|
||||
private fun modelsWithout(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
|
||||
.filterNot { predicate(it.features) }
|
||||
.toSet()
|
||||
|
||||
private data class FeatureExpectation(
|
||||
val name: String,
|
||||
val predicate: (PodModel.Features) -> Boolean,
|
||||
val models: Set<PodModel>,
|
||||
)
|
||||
|
||||
private fun feature(
|
||||
name: String,
|
||||
predicate: (PodModel.Features) -> Boolean,
|
||||
models: Set<PodModel>,
|
||||
) = FeatureExpectation(name, predicate, models)
|
||||
|
||||
private val dualPodModels = setOf(
|
||||
PodModel.AIRPODS_GEN1,
|
||||
PodModel.AIRPODS_GEN2,
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.BEATS_SOLO_BUDS,
|
||||
PodModel.BEATS_STUDIO_BUDS,
|
||||
PodModel.BEATS_STUDIO_BUDS_PLUS,
|
||||
PodModel.POWERBEATS_PRO,
|
||||
PodModel.POWERBEATS_PRO2,
|
||||
PodModel.BEATS_FIT_PRO,
|
||||
PodModel.FAKE_AIRPODS_GEN1,
|
||||
PodModel.FAKE_AIRPODS_GEN2,
|
||||
PodModel.FAKE_AIRPODS_GEN3,
|
||||
PodModel.FAKE_AIRPODS_PRO,
|
||||
PodModel.FAKE_AIRPODS_PRO2,
|
||||
)
|
||||
|
||||
private val earDetectionModels = setOf(
|
||||
PodModel.AIRPODS_GEN1,
|
||||
PodModel.AIRPODS_GEN2,
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX,
|
||||
PodModel.AIRPODS_MAX_USBC,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
PodModel.POWERBEATS_PRO,
|
||||
PodModel.POWERBEATS_PRO2,
|
||||
PodModel.BEATS_FIT_PRO,
|
||||
PodModel.FAKE_AIRPODS_GEN1,
|
||||
PodModel.FAKE_AIRPODS_GEN2,
|
||||
PodModel.FAKE_AIRPODS_GEN3,
|
||||
PodModel.FAKE_AIRPODS_PRO,
|
||||
PodModel.FAKE_AIRPODS_PRO2,
|
||||
)
|
||||
|
||||
private val ancControlModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX,
|
||||
PodModel.AIRPODS_MAX_USBC,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
PodModel.BEATS_SOLO_PRO,
|
||||
PodModel.BEATS_STUDIO_3,
|
||||
PodModel.BEATS_STUDIO_BUDS,
|
||||
PodModel.BEATS_STUDIO_BUDS_PLUS,
|
||||
PodModel.BEATS_STUDIO_PRO,
|
||||
PodModel.POWERBEATS_PRO2,
|
||||
PodModel.BEATS_FIT_PRO,
|
||||
PodModel.FAKE_AIRPODS_PRO,
|
||||
PodModel.FAKE_AIRPODS_PRO2,
|
||||
)
|
||||
|
||||
private val adaptiveAncModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val conversationAwarenessModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val ncOneAirpodModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
)
|
||||
|
||||
private val pressSpeedModels = setOf(
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX,
|
||||
PodModel.AIRPODS_MAX_USBC,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val volumeSwipeModels = setOf(
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
)
|
||||
|
||||
private val personalizedVolumeModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val toneVolumeModels = setOf(
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX,
|
||||
PodModel.AIRPODS_MAX_USBC,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val endCallMuteMicModels = setOf(
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
)
|
||||
|
||||
private val adaptiveAudioNoiseModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val microphoneModeModels = setOf(
|
||||
PodModel.AIRPODS_GEN1,
|
||||
PodModel.AIRPODS_GEN2,
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.POWERBEATS_PRO2,
|
||||
PodModel.BEATS_FIT_PRO,
|
||||
)
|
||||
|
||||
private val earDetectionToggleModels = setOf(
|
||||
PodModel.AIRPODS_GEN1,
|
||||
PodModel.AIRPODS_GEN2,
|
||||
PodModel.AIRPODS_GEN3,
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX,
|
||||
PodModel.AIRPODS_MAX_USBC,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
PodModel.POWERBEATS_PRO,
|
||||
PodModel.POWERBEATS_PRO2,
|
||||
PodModel.BEATS_FIT_PRO,
|
||||
)
|
||||
|
||||
private val listeningModeCycleModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.AIRPODS_MAX,
|
||||
PodModel.AIRPODS_MAX_USBC,
|
||||
PodModel.AIRPODS_MAX2,
|
||||
)
|
||||
|
||||
private val stemConfigModels = setOf(
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
)
|
||||
|
||||
private val sleepDetectionModels = setOf(
|
||||
PodModel.AIRPODS_GEN4,
|
||||
PodModel.AIRPODS_GEN4_ANC,
|
||||
PodModel.AIRPODS_PRO2,
|
||||
PodModel.AIRPODS_PRO2_USBC,
|
||||
PodModel.AIRPODS_PRO3,
|
||||
PodModel.POWERBEATS_PRO2,
|
||||
)
|
||||
|
||||
private val featureExpectations = listOf(
|
||||
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
|
||||
feature("hasCase", { it.hasCase }, dualPodModels),
|
||||
feature("hasEarDetection", { it.hasEarDetection }, earDetectionModels),
|
||||
feature("hasAncControl", { it.hasAncControl }, ancControlModels),
|
||||
feature("hasAdaptiveAnc", { it.hasAdaptiveAnc }, adaptiveAncModels),
|
||||
feature("hasConversationAwareness", { it.hasConversationAwareness }, conversationAwarenessModels),
|
||||
feature("hasNcOneAirpod", { it.hasNcOneAirpod }, ncOneAirpodModels),
|
||||
feature("hasPressSpeed", { it.hasPressSpeed }, pressSpeedModels),
|
||||
feature("hasPressHoldDuration", { it.hasPressHoldDuration }, pressSpeedModels),
|
||||
feature("hasVolumeSwipe", { it.hasVolumeSwipe }, volumeSwipeModels),
|
||||
feature("hasVolumeSwipeLength", { it.hasVolumeSwipeLength }, volumeSwipeModels),
|
||||
feature("hasPersonalizedVolume", { it.hasPersonalizedVolume }, personalizedVolumeModels),
|
||||
feature("hasToneVolume", { it.hasToneVolume }, toneVolumeModels),
|
||||
feature("hasEndCallMuteMic", { it.hasEndCallMuteMic }, endCallMuteMicModels),
|
||||
feature("hasAdaptiveAudioNoise", { it.hasAdaptiveAudioNoise }, adaptiveAudioNoiseModels),
|
||||
feature("hasMicrophoneMode", { it.hasMicrophoneMode }, microphoneModeModels),
|
||||
feature("hasEarDetectionToggle", { it.hasEarDetectionToggle }, earDetectionToggleModels),
|
||||
feature("hasListeningModeCycle", { it.hasListeningModeCycle }, listeningModeCycleModels),
|
||||
feature("hasAllowOffOption", { it.hasAllowOffOption }, listeningModeCycleModels),
|
||||
feature("hasStemConfig", { it.hasStemConfig }, stemConfigModels),
|
||||
feature("hasSleepDetection", { it.hasSleepDetection }, sleepDetectionModels),
|
||||
feature("hasDynamicEndOfCharge", { it.hasDynamicEndOfCharge }, setOf(PodModel.AIRPODS_PRO3)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import android.bluetooth.BluetoothSocket
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||
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.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.maps.shouldBeEmpty
|
||||
@@ -13,6 +15,7 @@ import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -24,6 +27,10 @@ import testhelpers.TestTimeSource
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
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() {
|
||||
|
||||
@@ -89,6 +96,45 @@ class AapConnectionManagerTest : BaseTest() {
|
||||
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
|
||||
fun `remote disconnect cleans up allStates`() = testScope.runTest {
|
||||
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED
|
||||
|
||||
@@ -337,6 +337,7 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
|
||||
f.hasPressSpeed shouldBe true
|
||||
f.hasPressHoldDuration shouldBe true
|
||||
f.hasToneVolume shouldBe true
|
||||
f.hasEndCallMuteMic shouldBe true
|
||||
f.hasListeningModeCycle shouldBe false
|
||||
f.hasStemConfig shouldBe false
|
||||
f.hasSleepDetection shouldBe true
|
||||
@@ -351,14 +352,31 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
|
||||
f.hasStemConfig shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `Gen 1 has mic mode only`() {
|
||||
@Test fun `Gen 1 has mic mode and ear detection toggle`() {
|
||||
val f = PodModel.AIRPODS_GEN1.features
|
||||
f.hasMicrophoneMode shouldBe true
|
||||
f.hasEarDetectionToggle shouldBe false
|
||||
f.hasEarDetection shouldBe true
|
||||
f.hasEarDetectionToggle shouldBe true
|
||||
f.hasListeningModeCycle shouldBe false
|
||||
f.hasStemConfig shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `Gen 2 and Gen 3 expose ear detection toggle`() {
|
||||
val gen2 = PodModel.AIRPODS_GEN2.features
|
||||
gen2.hasMicrophoneMode shouldBe true
|
||||
gen2.hasEarDetection shouldBe true
|
||||
gen2.hasEarDetectionToggle shouldBe true
|
||||
|
||||
val gen3 = PodModel.AIRPODS_GEN3.features
|
||||
gen3.hasMicrophoneMode shouldBe true
|
||||
gen3.hasEarDetection shouldBe true
|
||||
gen3.hasEarDetectionToggle shouldBe true
|
||||
gen3.hasPressSpeed shouldBe true
|
||||
gen3.hasPressHoldDuration shouldBe true
|
||||
gen3.hasToneVolume shouldBe true
|
||||
gen3.hasEndCallMuteMic shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `Max 2 has H2 features`() {
|
||||
val f = PodModel.AIRPODS_MAX2.features
|
||||
f.hasAdaptiveAnc shouldBe true
|
||||
@@ -382,6 +400,18 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
|
||||
f.hasEarDetectionToggle shouldBe true
|
||||
f.hasAncControl shouldBe true
|
||||
f.hasEarDetection shouldBe true
|
||||
f.hasMicrophoneMode shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `Powerbeats Pro and Beats Fit Pro expose ear detection toggle`() {
|
||||
val powerbeatsPro = PodModel.POWERBEATS_PRO.features
|
||||
powerbeatsPro.hasEarDetection shouldBe true
|
||||
powerbeatsPro.hasEarDetectionToggle shouldBe true
|
||||
|
||||
val beatsFitPro = PodModel.BEATS_FIT_PRO.features
|
||||
beatsFitPro.hasEarDetection shouldBe true
|
||||
beatsFitPro.hasEarDetectionToggle shouldBe true
|
||||
beatsFitPro.hasMicrophoneMode shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +443,14 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `earDetectionToggle implies earDetection`() {
|
||||
for (model in PodModel.entries) {
|
||||
if (model.features.hasEarDetectionToggle) {
|
||||
model.features.hasEarDetection shouldBe true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `adaptiveAudioNoise implies adaptiveAnc`() {
|
||||
for (model in PodModel.entries) {
|
||||
if (model.features.hasAdaptiveAudioNoise) {
|
||||
|
||||
@@ -24,6 +24,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
|
||||
batteryHeadsetPercent shouldBe 0.6f
|
||||
|
||||
isHeadsetBeingCharged shouldBe false
|
||||
isBeingWorn shouldBe true
|
||||
|
||||
model shouldBe PodModel.AIRPODS_MAX2
|
||||
}
|
||||
@@ -35,6 +36,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
|
||||
pubStatus shouldBe 0x20.toUByte()
|
||||
batteryHeadsetPercent shouldBe 0.6f
|
||||
isHeadsetBeingCharged shouldBe false
|
||||
isBeingWorn shouldBe false
|
||||
model shouldBe PodModel.AIRPODS_MAX2
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
|
||||
pubStatus shouldBe 0x21.toUByte()
|
||||
batteryHeadsetPercent shouldBe 0.6f
|
||||
isHeadsetBeingCharged shouldBe false
|
||||
isBeingWorn shouldBe false
|
||||
model shouldBe PodModel.AIRPODS_MAX2
|
||||
}
|
||||
|
||||
@@ -49,6 +52,17 @@ class AirPodsMax2Test : BaseBlePodsTest() {
|
||||
pubStatus shouldBe 0x23.toUByte()
|
||||
batteryHeadsetPercent shouldBe 0.6f
|
||||
isHeadsetBeingCharged shouldBe false
|
||||
isBeingWorn shouldBe true
|
||||
model shouldBe PodModel.AIRPODS_MAX2
|
||||
}
|
||||
|
||||
// Synthetic: one earcup off, the other on (issue #548 sees 0x29 in this state on
|
||||
// both macOS and Android; we don't claim left vs right side semantics here).
|
||||
create<AirPodsMax2>("07 19 01 2D 20 29 F6 8F 03 14 04 E1 8B 99 98 20 0B C3 C1 12 2D B0 43 98 94 D6 A0") {
|
||||
pubStatus shouldBe 0x29.toUByte()
|
||||
batteryHeadsetPercent shouldBe 0.6f
|
||||
isHeadsetBeingCharged shouldBe false
|
||||
isBeingWorn shouldBe true
|
||||
model shouldBe PodModel.AIRPODS_MAX2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,20 @@ platform :android do
|
||||
)
|
||||
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|
|
||||
# This block is called, only if the executed lane was successful
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ GEM
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.8)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1216.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-partitions (1.1244.0)
|
||||
aws-sdk-core (3.246.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -17,11 +17,11 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.122.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (1.124.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-s3 (1.220.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
@@ -29,7 +29,7 @@ GEM
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
@@ -68,11 +68,11 @@ GEM
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.232.1)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.233.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
@@ -92,7 +92,7 @@ GEM
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.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)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
@@ -122,10 +122,9 @@ GEM
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
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.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
@@ -135,19 +134,19 @@ GEM
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
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-playcustomapp_v1 (0.17.0)
|
||||
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-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
@@ -169,13 +168,13 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.18.1)
|
||||
json (2.19.5)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multi_json (1.21.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
@@ -185,13 +184,13 @@ GEM
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.2)
|
||||
rake (13.3.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.2.0)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
@@ -205,7 +204,6 @@ GEM
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
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 |