Compare commits

..
38 Commits
Author SHA1 Message Date
d4rken-org-releaser[bot] dd827af3cd Release: 5.1.6-rc0 2026-05-26 13:32:39 +00:00
Matthias Urhahn 948205667b General: Update translations for 75 languages (#600)
* General: Update app translations from Crowdin

* General: Include latest Polish community translation updates
2026-05-26 12:20:17 +02:00
darken dbbfa75429 feat(reaction): Add Conversational Awareness media reaction
Reacts to the AirPods speaking-detection event (AAP 0x4B): lowers media volume by a configurable amount or pauses playback when you start talking, and reverts when you stop. Per-profile, Pro-gated, opt-in (default off).

Decodes the speaking status from the last payload byte ({1,2}=start, {6,8,9}=stop, else keep-alive); engage/disengage with a frame-idle stale timeout to recover a dropped stop, plus disconnect and service-stop cleanup.
2026-05-25 23:55:55 +02:00
Matthias Urhahn 78790f73df Delete Screenshot_20220914_174644.png~ 2026-05-11 12:39:16 +02:00
darken 2950d8b3f9 docs: Update README screenshots to current fastlane set 2026-05-11 12:37:41 +02:00
Matthias Urhahn 4d3a9bc165 refactor: Remove General Settings Debug menu and dead supporting code 2026-05-07 15:41:57 +02:00
Matthias Urhahn 7d57845502 fix(monitor): Make Extra notification toggle reactive 2026-05-07 15:23:06 +02:00
Matthias Urhahn 83ef111599 refactor: Move nudge persistence into GeneralSettings
Consolidate the nudge availability DataStoreValue with the rest of the persisted settings instead of carrying it in a separate Hilt module + DataStore file. Matches the existing convention where compat-style flags (offloaded filtering, indirect callbacks, etc.) live alongside theme/notification settings.

NudgeCapabilityStore keeps the verdict-mapping behavior; only the source of the persisted value changes.
2026-05-07 14:10:16 +02:00
Matthias Urhahn 0e6848d886 fix: Address Codex review on auto-monitor-mode
- Seed connectedDevices flow with onStart so ALWAYS autolaunch fires before HEADSET profile binds

- Gate UnavailableMissingPermission classification on Android 12+ (BLUETOOTH_CONNECT only exists from API 31)

- Treat blank/empty profile.address as unpaired (matches AutoConnect)

- Subscribe DeviceSettingsViewModel to nudgeCapabilityStore.availability so UI updates immediately on verdict change

- Seed availability StateFlow with the persisted value via valueBlocking to close the cold-start race
2026-05-07 14:10:16 +02:00
Matthias Urhahn 1eeeb63a76 test: Cover nudge persistence, permission gating, and autolaunch reactivity 2026-05-07 14:10:16 +02:00
Matthias Urhahn a4ba302223 refactor(monitor): Compute monitor mode automatically from profile state 2026-05-07 14:10:16 +02:00
Matthias Urhahn 8c9094d85a ui(reaction): Tighten Auto play / Start music on wear descriptions 2026-05-07 14:08:22 +02:00
Matthias Urhahn 4529b107dd fix(reaction): Only auto-resume after ear-removal pause, not stem or sleep
Matches Apple's iOS/macOS behavior: stem-press pauses and sleep-detection pauses are explicit user intent, not eligible for auto-resume on next pod-in. sendPause gains a rememberForResume parameter (default false); only PlayPause's auto-pause branch passes true. New sendStop wrapper clears the auto-resume flag for stem-mapped MEDIA_STOP.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 17663bc759 refactor(reaction): Switch to sticky resume flag with opt-in cold-wear
Replaces the origin-tracking machinery with a simpler model that matches Apple's iOS/macOS behavior: auto-play strictly resumes a CAP-dispatched pause, gated by a sticky boolean cleared on inactive→active transitions. The original fire-on-cold-wear behavior is preserved as a per-device opt-in 'Start music on wear' setting.
2026-05-07 14:08:22 +02:00
Matthias Urhahn 278257998b fix(reaction): Don't auto-resume music after a user-initiated pause 2026-05-07 14:08:22 +02:00
d4rken-org-releaser[bot] 9d41979499 Release: 5.1.5-rc0 2026-05-06 10:16:00 +00:00
darken 985b73754e fix: Eliminate Float? boxing from battery display and cache merge
Two SIGSEGV native crashes recurred on Android 10 in 5.1.4-rc0 inside JIT-cached code at toBatteryFloat+4 (popup) and mergeBatterySlot+40 (cache merge). Both functions had a boxed Float? unbox at function entry that R8 horizontally merged into stdlib host classes, where Android 10 ART JIT miscompiled the unbox.

Convert PodDevice battery getters to non-null Float with BATTERY_UNKNOWN sentinel and propagate primitive Float through every display/persistence consumer. mergeBatterySlot now takes primitive Float; toBatteryFloat and toBatteryOrNull are deleted. Add isKnownBattery and batteryProgress helpers used everywhere instead of scattered nullable checks. Raw live extraction in toCachedState avoids touching the unified getter so cached values aren't refreshed as live.
2026-05-06 11:54:25 +02:00
Matthias Urhahn e4e4190523 General: Update zh-CN phone screenshots 2026-05-04 22:10:14 +02:00
Matthias Urhahn ebfb034fdd fix(navigation): Defer onNewIntent navCtrl call until Compose attaches
Move consumeUpgradeExtra() out of MainActivity.onNewIntent and into a Compose-level LaunchedEffect that collects a new MainActivity.warmIntents SingleEventFlow.

onNewIntent could fire before the setContent{} lambda registered the back stack with NavigationController, leaving navCtrl.goTo(Nav.Main.Upgrade) to throw IllegalStateException("NavigationController not initialized"). Same race shipped a fix for in octi (d4rken-org/octi#284).
2026-05-04 21:42:53 +02:00
Matthias Urhahn 8a81d354e6 chore(screenshots): Improve dashboard and add-profile previews
Use AAP-connected mocks for the dark dashboard to showcase full connectivity badges (BLE + IRK + encrypted + AAP) and the ANC mode selector. Populate the add-profile preview with a demo name, model, and paired device. Add user-facing labels to the dashboard mock devices.

Refactor BluetoothDevice2 so name/address are primary constructor fields, letting previews construct one without a real Android BluetoothDevice. Bump the screenshot test JVM heap to 4g — the smoke batch (42 renders) was hitting the test executor's default ceiling.
2026-05-04 21:30:25 +02:00
Matthias Urhahn ac0c4693dc chore(screenshots): Switch to smoke-only Play Store screenshot commits
Mirrors permission-pilot's policy: only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) check phoneScreenshots PNGs into the repo. Non-smoke locales are excluded via .gitignore. Drops fastlane/metadata/android/ from ~67 MB to ~7 MB and prevents future bloat from full regens. Play Store's supply retains previously-uploaded screenshots for locales not pushed, so full localization is maintained by occasional manual regen + screenshots_only upload.
2026-05-04 19:27:57 +02:00
Matthias Urhahn 4fd7a41c53 chore(screenshots): Regenerate Play Store screenshots
Replaces stale 7_settings.png and 8_reaction_settings.png with the current 7_device_settings_reactions.png across all 68 locales.
2026-05-04 18:50:30 +02:00
Matthias Urhahn cc1caf47f0 chore(fastlane): Add screenshots_only supply lane 2026-05-04 18:32:00 +02:00
darken 8b9f953818 feat(widget): Show profile name and nearby state when ANC widget is disconnected 2026-05-04 13:03:27 +02:00
d4rken-org-releaser[bot] d541de8c38 Release: 5.1.4-rc0 2026-05-03 17:32:42 +00:00
darken 2821da434d fix(settings): Hide Microphone & Press controls when AAP isn't ready 2026-05-03 19:27:52 +02:00
darken 07c72dc86c fix(aap): Cancel hung BluetoothSocket connect on timeout
BluetoothSocket.connect() is a blocking JNI call that ignores coroutine cancellation, so the previous withTimeout in AapAutoConnect only cancelled the suspending wrapper while the native thread stayed pinned. Hung threads accumulated and could trigger ANRs.

Move the timeout inside AapConnection and run the blocking connect on a daemon thread; on timeout, close the socket from the caller thread to unblock the native call (the documented Android pattern for cancelling in-flight L2CAP connects).

Also cancel appScope before delegating uncaught exceptions so coroutines have a best-effort window to release resources before the system handler terminates the process.
2026-05-03 15:39:03 +02:00
darken 7d93e772b4 fix(monitor): Prevent NPE in cache merge from freezing device flow
Battery slot percent comparisons in mergeBatterySlot/hasStateChanged compiled to Intrinsics.areEqual on boxed Float; R8 optimization on Android 10/11 dropped a null check during inlining and the resulting NPE escaped onEach { persistLiveDevices }, cancelling the upstream combine and freezing every observer of DeviceMonitor.devices.

Comparisons now operate on primitive float (cmpg-float in dex) so no Intrinsics.areEqual call remains in the merge path. The persist loop also catches and reports per-profile, and AAP-only profiles with active DeviceInfo are now persisted even when no BLE pod is in range.
2026-05-03 14:20:49 +02:00
darken 82dc88faac fix: Harden uncaught exception safety net
Wrap logging, reporting, and Looper resume calls so the foreground service timing exception suppression cannot itself trigger another crash. Extract handler into a dedicated class with seams for unit tests.
2026-05-03 12:41:32 +02:00
darken 06b41ec8eb fix(monitor): Recover from revoked Bluetooth scan permission 2026-05-03 11:54:11 +02:00
darken acc6e92b2d feat(tile): Add ANC Quick Settings tile 2026-05-03 10:58:05 +02:00
d4rken-org-releaser[bot] 39922df792 Release: 5.1.3-rc0 2026-05-01 19:52:12 +00:00
darken 5a8c4b94ef fix: Correct wear detection on AirPods Max 2
Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.

Closes #548
2026-05-01 21:50:51 +02:00
darken 0ffa87deea CI: Skip release bump-only workflow runs 2026-05-01 20:08:42 +02:00
darken fc98db09a2 fix: Correct PodModel feature flags for ear detection and call controls
Sync flags with what the BLE classes actually report and what iOS exposes:

- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle

- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)

- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)

- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)

- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual

- Generalize microphone mode description from 'AirPod' to 'earbud'

Tests rewritten as exhaustive set assertions plus implication invariants.
2026-05-01 20:08:29 +02:00
darken e1c0a702dd chore(ci): Rename stale step name and use client-id for App auth 2026-05-01 09:15:43 +02:00
d4rken-org-releaser[bot] 3f310016b1 Release: 5.1.2-rc1 2026-05-01 07:04:37 +00:00
darken d399a6d908 fix(ci): Drop redundant explicit dispatch in release-prepare 2026-05-01 09:00:29 +02:00
755 changed files with 6939 additions and 2590 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

+3 -3
View File
@@ -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`.
+30 -10
View File
@@ -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)
+4 -1
View File
@@ -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
+8
View File
@@ -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:
+3 -15
View File
@@ -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
+10
View File
@@ -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/
+1 -2
View File
@@ -81,8 +81,7 @@ Currently supported models:
## Screenshots
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
## Thanks to
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -1 +1 @@
5.1.2-rc0 50102000
5.1.6-rc0 50106000
+1
View File
@@ -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,17 +38,26 @@ 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(),
),
isDebugMode = false,
devices = devices,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -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
+7 -7
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">تبرُّع</string>
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string>
<string name="foss_upgrade_donate_label">تبرع</string>
<string name="foss_upgrade_alreadydonated_label">أنا متبرّع فعلًا</string>
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم المشروع</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدتم فعلًا؟ دعمكم يُبقي CAPod حيًا.</string>
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
</resources>
+5 -5
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متوفّرة.</string>
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string>
<string name="upgrade_restore_action">استعادة المُشتَرَى</string>
<string name="upgrade_badge_label">احترافي</string>
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من Google Play. يُرجى مسح ذاكرة التخزين المؤقّت لـ Google Play وإعادة تشغيل هاتفك.\n\nخطأ %s</string>
<string name="upgrade_restore_action">استعادة المشتريات</string>
<string name="upgrade_badge_label">النسخة الاحترافية</string>
</resources>
+15 -1
View File
@@ -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>
+14 -30
View File
@@ -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
}
@@ -1,8 +1,11 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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 kotlinx.coroutines.delay
@@ -14,48 +17,103 @@ class MediaControl @Inject constructor(
private val audioManager: AudioManager,
private val timeSource: TimeSource,
) {
private var capPauseExpiryElapsedRealtime: Long = 0L
/**
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
* dispatches a resume or when music transitions inactive→active from any source. Read by
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
*
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
* pod-out conversations and had a known race where music starting again via another source
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
*/
@Volatile private var capPaused: Boolean = false
@Volatile private var lastKnownMusicActive: Boolean = false
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
val nowActive = audioManager.isMusicActive
if (!lastKnownMusicActive && nowActive) {
// Music started by some source (could be us via sendPlay or someone else).
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
// re-fire a play key over already-active music.
capPaused = false
}
lastKnownMusicActive = nowActive
}
}
init {
// Seed the active flag from current state so we won't miss the next inactive→active
// transition if music is already playing when MediaControl is constructed.
lastKnownMusicActive = audioManager.isMusicActive
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
}
val isPlaying: Boolean
get() = audioManager.isMusicActive
val wasRecentlyPausedByCap: Boolean
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
get() = capPaused
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
if (audioManager.isMusicActive && !capPaused) {
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
clearRecentCapPause()
capPaused = false
}
/**
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
*
* Returns `true` when a key event was actually dispatched (and the 15-second
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
* cooldown on a real pause — should branch on the return value rather than checking
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
* @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
* pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
* flow. When `false` (default), the dispatched pause clears any pending auto-resume —
* this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
* is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
* behavior where only ear-removal auto-pauses are eligible for auto-resume.
*
* Returns `true` when a key event was actually dispatched, `false` when the call was a
* no-op because nothing was playing. A no-op leaves the existing flag state untouched
* (so a sleep-reaction firing while music is already paused doesn't accidentally cancel
* a pending auto-resume from a recent ear-removal pause). Callers that need to
* distinguish "we actually paused" from "there was nothing to pause" — e.g. the sleep
* reaction, which gates its notification and cooldown on a real pause — should branch
* on the return value rather than checking [isPlaying] themselves to avoid a
* check-then-act race with the audio system.
*/
suspend fun sendPause(): Boolean {
log(TAG, INFO) { "sendPause()" }
suspend fun sendPause(rememberForResume: Boolean = false): Boolean {
log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" }
return false
}
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
// playback callback that fires during the dispatch (e.g. a fast user resume on the
// phone, or another app grabbing audio focus and immediately starting) could clear
// capPaused mid-dispatch and we'd then overwrite it back to true on a stale pause.
// This single explicit assignment also covers the contract that an explicit user
// pause cancels a pending auto-resume.
capPaused = rememberForResume
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
return true
}
/**
* Dispatches MEDIA_STOP and clears any pending auto-resume — Stop is an explicit user
* "stay stopped" action, so a later pod-in must not auto-resume from a prior auto-pause.
*/
suspend fun sendStop() {
log(TAG, INFO) { "sendStop()" }
capPaused = false
sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (wasRecentlyPausedByCap) {
if (capPaused) {
sendPlay()
return
}
@@ -101,16 +159,71 @@ class MediaControl @Inject constructor(
)
}
private fun markRecentCapPause() {
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
/**
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
* the prior + the volume actually applied, so the caller can later restore it and detect whether
* the user changed the volume in the meantime.
*
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
* frequent push event and the volume panel flashing would be noisy. The applied target is read
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
*/
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
if (!audioManager.isMusicActive) {
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
return null
}
if (audioManager.isVolumeFixed) {
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
return null
}
val percent = reductionPercent.coerceIn(0, 100)
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
} else {
0
}
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
if (target >= prior) {
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
return null
}
return try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
VolumeDuck(priorVolume = prior, appliedVolume = applied)
} catch (e: SecurityException) {
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
null
}
}
private fun clearRecentCapPause() {
capPauseExpiryElapsedRealtime = 0L
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
fun restoreMusicVolume(priorVolume: Int) {
if (audioManager.isVolumeFixed) return
try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
} catch (e: SecurityException) {
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
}
}
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
data class VolumeDuck(
val priorVolume: Int,
val appliedVolume: Int,
)
companion object {
private val TAG = logTag("MediaControl")
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
}
}
@@ -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
@@ -32,7 +33,6 @@ import javax.inject.Singleton
class BleScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val bluetoothManager: BluetoothManager2,
private val fakeBleData: FakeBleData,
private val scanResultForwarder: BleScanResultForwarder,
private val timeSource: TimeSource,
) {
@@ -113,22 +113,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,31 +152,59 @@ 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" }
}
}
.map { fakeBleData.maybeAddfakeData(it) }
private val receiverIntent by lazy {
Intent(context, BleScanResultReceiver::class.java).apply {
@@ -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,
)
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -23,6 +24,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.flow.setupCommonEventHandlers
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
@@ -255,6 +257,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 +308,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 {
@@ -386,9 +392,6 @@ class BluetoothManager2 @Inject constructor(
initialValue = emptySet(),
)
private var _isNudgeAvailable: Boolean = true
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
/**
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
@@ -408,9 +411,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) {
@@ -420,28 +424,42 @@ class BluetoothManager2 @Inject constructor(
}
}
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val target = device.internal ?: return@map NudgeAttemptResult.Rejected
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted
} catch (e: Exception) {
val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
if (isSecurityException) {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
_isNudgeAvailable = false
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
} catch (e: Exception) {
val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
if (!isSecurityException) {
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
return@map NudgeAttemptResult.Rejected
}
// BLUETOOTH_CONNECT is only a runtime permission on Android 12+. On older
// versions a SecurityException can't be a missing-permission failure for that
// permission — it's hidden-API enforcement (e.g. MODIFY_PHONE_STATE) and we
// should persist BROKEN.
val missingBtConnect = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
!Permission.BLUETOOTH_CONNECT.isGranted(context)
if (missingBtConnect) {
log(TAG, WARN) { "nudgeConnection failed because BLUETOOTH_CONNECT is not granted" }
NudgeAttemptResult.UnavailableMissingPermission
} else {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable on this device: $e" }
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
NudgeAttemptResult.UnavailableHiddenApi
}
}
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
}.first()
}.first()
companion object {
private val TAG = logTag("Bluetooth", "Manager2")
@@ -1,103 +0,0 @@
package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.fromHex
import javax.inject.Inject
import kotlin.random.Random
import eu.darken.capod.common.datastore.valueBlocking
@Reusable
class FakeBleData @Inject constructor(
private val debugSettings: DebugSettings,
private val timeSource: TimeSource,
) {
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.valueBlocking) return originals
return originals + getFakeData()
}
fun getFakeData(): Collection<BleScanResult> {
val fakeDevices = mutableListOf<BleScanResult>()
val now = timeSource.now()
val generatedAt = timeSource.elapsedRealtimeNanos()
// AirPods Gen1
BleScanResult(
receivedAt = now,
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = generatedAt + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen2
BleScanResult(
receivedAt = now,
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = generatedAt + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Gen3
BleScanResult(
receivedAt = now,
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
).run {
fakeDevices.add(this)
}
// AirPods Max
BleScanResult(
receivedAt = now,
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
).run {
fakeDevices.add(this)
}
// BeatsFlex
BleScanResult(
receivedAt = now,
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
// Tws i99999
BleScanResult(
receivedAt = now,
address = "5E:9E:D1:29:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
).run {
fakeDevices.add(this)
}
// Unknown Device
BleScanResult(
receivedAt = now,
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = generatedAt + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
return fakeDevices
}
}
@@ -0,0 +1,18 @@
package eu.darken.capod.common.bluetooth
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class NudgeAvailability {
@SerialName("nudge.unknown") UNKNOWN,
@SerialName("nudge.available") AVAILABLE,
@SerialName("nudge.broken") BROKEN,
}
enum class NudgeAttemptResult {
Accepted,
Rejected,
UnavailableHiddenApi,
UnavailableMissingPermission,
}
@@ -0,0 +1,59 @@
package eu.darken.capod.common.bluetooth
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NudgeCapabilityStore @Inject constructor(
generalSettings: GeneralSettings,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
) {
private val persistedValue = generalSettings.nudgeAvailability
// Resolve the persisted value synchronously at construction so consumers that read
// `availability.value` (resolver, AutoConnect precondition, UI state) never see the
// synthetic UNKNOWN seed before the first DataStore emission arrives. On a known-broken
// device this prevents one bonus ALWAYS-mode + nudge-attempt cycle per cold start.
val availability: StateFlow<NudgeAvailability> = persistedValue.flow
.stateIn(
scope = appScope + dispatcherProvider.IO,
started = SharingStarted.Eagerly,
initialValue = persistedValue.valueBlocking,
)
fun record(result: NudgeAttemptResult) {
val verdict = verdictFor(result) ?: return
if (availability.value == verdict) return
log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" }
appScope.launch(dispatcherProvider.IO) {
persistedValue.value(verdict)
}
}
companion object {
private val TAG = logTag("Bluetooth", "NudgeCapabilityStore")
internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) {
NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE
NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN
NudgeAttemptResult.Rejected,
NudgeAttemptResult.UnavailableMissingPermission -> null
}
}
}
@@ -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,
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
) {
val keyName: String get() = key.name
val flow: Flow<T> = dataStore.data.map { prefs ->
reader(prefs[key])
}
val flow: Flow<T> = dataStore.data
.map { prefs -> prefs[key] }
.distinctUntilChanged()
.map { raw -> reader(raw) }
data class Updated<T>(val old: T, val new: T)
@@ -5,23 +5,41 @@ 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
import kotlinx.coroutines.flow.MutableStateFlow
object Bugs {
var reporter: AutomaticBugReporter? = null
/**
* True while the debug log recorder is actively recording. Surfaces extra
* diagnostic UI (e.g. raw BLE payload hex on device cards) so users helping
* to add support for new headphones can capture useful data without a
* permanent debug toggle.
*/
val isDebug = MutableStateFlow(false)
fun report(
tag: String,
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")
}
}
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
// Reporting is opt-out for gplay, and opt-in for github builds
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
)
val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false)
val showFakeData = dataStore.createValue("debug.fakedata.enabled", false)
val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false)
}
@@ -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
@@ -11,6 +11,7 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
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.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
}
}
.launchIn(appScope)
internalState.flow
.onEach { Bugs.isDebug.value = it.isRecording }
.launchIn(appScope)
}
private fun createSessionDir(): File {
@@ -36,9 +36,6 @@ object Nav {
@Serializable
data object General : Settings
@Serializable
data object Debug : Settings
@Serializable
data object Support : Settings
@@ -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)
}
}
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.serialization.SerializationCapod
@@ -36,8 +37,6 @@ class GeneralSettings @Inject constructor(
private val dataStore: DataStore<Preferences> get() = context.dataStore
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true)
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
val keepConnectedNotificationAfterDisconnect =
@@ -74,6 +73,12 @@ class GeneralSettings @Inject constructor(
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
/** Runtime-detected verdict for whether `BluetoothHeadset.connect()` reflection works on this device. */
val nudgeAvailability = dataStore.createValue(
"core.bluetooth.nudge.availability", NudgeAvailability.UNKNOWN, json,
onErrorFallbackToDefault = true,
)
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
@@ -1,21 +1,13 @@
package eu.darken.capod.main.core
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class MonitorMode(
@StringRes val labelRes: Int
val intensity: Int,
) {
@SerialName("monitor.mode.manual") MANUAL(
R.string.settings_monitor_mode_manual_label
),
@SerialName("monitor.mode.automatic") AUTOMATIC(
R.string.settings_monitor_mode_automatic_label
),
@SerialName("monitor.mode.always") ALWAYS(
R.string.settings_monitor_mode_always_label
),
}
@SerialName("monitor.mode.manual") MANUAL(intensity = 0),
@SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
@SerialName("monitor.mode.always") ALWAYS(intensity = 2),
}
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isRequired
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.profiles.core.toReactionConfig
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow
@@ -21,7 +22,7 @@ import javax.inject.Singleton
@Singleton
class PermissionTool @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val monitorModeResolver: MonitorModeResolver,
private val profilesRepo: DeviceProfilesRepo,
) {
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
val missingPermissions: Flow<Set<Permission>> = combine(
permissionCheckTrigger,
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
anyPopupEnabled,
) { _, monitorMode, showPopUp ->
Permission.entries
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
.filter { isApplicable(it, monitorMode, showPopUp) }
.filter { it.isRequired(context) }
.toSet()
}
@@ -61,5 +60,24 @@ class PermissionTool @Inject constructor(
companion object {
private val TAG = logTag("PermissionTool")
/**
* Whether [permission] is applicable for the user's current configuration.
* Three permissions are conditional on monitor mode or popup usage:
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed
* - [Permission.ACCESS_BACKGROUND_LOCATION] same
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
* Everything else is unconditionally applicable.
*/
internal fun isApplicable(
permission: Permission,
monitorMode: MonitorMode,
anyPopupEnabled: Boolean,
): Boolean = when (permission) {
Permission.IGNORE_BATTERY_OPTIMIZATION,
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
else -> true
}
}
}
@@ -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
}
@@ -64,6 +64,7 @@ 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.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -148,6 +149,8 @@ fun DeviceSettingsScreenHost(
onNavigateUp = { vm.navUp() },
onAncModeChange = { vm.setAncMode(it) },
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
onConversationActionChange = { vm.setConversationAction(it) },
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
onToneVolumeChange = { vm.setToneVolume(it) },
@@ -167,11 +170,11 @@ fun DeviceSettingsScreenHost(
onOnePodModeChange = { vm.setOnePodMode(it) },
onAutoPlayChange = { vm.setAutoPlay(it) },
onAutoPauseChange = { vm.setAutoPause(it) },
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
onAutoConnectChange = { vm.setAutoConnect(it) },
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
onOpenIssueTracker = { vm.openIssueTracker() },
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
)
@@ -186,6 +189,8 @@ fun DeviceSettingsScreen(
onNavigateUp: () -> Unit,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {},
@@ -205,11 +210,11 @@ fun DeviceSettingsScreen(
onOnePodModeChange: (Boolean) -> Unit = {},
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
onOpenAapTracker: () -> Unit = {},
) {
@@ -339,17 +344,18 @@ fun DeviceSettingsScreen(
device = device,
features = features,
isPro = isPro,
monitorMode = state.monitorMode,
onAutoPlayChange = onAutoPlayChange,
onAutoPauseChange = onAutoPauseChange,
onStartMusicOnWearChange = onStartMusicOnWearChange,
onOnePodModeChange = onOnePodModeChange,
onConversationalAwarenessChange = onConversationalAwarenessChange,
onConversationActionChange = onConversationActionChange,
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
onSleepDetectionChange = onSleepDetectionChange,
onAutoConnectChange = onAutoConnectChange,
onAutoConnectConditionChange = onAutoConnectConditionChange,
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
onFixMonitorMode = onFixMonitorMode,
onOpenIssueTracker = onOpenIssueTracker,
)
}
@@ -386,7 +392,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 +410,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) ||
@@ -5,8 +5,10 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -16,9 +18,9 @@ import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
@@ -29,6 +31,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemAction
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -51,7 +54,8 @@ class DeviceSettingsViewModel @Inject constructor(
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val generalSettings: GeneralSettings,
private val monitorModeResolver: MonitorModeResolver,
private val nudgeCapabilityStore: NudgeCapabilityStore,
private val timeSource: TimeSource,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
@@ -114,8 +118,9 @@ class DeviceSettingsViewModel @Inject constructor(
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
// Seed this branch so the screen can render immediately after navigation.
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
profilesRepo.profiles,
nudgeCapabilityStore.availability,
) { args ->
val device = args[1] as PodDevice?
val upgrade = args[2] as UpgradeRepo.Info
@@ -127,6 +132,7 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST")
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
val nudgeAvailability = args[7] as NudgeAvailability
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.id == profileId }
?.stemActions
@@ -142,7 +148,7 @@ class DeviceSettingsViewModel @Inject constructor(
device = device,
now = timeSource.now(),
isPro = upgrade.isPro,
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
isForceConnecting = forcing,
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
monitorMode = monitorMode,
@@ -206,18 +212,19 @@ class DeviceSettingsViewModel @Inject constructor(
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
if (!bluetoothManager.isNudgeAvailable) {
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
val accepted = try {
val result = try {
bluetoothManager.nudgeConnection(bonded)
} catch (e: Exception) {
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
false
NudgeAttemptResult.Rejected
}
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" }
if (!accepted) {
log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
nudgeCapabilityStore.record(result)
if (result != NudgeAttemptResult.Accepted) {
events.tryEmit(Event.OpenBluetoothSettings)
}
} finally {
@@ -382,6 +389,15 @@ class DeviceSettingsViewModel @Inject constructor(
syncEarDetection(autoPause = enabled)
}
fun setStartMusicOnWear(enabled: Boolean) = launch {
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(startMusicOnWear = enabled) }
}
/**
* Keeps the device-side Automatic Ear Detection setting in sync with the
* auto-play / auto-pause reaction toggles. When either reaction is active
@@ -405,15 +421,6 @@ class DeviceSettingsViewModel @Inject constructor(
fun setAutoConnect(enabled: Boolean) = launch {
log(TAG, INFO) { "setAutoConnect($enabled)" }
updateProfileNow { it.copy(autoConnect = enabled) }
if (enabled) {
// Auto-connect requires ALWAYS mode to react to BLE/case/ear events when
// nothing is connected. We intentionally do NOT revert on disable — the user
// may have set ALWAYS manually, or another profile may still need it.
if (generalSettings.monitorMode.value() != MonitorMode.ALWAYS) {
log(TAG, INFO) { "Forcing monitorMode to ALWAYS because autoConnect was enabled" }
generalSettings.monitorMode.value(MonitorMode.ALWAYS)
}
}
}
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
@@ -431,11 +438,23 @@ class DeviceSettingsViewModel @Inject constructor(
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
}
fun setMonitorModeAutomatic() = launch {
log(TAG, INFO) { "setMonitorModeAutomatic()" }
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
fun setConversationAction(action: ConversationAction) = launch {
log(TAG, INFO) { "setConversationAction($action)" }
// The action picker is only shown while Conversation Awareness is already enabled (the pod
// emits no speaking frames otherwise), so no need to auto-enable it here.
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
updateProfileNow { it.copy(conversationAction = action) }
}
fun setConversationVolumeReduction(percent: Int) = launch {
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
}
fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" }
val profileId = targetProfileId.value ?: return@launch
@@ -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) {
@@ -4,12 +4,14 @@ import android.os.Build
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.RecordVoiceOver
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -29,39 +31,45 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
internal fun ReactionsCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
monitorMode: MonitorMode,
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onStartMusicOnWearChange: (Boolean) -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onConversationActionChange: (ConversationAction) -> Unit = {},
onConversationVolumeReductionChange: (Int) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val reactions = device.reactions
val enabled = device.isAapReady
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showConversationActionDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
@@ -73,6 +81,16 @@ internal fun ReactionsCard(
onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro,
)
if (reactions.autoPlay) {
SettingsSwitchItem(
icon = Icons.TwoTone.PlayCircle,
title = stringResource(R.string.settings_start_music_on_wear_label),
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
checked = reactions.startMusicOnWear,
onCheckedChange = onStartMusicOnWearChange,
requiresUpgrade = !isPro,
)
}
SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label),
@@ -117,20 +135,45 @@ internal fun ReactionsCard(
}
if (device.isAapConnected) {
val convAwareness = device.conversationalAwareness
val hasAnyAapReaction =
(features.hasConversationAwareness && convAwareness != null) ||
features.hasSleepDetection
if (features.hasConversationAwareness && convAwareness != null) {
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
if (showConversationAwareness) {
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
checked = convAwareness?.enabled == true,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
// speaking frames otherwise), so only surface it once CA is actually enabled.
if (convAwareness?.enabled == true) {
SettingsBaseItem(
icon = Icons.TwoTone.RecordVoiceOver,
title = stringResource(R.string.settings_conversation_action_label),
subtitle = stringResource(reactions.conversationAction.labelRes),
onClick = { showConversationActionDialog = true },
enabled = enabled,
requiresUpgrade = !isPro,
)
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
SettingsSliderItem(
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
title = stringResource(R.string.settings_conversation_volume_reduction_label),
value = reactions.conversationVolumeReduction.toFloat(),
onValueChange = { onConversationVolumeReductionChange(it.toInt()) },
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
enabled = enabled,
valueLabel = { "${it.toInt()}%" },
)
}
}
}
if (features.hasSleepDetection) {
if (showConversationAwareness) ReactionsDivider()
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
@@ -198,23 +241,24 @@ internal fun ReactionsCard(
requiresUpgrade = !isPro,
)
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) {
SettingsInfoBox(
text = stringResource(R.string.settings_popup_warning_manual_mode),
type = InfoBoxType.WARNING,
action = {
TextButton(onClick = onFixMonitorMode) {
Text(stringResource(R.string.general_fix_it_action))
}
},
)
} else if (anyPopupEnabled) {
if (anyPopupEnabled) {
SettingsInfoBox(
text = stringResource(R.string.settings_popup_info_not_in_app),
)
}
}
if (showConversationActionDialog) {
ConversationActionDialog(
current = reactions.conversationAction,
onSelect = {
onConversationActionChange(it)
showConversationActionDialog = false
},
onDismiss = { showConversationActionDialog = false },
)
}
if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
@@ -246,7 +290,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@@ -259,6 +302,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@@ -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)
@@ -0,0 +1,75 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
internal fun ConversationActionDialog(
current: ConversationAction,
onSelect: (ConversationAction) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.settings_conversation_action_label)) },
text = {
Column(Modifier.selectableGroup()) {
ConversationAction.entries.forEach { action ->
val isSelected = action == current
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onSelect(action) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = stringResource(action.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
@Preview2
@Composable
private fun ConversationActionDialogPreview() = PreviewWrapper {
ConversationActionDialog(
current = ConversationAction.LOWER_VOLUME,
onSelect = {},
onDismiss = {},
)
}
@@ -327,7 +327,7 @@ fun OverviewScreen(
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode,
showDebug = state.isDebug,
now = state.now,
isCollapsed = isCollapsed,
onToggleCollapse = if (isToggleable) {
@@ -376,7 +376,7 @@ fun OverviewScreen(
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode,
showDebug = state.isDebug,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onUpgrade = onUpgrade,
@@ -438,7 +438,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
@@ -465,7 +465,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(),
@@ -488,7 +488,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = MockPodDataProvider.gplayInfo(),
@@ -511,7 +511,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebugMode = false,
isDebug = false,
isBluetoothEnabled = false,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
@@ -5,8 +5,7 @@ import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -22,7 +21,9 @@ import eu.darken.capod.main.core.GeneralSettings
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.MonitorModeResolver
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
@@ -33,12 +34,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant
import javax.inject.Inject
@@ -49,11 +51,11 @@ class OverviewViewModel @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager,
private val monitorModeResolver: MonitorModeResolver,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
@@ -76,29 +78,30 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
val workerAutolaunch = permissionTool.missingScanPermissions
.onEach { permissions ->
if (permissions.isNotEmpty()) {
log(TAG) { "Missing scan permissions: $permissions" }
val workerAutolaunch = combine(
permissionTool.missingScanPermissions,
monitorModeResolver.effectiveMode,
// BluetoothManager2.connectedDevices is gated by a slow HEADSET-profile lookup. Without
// an onStart seed the combine wouldn't fire until that profile is bound, so ALWAYS mode
// would silently delay autolaunch until the first connected-device emission.
bluetoothManager.connectedDevices
.onStart { emit(emptyList()) }
.map { it.isNotEmpty() }
.distinctUntilChanged(),
) { missing, mode, anyConnected -> Triple(missing, mode, anyConnected) }
.distinctUntilChanged()
.onEach { (missing, mode, anyConnected) ->
if (missing.isNotEmpty()) {
log(TAG) { "Missing scan permissions: $missing" }
return@onEach
}
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
val shouldStart = when (mode) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> {
try {
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
devices?.isNotEmpty() == true
} catch (e: SecurityException) {
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
false
}
}
MonitorMode.AUTOMATIC -> anyConnected
MonitorMode.ALWAYS -> true
}
if (shouldStart) {
log(TAG) { "Starting monitor" }
log(TAG) { "Starting monitor (mode=$mode)" }
monitorControl.startMonitor()
}
}
@@ -125,7 +128,7 @@ class OverviewViewModel @Inject constructor(
updateTicker,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
Bugs.isDebug,
bluetoothManager.isBluetoothEnabled,
profilesRepo.profiles,
upgradeRepo.upgradeInfo,
@@ -133,7 +136,7 @@ class OverviewViewModel @Inject constructor(
userExpansionOverrides,
generalSettings.reactionsHintDismissed.flow,
profilesRepo.hadLegacyReactionData,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
// Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -142,7 +145,7 @@ class OverviewViewModel @Inject constructor(
now = timeSource.now(),
permissions = permissions,
devices = devices,
isDebugMode = isDebugMode,
isDebug = isDebug,
isBluetoothEnabled = isBluetoothEnabled,
profiles = profiles,
upgradeInfo = upgradeInfo,
@@ -158,7 +161,7 @@ class OverviewViewModel @Inject constructor(
val now: Instant,
val permissions: Set<Permission>,
val devices: List<PodDevice>,
val isDebugMode: Boolean,
val isDebug: Boolean,
val isBluetoothEnabled: Boolean,
val profiles: List<DeviceProfile>,
val upgradeInfo: UpgradeRepo.Info,
@@ -175,7 +178,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 +287,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),
@@ -1,44 +1,31 @@
package eu.darken.capod.main.ui.settings.general
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.AccountTree
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.Contrast
import androidx.compose.material.icons.twotone.DarkMode
import androidx.compose.material.icons.twotone.DisabledVisible
import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
@@ -55,7 +42,6 @@ import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeState
import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.main.core.MonitorMode
@Composable
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
@@ -67,10 +53,8 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
GeneralSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
onDebugSettings = { vm.goToDebugSettings() },
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
@@ -86,10 +70,8 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
fun GeneralSettingsScreen(
state: GeneralSettingsViewModel.State,
onNavigateUp: () -> Unit,
onMonitorModeSelected: (MonitorMode) -> Unit,
onShowConnectedNotificationChanged: (Boolean) -> Unit,
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
onDebugSettings: () -> Unit,
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
@@ -99,7 +81,6 @@ fun GeneralSettingsScreen(
onUpgrade: () -> Unit = {},
modifier: Modifier = Modifier,
) {
var showMonitorModeDialog by remember { mutableStateOf(false) }
var showColorDialog by remember { mutableStateOf(false) }
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
@@ -122,17 +103,6 @@ fun GeneralSettingsScreen(
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_monitoring_label))
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_monitor_mode_label),
subtitle = stringResource(state.monitorMode.labelRes),
icon = Icons.TwoTone.DisabledVisible,
onClick = { showMonitorModeDialog = true },
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
}
@@ -227,14 +197,6 @@ fun GeneralSettingsScreen(
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_label),
subtitle = stringResource(R.string.settings_debug_description),
icon = Icons.TwoTone.BugReport,
onClick = onDebugSettings,
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
}
@@ -286,20 +248,6 @@ fun GeneralSettingsScreen(
}
}
if (showMonitorModeDialog) {
ListPreferenceDialog(
title = stringResource(R.string.settings_monitor_mode_label),
entries = MonitorMode.entries,
selectedEntry = state.monitorMode,
onEntrySelected = {
onMonitorModeSelected(it)
showMonitorModeDialog = false
},
entryLabel = { stringResource(it.labelRes) },
onDismiss = { showMonitorModeDialog = false },
)
}
if (showColorDialog) {
ThemeColorSelectorDialog(
selectedColor = state.themeState.color,
@@ -314,7 +262,6 @@ fun GeneralSettingsScreen(
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
isPro = isPro,
monitorMode = MonitorMode.AUTOMATIC,
showConnectedNotification = true,
keepNotificationAfterDisconnect = false,
isOffloadedFilteringDisabled = false,
@@ -329,10 +276,8 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
GeneralSettingsScreen(
state = previewGeneralState(isPro = true),
onNavigateUp = {},
onMonitorModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
@@ -345,60 +290,10 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
GeneralSettingsScreen(
state = previewGeneralState(isPro = false),
onNavigateUp = {},
onMonitorModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
)
}
@Composable
private fun <T> ListPreferenceDialog(
title: String,
entries: List<T>,
selectedEntry: T,
onEntrySelected: (T) -> Unit,
entryLabel: @Composable (T) -> String,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title) },
text = {
Column(Modifier.selectableGroup()) {
entries.forEach { entry ->
val isSelected = entry == selectedEntry
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onEntrySelected(entry) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = isSelected,
onClick = null,
)
Text(
text = entryLabel(entry),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
@@ -13,7 +13,6 @@ import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.themeState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -30,7 +29,6 @@ class GeneralSettingsViewModel @Inject constructor(
data class State(
val isPro: Boolean,
val monitorMode: MonitorMode,
val showConnectedNotification: Boolean,
val keepNotificationAfterDisconnect: Boolean,
val isOffloadedFilteringDisabled: Boolean,
@@ -43,12 +41,11 @@ class GeneralSettingsViewModel @Inject constructor(
val state = combine(
combine(
generalSettings.monitorMode.flow,
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { monitorMode, showNotif, keepNotif ->
) { showNotif, keepNotif ->
@Suppress("USELESS_CAST")
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
arrayOf<Any>(showNotif as Any, keepNotif as Any)
},
combine(
generalSettings.isOffloadedFilteringDisabled.flow,
@@ -63,9 +60,8 @@ class GeneralSettingsViewModel @Inject constructor(
) { general, compat, themeState, isPro ->
State(
isPro = isPro,
monitorMode = general[0] as MonitorMode,
showConnectedNotification = general[1] as Boolean,
keepNotificationAfterDisconnect = general[2] as Boolean,
showConnectedNotification = general[0] as Boolean,
keepNotificationAfterDisconnect = general[1] as Boolean,
isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
@@ -73,11 +69,6 @@ class GeneralSettingsViewModel @Inject constructor(
)
}.asLiveState()
fun setMonitorMode(mode: MonitorMode) {
log(TAG, INFO) { "setMonitorMode($mode)" }
generalSettings.monitorMode.valueBlocking = mode
}
fun setShowConnectedNotification(enabled: Boolean) {
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
@@ -135,11 +126,6 @@ class GeneralSettingsViewModel @Inject constructor(
navTo(Nav.Main.Upgrade)
}
fun goToDebugSettings() {
log(TAG, INFO) { "goToDebugSettings()" }
navTo(Nav.Settings.Debug)
}
companion object {
private val TAG = logTag("Settings", "General", "VM")
}
@@ -1,26 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
}
}
@@ -1,136 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.BugReport
import androidx.compose.material.icons.twotone.DataArray
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsBaseItem
@Composable
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
state?.let {
DebugSettingsScreen(
state = it,
onNavigateUp = { vm.navUp() },
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
)
}
}
@Composable
fun DebugSettingsScreen(
state: DebugSettingsViewModel.State,
onNavigateUp: () -> Unit,
onDebugModeChanged: (Boolean) -> Unit,
onShowFakeDataChanged: (Boolean) -> Unit,
onShowUnfilteredChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.settings_debug_label)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item {
SettingsBaseItem(
title = stringResource(R.string.settings_debug_mode_label),
subtitle = stringResource(R.string.settings_debug_mode_description),
icon = Icons.TwoTone.BugReport,
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
trailingContent = {
Switch(
checked = state.isDebugModeEnabled,
onCheckedChange = onDebugModeChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_fake_data_label),
subtitle = stringResource(R.string.settings_fake_data_description),
icon = Icons.TwoTone.DataArray,
onClick = { onShowFakeDataChanged(!state.showFakeData) },
trailingContent = {
Switch(
checked = state.showFakeData,
onCheckedChange = onShowFakeDataChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_blescanner_unfiltered_label),
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
icon = Icons.TwoTone.DevicesOther,
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
trailingContent = {
Switch(
checked = state.showUnfiltered,
onCheckedChange = onShowUnfilteredChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
}
}
}
@Preview2
@Composable
private fun DebugSettingsScreenPreview() = PreviewWrapper {
DebugSettingsScreen(
state = DebugSettingsViewModel.State(
isDebugModeEnabled = true,
showFakeData = false,
showUnfiltered = false,
),
onNavigateUp = {},
onDebugModeChanged = {},
onShowFakeDataChanged = {},
onShowUnfilteredChanged = {},
)
}
@@ -1,57 +0,0 @@
package eu.darken.capod.main.ui.settings.general.debug
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@HiltViewModel
class DebugSettingsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val debugSettings: DebugSettings,
) : ViewModel4(dispatcherProvider) {
data class State(
val isDebugModeEnabled: Boolean,
val showFakeData: Boolean,
val showUnfiltered: Boolean,
)
val state = combine(
debugSettings.isDebugModeEnabled.flow,
debugSettings.showFakeData.flow,
debugSettings.showUnfiltered.flow,
) { debugMode, fakeData, unfiltered ->
State(
isDebugModeEnabled = debugMode,
showFakeData = fakeData,
showUnfiltered = unfiltered,
)
}.asLiveState()
fun setDebugModeEnabled(enabled: Boolean) {
log(TAG, INFO) { "setDebugModeEnabled($enabled)" }
debugSettings.isDebugModeEnabled.valueBlocking = enabled
}
fun setShowFakeData(enabled: Boolean) {
log(TAG, INFO) { "setShowFakeData($enabled)" }
debugSettings.showFakeData.valueBlocking = enabled
}
fun setShowUnfiltered(enabled: Boolean) {
log(TAG, INFO) { "setShowUnfiltered($enabled)" }
debugSettings.showUnfiltered.valueBlocking = enabled
}
companion object {
private val TAG = logTag("Settings", "Debug", "VM")
}
}
@@ -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) }
}
}
}
}
@@ -0,0 +1,45 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.toReactionConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MonitorModeResolver @Inject constructor(
private val profilesRepo: DeviceProfilesRepo,
private val nudgeCapabilityStore: NudgeCapabilityStore,
) {
val effectiveMode: Flow<MonitorMode> = combine(
profilesRepo.profiles,
nudgeCapabilityStore.availability,
) { profiles, nudge ->
val primary = profiles.firstOrNull() ?: return@combine MonitorMode.MANUAL
primary.requiredMode(nudge)
}
.distinctUntilChanged()
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
private fun DeviceProfile.requiredMode(nudge: NudgeAvailability): MonitorMode = when {
// Match AutoConnect.kt's isNullOrEmpty check — a blank/legacy "" address is no address.
address.isNullOrBlank() -> MonitorMode.MANUAL
toReactionConfig().autoConnect && nudge != NudgeAvailability.BROKEN -> MonitorMode.ALWAYS
else -> MonitorMode.AUTOMATIC
}
companion object {
private val TAG = logTag("Monitor", "ModeResolver")
}
}
@@ -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
}
}
@@ -66,7 +66,7 @@ class StemPressReaction @Inject constructor(
is StemAction.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
is StemAction.VolumeUp -> mediaControl.adjustVolumeUp()
is StemAction.VolumeDown -> mediaControl.adjustVolumeDown()
is StemAction.Stop -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
is StemAction.Stop -> mediaControl.sendStop()
is StemAction.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
is StemAction.MuteToggle -> mediaControl.toggleMuteMusic()
@@ -8,7 +8,6 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
@@ -27,7 +26,10 @@ 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.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@@ -50,14 +52,24 @@ class BlePodMonitor @Inject constructor(
private val timeSource: TimeSource,
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
permissionTool: PermissionTool,
private val permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
private val cacheLock = Mutex()
/**
* Ephemeral override that disables the proximity-pairing scan filter so
* the troubleshooter can collect raw BLE broadcasts. Resets to false on
* every process start; the troubleshooter is the only writer.
*/
private val unfilteredOverride = MutableStateFlow(false)
fun setUnfilteredOverride(enabled: Boolean) {
unfilteredOverride.value = enabled
}
val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
@@ -89,8 +101,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)
@@ -130,7 +144,7 @@ class BlePodMonitor @Inject constructor(
private fun createBleScanner() = combine(
bleScanModeController.scannerMode,
debugSettings.showUnfiltered.flow,
unfilteredOverride,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
@@ -160,13 +174,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)
}
@@ -15,7 +15,6 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -30,6 +29,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.MonitorCoroutineScope
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.primaryDevice
@@ -42,6 +42,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.core.conversation.ConversationReaction
import eu.darken.capod.reaction.core.sleep.SleepReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
@@ -78,9 +79,11 @@ class MonitorService : Service() {
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var sleepReaction: SleepReaction
@Inject lateinit var conversationReaction: ConversationReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager
@Inject lateinit var monitorModeResolver: MonitorModeResolver
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@@ -88,6 +91,10 @@ class MonitorService : Service() {
private var foregroundStartFailed = false
private var injectionComplete = false
@Volatile
private var latestNotificationSettings: NotificationSettings =
NotificationSettings(useExtraNotification = false, keepAfterDisconnect = false)
@SuppressLint("InlinedApi")
private fun promoteToForeground(notification: Notification): Boolean {
return try {
@@ -188,27 +195,53 @@ class MonitorService : Service() {
}
private suspend fun doMonitor() {
// Seed snapshot eagerly so onDestroy() has correct values even if we abort below.
latestNotificationSettings = NotificationSettings(
useExtraNotification = generalSettings.useExtraMonitorNotification.flow.first(),
keepAfterDisconnect = generalSettings.keepConnectedNotificationAfterDisconnect.flow.first(),
)
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
return
}
val monitorJob = deviceMonitor.primaryDevice()
val deviceFlow = deviceMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChangedBy { it?.toNotificationKey() }
.throttleLatest(1000)
.onEach { currentDevice ->
val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking
val notificationSettingsFlow = combine(
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { useExtra, keepAfter ->
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
}
val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings ->
currentDevice to settings
}
.onEach { (currentDevice, settings) ->
latestNotificationSettings = settings
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
notifications.getNotification(currentDevice, showHint = useExtraNotification),
notifications.getNotification(
currentDevice,
showHint = settings.useExtraNotification,
),
)
if (generalSettings.useExtraMonitorNotification.valueBlocking && currentDevice != null) {
notificationManager.notify(
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
is ExtraNotificationAction.Post -> notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
notifications.getNotificationConnected(currentDevice),
notifications.getNotificationConnected(action.device),
)
ExtraNotificationAction.Cancel -> notificationManager.cancel(
MonitorNotifications.NOTIFICATION_ID_CONNECTED
)
ExtraNotificationAction.KeepExisting -> Unit
}
}
.catch {
@@ -224,7 +257,7 @@ class MonitorService : Service() {
emptyFlow()
} else {
combine(
generalSettings.monitorMode.flow,
monitorModeResolver.effectiveMode,
profilesRepo.profiles,
bluetoothManager.connectedDevices,
aapConnectionManager.allStates,
@@ -300,6 +333,11 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
conversationReaction.monitor()
.setupCommonEventHandlers(TAG) { "conversationReaction" }
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -310,7 +348,8 @@ class MonitorService : Service() {
monitorScope.cancel("Service destroyed")
if (injectionComplete) {
if (generalSettings.useExtraMonitorNotification.valueBlocking && !generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking) {
val snapshot = latestNotificationSettings
if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) {
try {
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
} catch (e: Exception) {
@@ -363,6 +402,7 @@ private data class NotificationDeviceKey(
val profileId: String?,
val label: String?,
val model: PodModel,
val isLive: Boolean,
val hasDualPods: Boolean,
val hasCase: Boolean,
val hasEarDetection: Boolean,
@@ -387,6 +427,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
profileId = profileId,
label = label,
model = model,
isLive = isLive,
hasDualPods = hasDualPods,
hasCase = hasCase,
hasEarDetection = hasEarDetection,
@@ -406,3 +447,24 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
rightPodIcon = rightPodIcon,
caseIcon = caseIcon,
)
internal data class NotificationSettings(
val useExtraNotification: Boolean,
val keepAfterDisconnect: Boolean,
)
internal sealed interface ExtraNotificationAction {
object Cancel : ExtraNotificationAction
data class Post(val device: PodDevice) : ExtraNotificationAction
object KeepExisting : ExtraNotificationAction
}
internal fun decideExtraNotificationAction(
device: PodDevice?,
settings: NotificationSettings,
): ExtraNotificationAction = when {
!settings.useExtraNotification -> ExtraNotificationAction.Cancel
device != null && device.isLive -> ExtraNotificationAction.Post(device)
settings.keepAfterDisconnect -> ExtraNotificationAction.KeepExisting
else -> ExtraNotificationAction.Cancel
}
@@ -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,
@@ -11,6 +11,7 @@ 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.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -73,6 +74,17 @@ class AapConnectionManager @Inject constructor(
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
/**
* Emits when a connected device reports a Conversational Awareness speaking transition
* (START/STOP, AAP command 0x4B). Paired with the origin address so the conversation
* reaction can gate on the primary device. Only the known 0x01/0x04 markers reach here —
* the engine drops unknown raw values (see [AapSessionEngine]).
*/
private val _conversationalAwarenessEvents =
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
val conversationalAwarenessEvents: SharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>> =
_conversationalAwarenessEvents.asSharedFlow()
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
@@ -140,6 +152,13 @@ class AapConnectionManager @Inject constructor(
}
}
// Forward conversational-awareness speaking transitions from this connection (child coroutine).
launch {
connection.conversationalAwarenessEvents.collect { event ->
_conversationalAwarenessEvents.tryEmit(address to event)
}
}
// Forward OFF-rejection events from this connection (child coroutine)
launch {
connection.offRejected.collect {
@@ -14,8 +14,10 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
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.ConversationAwarenessEvent
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 +28,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 +45,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)
@@ -47,6 +54,7 @@ internal class AapConnection(
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> get() = engine.conversationalAwarenessEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
@@ -68,7 +76,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 +220,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")
}
}
}
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
@@ -54,6 +55,11 @@ internal class AapSessionEngine(
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
private val _conversationalAwarenessEvents =
MutableSharedFlow<ConversationAwarenessEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> =
_conversationalAwarenessEvents.asSharedFlow()
private val _offRejected =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
@@ -301,6 +307,13 @@ internal class AapSessionEngine(
)
}
}
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
// conversation reaction: START / STOP / HOLD (keep-alive). The decoder already dropped
// malformed frames (rawValue stays null only in that case). The raw payload remains logged.
if (value is AapSetting.ConversationalAwarenessState) {
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
}
}
private fun handleTimerFired(key: EngineTimerKey) {
@@ -99,9 +99,17 @@ sealed class AapSetting {
val level: Int,
) : AapSetting()
/** Push-only from device — reports speaking detection state (command 0x4B). */
/**
* Push-only from device — reports speaking detection state (command 0x4B).
*
* [rawValue] is the first payload byte, preserved so consumers can distinguish the known
* speaking-start (0x01) and speaking-stop (0x04) markers from other values (e.g. 0x00, seen
* in captures with unclear meaning). [speaking] collapses everything non-0x01 to false for
* storage/UI; reaction logic must gate on [rawValue] to avoid acting on unknown values.
*/
data class ConversationalAwarenessState(
val speaking: Boolean,
val rawValue: Int? = null,
) : AapSetting()
data class MicrophoneMode(
@@ -0,0 +1,31 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
*
* Status-byte mapping (confirmed against a live AirPods Pro 3 capture and the librepods project):
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
* - `6`, `8`, `9` → [STOP] (wearer stopped → disengage)
* - any other value (`3`, `4`, `0x0B`, …) → [HOLD] (intermediate "still in session" frame; the pod
* streams these while speaking — they act as a keep-alive and must NOT disengage the reaction)
*
* The pod emits no `0x4B` frames at all during silence, so [HOLD] frames ceasing is itself a
* reliable "speaking ended" signal (used as a stale-timeout fallback for a missed [STOP]).
*/
enum class ConversationAwarenessEvent {
START,
HOLD,
STOP,
;
companion object {
val SPEAKING_STATUSES = setOf(1, 2)
val STOPPED_STATUSES = setOf(6, 8, 9)
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
in SPEAKING_STATUSES -> START
in STOPPED_STATUSES -> STOP
else -> HOLD
}
}
}
@@ -183,12 +183,24 @@ class DefaultAapDeviceProfile(
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
}
// Conversation Awareness State is a separate command type (push-only)
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
// are seen: a legacy single byte, and the 4-byte `02 00 01 <status>` form on Pro 3 fw 81.x.
// The status (speaking marker) is the last byte in both — but we validate the shape rather
// than blindly taking payload.last(), so a truncated/garbled frame can't be misread as a
// speaking transition. Unrecognised shapes return null → logged as an unhandled message.
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
if (message.payload.isEmpty()) return null
val value = message.payload[0].toInt() and 0xFF
val speaking = value == 0x01
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
val p = message.payload
val status = when {
p.size == 1 -> p[0].toInt() and 0xFF
p.size == 4 &&
p[0] == 0x02.toByte() && p[1] == 0x00.toByte() && p[2] == 0x01.toByte() ->
p[3].toInt() and 0xFF
else -> return null
}
// speaking = the "started/active speaking" statuses (1,2); see ConversationAwarenessEvent.
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES
return AapSetting.ConversationalAwarenessState::class to
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
}
if (message.commandType != AapMessageType.CONTROL.value) return null
@@ -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,
@@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
@@ -25,11 +26,14 @@ data class AppleDeviceProfile(
@SerialName("address") override val address: String? = null,
@SerialName("reactionAutoPause") val autoPause: Boolean = false,
@SerialName("reactionAutoPlay") val autoPlay: Boolean = false,
@SerialName("reactionStartMusicOnWear") val startMusicOnWear: Boolean = false,
@SerialName("reactionOnePodMode") val onePodMode: Boolean = false,
@SerialName("reactionAutoConnect") val autoConnect: Boolean = false,
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
/**
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
@@ -49,11 +53,14 @@ data class AppleDeviceProfile(
get() = ReactionConfig(
autoPause = autoPause,
autoPlay = autoPlay,
startMusicOnWear = startMusicOnWear,
onePodMode = onePodMode,
autoConnect = autoConnect,
autoConnectCondition = autoConnectCondition,
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
showPopUpOnConnection = showPopUpOnConnection,
conversationAction = conversationAction,
conversationVolumeReduction = conversationVolumeReduction,
)
override fun toString(): String = "AppleDeviceProfile(" +
@@ -62,10 +69,13 @@ data class AppleDeviceProfile(
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
"startMusicOnWear=$startMusicOnWear, " +
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
"autoConnectCondition=$autoConnectCondition, " +
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
"showPopUpOnConnection=$showPopUpOnConnection, " +
"conversationAction=$conversationAction, " +
"conversationVolumeReduction=$conversationVolumeReduction, " +
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
"stemActions=$stemActions" +
@@ -1,16 +1,29 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.conversation.ConversationAction
data class ReactionConfig(
val autoPause: Boolean = false,
val autoPlay: Boolean = false,
val startMusicOnWear: Boolean = false,
val onePodMode: Boolean = false,
val autoConnect: Boolean = false,
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
val showPopUpOnCaseOpen: Boolean = false,
val showPopUpOnConnection: Boolean = false,
)
val conversationAction: ConversationAction = ConversationAction.NOTHING,
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
) {
companion object {
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
/** 100% lowers media volume all the way to 0 — i.e. full mute while speaking. */
const val MAX_CONVERSATION_VOLUME_REDUCTION = 100
}
}
interface HasReactionConfig {
val reactionConfig: ReactionConfig
@@ -1,6 +1,8 @@
package eu.darken.capod.reaction.core.autoconnect
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.NudgeAvailability
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
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
@@ -26,6 +28,7 @@ import javax.inject.Singleton
class AutoConnect @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val deviceMonitor: DeviceMonitor,
private val nudgeCapabilityStore: NudgeCapabilityStore,
) {
fun monitor(): Flow<Unit> = deviceMonitor.primaryDevice()
@@ -98,13 +101,14 @@ class AutoConnect @Inject constructor(
return@map
}
if (!bluetoothManager.isNudgeAvailable) {
log(TAG, WARN) { "nudgeConnection is not available on this device, skipping" }
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
log(TAG, WARN) { "nudgeConnection is known broken on this device, skipping" }
return@map
}
val result = bluetoothManager.nudgeConnection(bondedDevice)
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
nudgeCapabilityStore.record(result)
}
.setupCommonEventHandlers(TAG) { "monitor" }
@@ -0,0 +1,28 @@
package eu.darken.capod.reaction.core.conversation
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* What CAPod does when the pods report that the wearer started speaking (Conversational
* Awareness). On Android the firmware does not duck audio on its own, so CAPod performs the
* reaction itself. The action is reverted when speaking stops.
*
* Extend by adding entries — the persisted [SerialName] strings are stable identifiers.
*/
@Serializable
enum class ConversationAction(
val identifier: String,
@StringRes val labelRes: Int,
) {
@SerialName("conversation.action.nothing")
NOTHING("conversation.action.nothing", R.string.settings_conversation_action_nothing_label),
@SerialName("conversation.action.lower_volume")
LOWER_VOLUME("conversation.action.lower_volume", R.string.settings_conversation_action_lower_volume_label),
@SerialName("conversation.action.pause")
PAUSE("conversation.action.pause", R.string.settings_conversation_action_pause_label),
}
@@ -0,0 +1,281 @@
package eu.darken.capod.reaction.core.conversation
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
import eu.darken.capod.profiles.core.ReactionConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
*
* The pod streams classified frames while you talk ([ConversationAwarenessEvent.START] at onset,
* then [ConversationAwarenessEvent.HOLD] keep-alives) and an explicit [ConversationAwarenessEvent.STOP]
* when you finish; no frames at all during silence. Disengage happens on STOP, or — if a STOP is
* dropped — via a stale timeout that fires once frames stop arriving. Each frame (START or HOLD)
* resets that timer, so a long conversation stays engaged.
*
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
* Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped
* session never strands the user at low volume.
*/
@Singleton
class ConversationReaction @Inject constructor(
private val aapManager: AapConnectionManager,
private val deviceMonitor: DeviceMonitor,
private val mediaControl: MediaControl,
@AppScope private val appScope: CoroutineScope,
private val timeSource: TimeSource,
) {
private sealed interface Kind {
data object Paused : Kind
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
}
private data class Active(
val id: Long,
val owner: BluetoothAddress,
val kind: Kind,
val at: Long,
)
private val mutex = Mutex()
private var active: Active? = null
private var staleJob: Job? = null
private var idCounter = 0L
fun monitor(): Flow<Unit> = merge(
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
// Reverts a stranded duck when the owning device leaves the AAP state map for any reason
// (intentional or not) — disconnectEvents only fires for unintentional drops.
aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) },
)
.map { }
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
.onCompletion { withContext(NonCancellable) { onMonitorCompleted() } }
.setupCommonEventHandlers(TAG) { "conversationReaction" }
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
ConversationAwarenessEvent.START -> onSpeakingStart(address)
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
}
private suspend fun onSpeakingStart(address: BluetoothAddress) {
val primary = deviceMonitor.primaryDevice().first()
if (primary?.address != address) {
log(TAG) { "START from $address ignored — not primary device (primary=${primary?.address})" }
return
}
val action = primary.reactions.conversationAction
if (action == ConversationAction.NOTHING) return
mutex.withLock {
val current = active
if (current != null && current.owner == address) {
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
restartStaleTimer(current)
log(TAG) { "START from $address — already active ($action), keep-alive" }
return
}
// A different device started speaking while we were active — undo the old one first.
if (current != null) revert(current, "superseded by $address")
when (action) {
ConversationAction.PAUSE -> {
val paused = mediaControl.sendPause(rememberForResume = false)
if (paused) {
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
active = record
restartStaleTimer(record)
log(TAG, INFO) { "START on $address → paused media" }
} else {
active = null
log(TAG) { "START on $address → nothing playing, no pause" }
}
}
ConversationAction.LOWER_VOLUME -> {
val reduction = primary.reactions.conversationVolumeReduction
.coerceIn(
ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION,
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION,
)
val duck = mediaControl.duckMusicVolume(reduction)
if (duck != null) {
val record = Active(
nextId(),
address,
Kind.Ducked(duck.priorVolume, duck.appliedVolume),
timeSource.elapsedRealtime(),
)
active = record
restartStaleTimer(record)
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}${duck.appliedVolume}" }
} else {
active = null
log(TAG) { "START on $address → duck no-op" }
}
}
ConversationAction.NOTHING -> Unit
}
}
}
/** Intermediate keep-alive frame: the wearer is still speaking — refresh the stale timer. */
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
val current = active ?: return
if (current.owner != address) return
restartStaleTimer(current)
}
private suspend fun onSpeakingStop(address: BluetoothAddress) {
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
val current = active ?: return
if (current.owner != address) {
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
return
}
clearActive()
disengage(current, primary, "STOP on $address")
}
}
/** Graceful disengage (STOP event or stale timeout). Must be called under [mutex]. */
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String) {
when (val kind = record.kind) {
is Kind.Paused -> {
// Resume the pause WE caused, regardless of the current action setting. Gating on
// "action still == PAUSE" would strand media paused if the user switched the action
// (or set NOTHING) mid-conversation — undoing our own side effect is the least
// surprising behaviour. The remaining guards are about real device/playback state.
val age = timeSource.elapsedRealtime() - record.at
when {
age > PAUSE_RESUME_WINDOW_MS ->
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
primary?.address != record.owner ->
log(TAG) { "$reason — resume skipped (primary switched)" }
primary.isBeingWorn == false ->
log(TAG) { "$reason — resume skipped (not worn)" }
mediaControl.isPlaying ->
log(TAG) { "$reason — resume skipped (already playing)" }
else -> {
mediaControl.sendPlay()
log(TAG, INFO) { "$reason → resumed media" }
}
}
}
is Kind.Ducked -> revertDuck(kind, reason)
}
}
private suspend fun onActiveDevicesChanged(addresses: Set<BluetoothAddress>) = mutex.withLock {
val current = active ?: return
if (current.owner !in addresses) {
clearActive()
revert(current, "owner ${current.owner} gone")
}
}
private suspend fun onMonitorCompleted() = mutex.withLock {
val current = active ?: return
clearActive()
revert(current, "monitor completed")
}
/** Forced revert (disconnect / supersede / shutdown): undo volume ducks; leave pauses as-is. */
private fun revert(record: Active, reason: String) {
when (val kind = record.kind) {
is Kind.Ducked -> revertDuck(kind, reason)
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
}
}
private fun revertDuck(kind: Kind.Ducked, reason: String) {
// Restore to the pre-duck volume unconditionally — we do NOT gate on
// currentMusicVolume() == appliedVolume. Such a guard skips the restore whenever anything
// else moved the volume meanwhile (e.g. a concurrent volume-manager app), which lets the
// baseline ratchet down across conversations. Restoring deterministically to the level we
// saved at engage avoids that; the trade-off is overriding a deliberate mid-conversation
// volume change (rare). Matches librepods' unconditional restore.
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (ducked to ${kind.appliedVolume}, now ${mediaControl.currentMusicVolume()}, $reason)" }
mediaControl.restoreMusicVolume(kind.priorVolume)
}
/** Must be called under [mutex]. Clears the active slot and cancels its stale timer. */
private fun clearActive() {
staleJob?.cancel()
staleJob = null
active = null
}
/**
* Must be called under [mutex]. (Re)starts the stale timer for [record]. Reset on every frame
* (START/HOLD); if no frame arrives for [STALE_TIMEOUT_MS] the speaking session is treated as
* ended (recovers from a dropped STOP). Identity-checked so a late timer can't disengage a newer
* session.
*/
private fun restartStaleTimer(record: Active) {
staleJob?.cancel()
staleJob = appScope.launch {
delay(STALE_TIMEOUT_MS)
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
if (active?.id == record.id) {
val current = active!!
clearActive()
disengage(current, primary, "stale timeout (frames ceased)")
}
}
}
}
private fun nextId(): Long = ++idCounter
companion object {
private val TAG = logTag("Reaction", "Conversation")
/**
* Disengage if no `0x4B` frame arrives for this long while engaged. The pod streams frames
* (~1/s) throughout active speech and none during silence, so this both recovers a dropped
* STOP and bounds how long a duck can linger. Long enough not to disengage mid-conversation
* between frames.
*/
private const val STALE_TIMEOUT_MS = 12L * 1000L
/** A disengage older than this no longer auto-resumes a pause — unexpected late playback is worse. */
private const val PAUSE_RESUME_WINDOW_MS = 2L * 60L * 1000L
}
}
@@ -179,6 +179,7 @@ class PlayPause @Inject constructor(
onePodMode = reactions.onePodMode,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
startMusicOnWear = reactions.startMusicOnWear,
)
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
@@ -204,6 +205,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
startMusicOnWear = reactions.startMusicOnWear,
)
pendingPlayConfirmation = confirmation.pending
@@ -263,7 +265,7 @@ class PlayPause @Inject constructor(
}
decision.shouldPause && reactions.autoPause -> {
val pauseSent = mediaControl.sendPause()
val pauseSent = mediaControl.sendPause(rememberForResume = true)
log(TAG, INFO) {
"autoPause triggered: source=$source, " +
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
@@ -288,10 +290,11 @@ class PlayPause @Inject constructor(
onePodMode: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean = false,
startMusicOnWear: Boolean = false,
): PlayPauseDecision = if (onePodMode) {
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
} else {
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
}
private fun evaluateOnePodMode(
@@ -299,6 +302,7 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
startMusicOnWear: Boolean,
): PlayPauseDecision {
val netChange = current.podCount - previous.podCount
@@ -310,8 +314,13 @@ class PlayPause @Inject constructor(
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
)
// Net increase: pod(s) inserted → play
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
// Net increase: pod(s) inserted → play, but only when we paused recently OR the user
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
// so we never override a user-initiated pause or fire over silence.
netChange > 0 && (
wasRecentlyPausedByUs ||
(startMusicOnWear && !isCurrentlyPlaying)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
@@ -332,13 +341,19 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByCap: Boolean,
startMusicOnWear: Boolean,
): PlayPauseDecision {
val wasWorn = previous.bothInEar
val isWorn = current.bothInEar
return when {
// Transition: not worn → worn, and not playing → play
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
// Transition: not worn → worn → play, but only when we paused recently OR the user
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
// so we never override a user-initiated pause or fire over silence.
!wasWorn && isWorn && (
wasRecentlyPausedByCap ||
(startMusicOnWear && !isCurrentlyPlaying)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "Normal mode: both pods in ear",
@@ -371,6 +386,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
startMusicOnWear: Boolean = false,
): PlayConfirmationResult {
val activePending = pending?.takeIf {
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
@@ -378,7 +394,10 @@ class PlayPause @Inject constructor(
if (activePending != null &&
currentState == activePending.targetState &&
(!isCurrentlyPlaying || wasRecentlyPausedByUs)
(
wasRecentlyPausedByUs ||
(startMusicOnWear && !isCurrentlyPlaying)
)
) {
return PlayConfirmationResult(
decision = PlayPauseDecision(
@@ -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,
@@ -8,7 +8,6 @@ import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -43,7 +42,6 @@ class TroubleShooterViewModel @Inject constructor(
private val blePodMonitor: BlePodMonitor,
private val bleScanModeController: BleScanModeController,
private val deviceMonitor: DeviceMonitor,
private val debugSettings: DebugSettings,
private val timeSource: TimeSource,
) : ViewModel4(dispatcherProvider) {
@@ -87,6 +85,7 @@ class TroubleShooterViewModel @Inject constructor(
log(TAG, INFO) { "troubleShootBle()" }
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
try {
run {
progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
@@ -114,7 +113,7 @@ class TroubleShooterViewModel @Inject constructor(
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
debugSettings.showUnfiltered.valueBlocking = unfiltered
blePodMonitor.setUnfilteredOverride(unfiltered)
val start = timeSource.elapsedRealtime()
val devices = withTimeoutOrNull(STEP_TIME) {
@@ -151,7 +150,6 @@ class TroubleShooterViewModel @Inject constructor(
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
debugSettings.showUnfiltered.valueBlocking = false
return@override
}
@@ -241,6 +239,9 @@ class TroubleShooterViewModel @Inject constructor(
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
} finally {
blePodMonitor.setUnfilteredOverride(false)
}
}
}
+17 -19
View File
@@ -40,8 +40,6 @@
<string name="upgrade_screen_restore_troubleshooting_msg">As u onlangs gekoop het, kan dit \'n oomblik neem vir Google Play om te sinkroniseer.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Probeer weer oor \'n paar minute as u aankoop nie verskyn nie.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Maak seker dat u met dieselfde Google-rekening aangemeld is wat vir die aankoop gebruik is.</string>
<string name="settings_monitor_mode_label">Monitormodus</string>
<string name="settings_monitor_mode_description">Onder watter omstandighede hierdie toepassing Bluetooth-data monitor.</string>
<string name="settings_monitor_connected_notification_label">Ekstra kennisgewing</string>
<string name="settings_monitor_connected_notification_description">Wys \'n ekstra kennisgewing wanneer \'n toestel gekoppel is. Dit laat jou toe om die permanente \"Geen toestelle\" kennisgewing te versteek deur die \"Toestelstatus\" kanaal te deaktiveer.</string>
<string name="settings_keep_notification_after_disconnect_label">Hou kennisgewing ná ontkoppeling</string>
@@ -49,13 +47,11 @@
<string name="settings_autopause_label">Outo-pouse</string>
<string name="settings_autopause_description">Pouseer oudio wanneer die toestel uit jou oor verwyder word.</string>
<string name="settings_autopplay_label">Outo-speel</string>
<string name="settings_autoplay_description">Begin oudio-terugspeel wanneer toestel gedra word.</string>
<string name="settings_autoplay_description">Hervat musiek wanneer jy jou pods weer dra, maar slegs as Outopouseer dit gestop het. Pouses wat jy self geaktiveer het (foon, AirPods-steel, slaap) bly gepouseer.</string>
<string name="settings_start_music_on_wear_label">Begin musiek by dra</string>
<string name="settings_start_music_on_wear_description">Probeer altyd musiek speel wanneer jy jou pods opsit, selfs na \'n handmatige pouseer.</string>
<string name="settings_eardetection_info_label">Nota oor oorbespeuring</string>
<string name="settings_eardetection_info_description">As oorbespeuring net vir een pod werk, is dit \'n Apple-beperking. Slegs die primêre pod (wat vir die mikrofoon gebruik word) word bespeur. Stel op Apple-toestelle op: Settings → Bluetooth → AirPods → Microphone.</string>
<string name="settings_fake_data_label">Vals data</string>
<string name="settings_fake_data_description">Wys vals data, d.w.s. simuleer toestelle wat nie bestaan nie.</string>
<string name="settings_debug_label">Ontfoutinstellings</string>
<string name="settings_debug_description">Addisionele instellings om probleme met die toepassing te help oplos.</string>
<string name="settings_signal_minimum_label">Minimum seingehalte</string>
<string name="settings_signal_minimum_description">Die minimum seingehalte wat \'n toestel moet hê om as joune beskou te word.</string>
<string name="settings_autoconnect_label">Outo-koppel</string>
@@ -80,8 +76,6 @@
<string name="settings_popup_connected_label">Wys verbindingsopspringer</string>
<string name="settings_popup_connected_description">Wys \'n opspringer wanneer die toestel vir die eerste keer koppel.</string>
<string name="settings_popup_info_not_in_app">Opspringvensters word slegs gewys as jy nie in die program is nie.</string>
<string name="settings_popup_warning_manual_mode">Opspringvensters benodig die monitor om te loop. Jou monitormodus is ingestel op \"Wanneer program oop is\", wat die monitor stop wanneer jy die program verlaat.</string>
<string name="general_fix_it_action">Herstel dit</string>
<string name="device_settings_experimental_title">Eksperimentele kenmerk</string>
<string name="device_settings_experimental_description">Hierdie kenmerk is nog nie op alle toestelle getoets nie. As iets nie korrek werk nie, open asseblief \'n kwessie met \'n foutopsporingsloglêer.</string>
<string name="device_settings_experimental_action">Open Kwessiespoorder</string>
@@ -122,10 +116,6 @@
<string name="settings_acknowledgements_label">Erkennings</string>
<string name="settings_debug_autoreports_label">Outomatiese foutverslae</string>
<string name="settings_debug_autoreports_description">Rapporteer probleme outomaties, bv. besonderhede oor \'n toepassingstorting sodat ek kan uitvind hoe om dit reg te stel.</string>
<string name="settings_debug_mode_label">Ontfoutmodus</string>
<string name="settings_debug_mode_description">Wys addisionele inligting om probleme op te los.</string>
<string name="settings_blescanner_unfiltered_label">Ongefiltreerde BLE-data</string>
<string name="settings_blescanner_unfiltered_description">Verwyder enige filters van die BLE-skandeerder om alle uitgesaaide BLE-data te wys. Nuttig om ondersteuning vir nuwe koptelefoontipes by te voeg.</string>
<string name="settings_compatibility_mode_label">Verenigbaarheidsmodus</string>
<string name="settings_compatibility_mode_description">Deaktiveer optimalisasies om verenigbaarheid te verbeter. Probeer dit as jy geen data sien nie.</string>
<string name="help_translate_label">Vertaling</string>
@@ -137,7 +127,14 @@
<string name="anc_widget_config_aap_required_hint">Hierdie widget vereis \'n direkte verbinding (AAP) met jou toestel. Maak seker jou toestel is gepaar en verbind in die program voor jy hierdie widget gebruik.</string>
<string name="anc_widget_no_anc_support_label">Hierdie toestel ondersteun nie geraasbeheersing nie</string>
<string name="anc_widget_aap_not_connected_label">Nie verbind nie</string>
<string name="anc_widget_aap_not_connected_nearby_description">Naby maar nie gekoppel nie</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">Nie naby nie</string>
<string name="anc_widget_aap_connecting_label">Verbind…</string>
<string name="tile_anc_label">Geraaskontrole</string>
<string name="tile_anc_subtitle_permission_required">Toestemming benodig</string>
<string name="tile_anc_subtitle_no_device">Geen toestel</string>
<string name="tile_anc_subtitle_no_anc_support">Geen geraaskontrole</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth af</string>
<string name="widget_config_screen_title">Widget-konfigurasie</string>
<string name="widget_configuration_title">Kies toestel</string>
<string name="widget_configuration_description">Kies watter toestelprofiel hierdie legstuk moet wys.</string>
@@ -220,9 +217,6 @@
<string name="permission_required_title">Die volgende toestemming is vereis:</string>
<string name="permission_system_alert_window_label">Stelselwaarskuwingsvenster</string>
<string name="permission_system_alert_window_description">Laat CAPod toe om oor ander toepassings te teken om die kenmerk Wys opspringer moontlik te maak.</string>
<string name="settings_monitor_mode_manual_label">Wanneer toepassing oop is</string>
<string name="settings_monitor_mode_automatic_label">Wanneer toestel gekoppel is</string>
<string name="settings_monitor_mode_always_label">Altyd</string>
<string name="settings_reaction_autoconnect_whenseen_label">Wanneer gesien</string>
<string name="settings_reaction_autoconnect_caseopen_label">Kassie is oop</string>
<string name="settings_reaction_autoconnect_inear_label">In oor</string>
@@ -254,6 +248,11 @@
<string name="anc_mode_transparency">Deursigtigheid</string>
<string name="anc_mode_adaptive">Aanpasbaar</string>
<string name="conversation_awareness_label">Gespreksbewustheid</string>
<string name="settings_conversation_action_label">Wanneer jy begin praat</string>
<string name="settings_conversation_action_nothing_label">Doen niks</string>
<string name="settings_conversation_action_lower_volume_label">Verlaag volume</string>
<string name="settings_conversation_action_pause_label">Pouseer media</string>
<string name="settings_conversation_volume_reduction_label">Volumevermindering</string>
<string name="signal_badge_ble_cd">BLE-skandering aktief</string>
<string name="signal_badge_key_irk_cd">Identiteit geverifieer</string>
<string name="signal_badge_key_encrypted_cd">Versleutelde verbinding</string>
@@ -322,7 +321,6 @@
<string name="ui_theme_color_amber_label">Amber</string>
<string name="ui_theme_color_disabled_materialyou">Gebruik muurpapierkleure</string>
<!-- Settings categories -->
<string name="settings_category_monitoring_label">Monitering</string>
<string name="settings_category_appearance_label">Voorkoms</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Ongestoorde veranderinge</string>
@@ -470,7 +468,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">Algemeen</string>
<string name="device_settings_microphone_mode_label">Mikrofoon</string>
<string name="device_settings_microphone_mode_description">Watter AirPod as mikrofoon gebruik word</string>
<string name="device_settings_microphone_mode_description">Watter oorfoon as mikrofoon gebruik word</string>
<string name="device_settings_microphone_mode_auto">Outomaties</string>
<string name="device_settings_microphone_mode_right">Regs</string>
<string name="device_settings_microphone_mode_left">Links</string>
@@ -508,7 +506,7 @@
<string name="device_settings_category_connections_label">Gekoppelde toestelle</string>
<string name="device_settings_connected_devices_description">Ander toestelle wat tans aan hierdie AirPods gekoppel is</string>
<string name="device_settings_connected_device_label">Toestel %d</string>
<string name="device_settings_connected_device_call">In n oproep</string>
<string name="device_settings_connected_device_call">In \n oproep</string>
<string name="device_settings_connected_device_media">Speel media</string>
<string name="device_settings_noise_control_label">Geraasbeheer</string>
<string name="device_settings_eq_label">Egaliseerder</string>
+16 -18
View File
@@ -40,8 +40,6 @@
<string name="upgrade_screen_restore_troubleshooting_msg">በቅርቡ ካዘዙ፣ Google Play ለማመሳሰል ትንሽ ጊዜ ሊወስድ ይችላል።</string>
<string name="upgrade_screen_restore_sync_patience_hint">ግዢዎ ካልታየ ከጥቂት ደቂቃዎች በኋላ እንደገና ሞክር።</string>
<string name="upgrade_screen_restore_multiaccount_hint">ለግዢው ጥቅም ላይ ከዋለው ጋር ተመሳሳይ Google መለያ ተጠቅመህ ገብተሃል ወይም ብለህ አረጋግጥ።</string>
<string name="settings_monitor_mode_label">የክትትል ሁነታ</string>
<string name="settings_monitor_mode_description">ይህ መተግበሪያ የብሉቱዝ መረጃን የሚከታተልበት ሁኔታዎች።</string>
<string name="settings_monitor_connected_notification_label">ተጨማሪ ማሳወቂያ</string>
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
@@ -49,13 +47,11 @@
<string name="settings_autopause_label">ራስ-ሰር ለአፍታ ማቆም</string>
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
<string name="settings_autoplay_description">መሳሪያው ሲለበስ የድምጽ መልሶ ማጫወት ይጀምሩ</string>
<string name="settings_autoplay_description">ፖዶቹን እንደገና ሲያጠቁ ሙዚቃን ቀጥል፣ ግን ራስ-አቆም ካቆመው ብቻ። ራስዎ ያቆሙት (ስልክ፣ የ AirPods ቀጭኑ፣ ወይም እንቅልፍ) ቆሞ ይቆያል</string>
<string name="settings_start_music_on_wear_label">ሲያጠቁ ሙዚቃ ጀምር</string>
<string name="settings_start_music_on_wear_description">ፖዶቹን ሲያጠቁ ሁልጊዜ ሙዚቃ ለማጫወት ይሞክራል፣ እጅ ሲቆም ካቆሙ በኋላ እንኳን።</string>
<string name="settings_eardetection_info_label">የጆሮ ማወቂያ ማስታወሻ</string>
<string name="settings_eardetection_info_description">የጆሮ ማወቂያ ለአንድ ፖድ ብቻ የሚሰራ ከሆነ ይህ የApple ገደብ ነው። \"ዋና ፖድ\" (ለማይክሮፎን የሚጠቅም) ብቻ ይታወቃል። በApple መሳሪያዎች ላይ ያዋቅሩ: Settings → Bluetooth → AirPods → Microphone.</string>
<string name="settings_fake_data_label">የውሸት መረጃ</string>
<string name="settings_fake_data_description">የውሸት መረጃ ያሳዩ፣ ማለትም የሌሉ መሳሪያዎችን አስመስለው ያሳዩ።</string>
<string name="settings_debug_label">የማረሚያ ቅንብሮች</string>
<string name="settings_debug_description">በመተግበሪያው ላይ ያሉ ችግሮችን ለመፍታት የሚረዱ ተጨማሪ ቅንብሮች።</string>
<string name="settings_signal_minimum_label">ዝቅተኛ የሲግናል ጥራት</string>
<string name="settings_signal_minimum_description">አንድ መሣሪያ የእርስዎ ተደርጎ እንዲቆጠር ሊኖረው የሚገባው ዝቅተኛው የሲግናል ጥራት።</string>
<string name="settings_autoconnect_label">ራስ-ሰር ግንኙነት</string>
@@ -80,8 +76,6 @@
<string name="settings_popup_connected_label">የግንኙነት ብቅ-ባይ አሳይ</string>
<string name="settings_popup_connected_description">መሳሪያው ለመጀመሪያ ጊዜ ሲገናኝ ብቅ-ባይ አሳይ።</string>
<string name="settings_popup_info_not_in_app">ፖፕ-አፕ መልዕክቶች በፕሮግራሙ ውስጥ ሳታሉ ብቻ ይታያሉ።</string>
<string name="settings_popup_warning_manual_mode">ፖፕ-አፕ መልዕክቶች ሞኒተሩ እየሮጠ እንዲሆን ይፈልጋሉ። የሞኒተር ሁነታዎ \"ፕሮግራሙ ሲከፈት\" ተዘጋጅቷል፣ ይህም ፕሮግራሙን ሲለቁ ሞኒተሩን ያቆማል።</string>
<string name="general_fix_it_action">አስተካክለው</string>
<string name="device_settings_experimental_title">ሙከራዊ ባህሪ</string>
<string name="device_settings_experimental_description">ይህ ባህሪ በሁሉም መሳሪያዎች ላይ ገና አልተሞከረም። ሆኖም ካልሰራ፣ እባክዎ የዲበግ ምዝግብ ማስታወሻ ጨምረው ጉዳዩን ይክፈቱ።</string>
<string name="device_settings_experimental_action">የጉዳይ መከታተያ ክፈት</string>
@@ -122,10 +116,6 @@
<string name="settings_acknowledgements_label">ምስጋናዎች</string>
<string name="settings_debug_autoreports_label">ራስ-ሰር የሳንካ ሪፖርቶች</string>
<string name="settings_debug_autoreports_description">ችግሮችን በራስ-ሰር ሪፖርት ያደርጋል፣ ለምሳሌ የመተግበሪያ ብልሽት ዝርዝሮችን እንዴት ማስተካከል እንዳለብኝ እንድገነዘብ።</string>
<string name="settings_debug_mode_label">የማረሚያ ሁነታ</string>
<string name="settings_debug_mode_description">ችግሮችን ለመፍታት ተጨማሪ መረጃ ያሳዩ።</string>
<string name="settings_blescanner_unfiltered_label">ያልተጣራ የBLE መረጃ</string>
<string name="settings_blescanner_unfiltered_description">ሁሉንም የተላለፉ የBLE መረጃዎችን ለማሳየት ማንኛውንም ማጣሪያ ከBLE ቃኚው ያስወግዱ። ለአዲስ የጆሮ ማዳመጫ አይነቶች ድጋፍ ለመጨመር ጠቃሚ ነው።</string>
<string name="settings_compatibility_mode_label">የተኳኋኝነት ሁነታ</string>
<string name="settings_compatibility_mode_description">ተኳኋኝነትን ለማሻሻል ማመቻቸቶችን አሰናክል። ምንም መረጃ ካላዩ ይህንን ይሞክሩ።</string>
<string name="help_translate_label">ትርጉም</string>
@@ -137,7 +127,14 @@
<string name="anc_widget_config_aap_required_hint">ይህ ዊጄት ወደ መሳሪያዎ ቀጥታ ግንኙነት (AAP) ይፈልጋል። ይህን ዊጄት ከመጠቀምዎ በፊት መሳሪያዎ ጥምር ሆኖ በአፕ ውስጥ መገናኘቱን ያረጋግጡ።</string>
<string name="anc_widget_no_anc_support_label">ይህ መሳሪያ የጫጫታ ቁጥጥርን አይደግፍም</string>
<string name="anc_widget_aap_not_connected_label">አልተገናኘም</string>
<string name="anc_widget_aap_not_connected_nearby_description">አቅራቢያ ነው ግን አልተገናኘም</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">አቅራቢያ የለም</string>
<string name="anc_widget_aap_connecting_label">በመገናኘት ላይ…</string>
<string name="tile_anc_label">ጫጫታ ቁጥጥር</string>
<string name="tile_anc_subtitle_permission_required">ፈቃድ ያስፈልጋል</string>
<string name="tile_anc_subtitle_no_device">መሣሪያ የለም</string>
<string name="tile_anc_subtitle_no_anc_support">ጫጫታ ቁጥጥር የለም</string>
<string name="tile_anc_subtitle_bluetooth_off">ብሉቱዝ ጠፍቷል</string>
<string name="widget_config_screen_title">ዊጀት ውቅር</string>
<string name="widget_configuration_title">መሣሪያ ይምረጡ</string>
<string name="widget_configuration_description">ይህ መግብር የትኛውን የመሣሪያ መገለጫ ማሳየት እንዳለበት ይምረጡ።</string>
@@ -220,9 +217,6 @@
<string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string>
<string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string>
<string name="permission_system_alert_window_description">\"ብቅ-ባይ አሳይ\" ባህሪውን ለማስቻል CAPod በሌሎች መተግበሪያዎች ላይ እንዲሳል ፍቀድ።</string>
<string name="settings_monitor_mode_manual_label">መተግበሪያው ሲከፈት</string>
<string name="settings_monitor_mode_automatic_label">መሣሪያ ሲገናኝ</string>
<string name="settings_monitor_mode_always_label">ሁልጊዜ</string>
<string name="settings_reaction_autoconnect_whenseen_label">ሲታይ</string>
<string name="settings_reaction_autoconnect_caseopen_label">ኬዝ ክፍት ነው</string>
<string name="settings_reaction_autoconnect_inear_label">በጆሮ ውስጥ</string>
@@ -254,6 +248,11 @@
<string name="anc_mode_transparency">ግልጽነት</string>
<string name="anc_mode_adaptive">መላሚ</string>
<string name="conversation_awareness_label">የውይይት ግንዛቤ</string>
<string name="settings_conversation_action_label">መናገር ሲጀምሩ</string>
<string name="settings_conversation_action_nothing_label">ምንም አታድርግ</string>
<string name="settings_conversation_action_lower_volume_label">ድምፅ ዝቅ አድርግ</string>
<string name="settings_conversation_action_pause_label">ሚዲያ አቆም</string>
<string name="settings_conversation_volume_reduction_label">የድምፅ ቅናሽ</string>
<string name="signal_badge_ble_cd">BLE ቅኝት ንቁ ነው</string>
<string name="signal_badge_key_irk_cd">ማንነት ተረጋግጧል</string>
<string name="signal_badge_key_encrypted_cd">ምስጢራዊ ግንኙነት</string>
@@ -322,7 +321,6 @@
<string name="ui_theme_color_amber_label">ህብረ-ቀለማዊ</string>
<string name="ui_theme_color_disabled_materialyou">የዳሰሳ ምስል ቀለሞችን ይጠቀማል</string>
<!-- Settings categories -->
<string name="settings_category_monitoring_label">ክትትል</string>
<string name="settings_category_appearance_label">ገጽታ</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">ያልተቀመጡ ለውጦች</string>
@@ -470,7 +468,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">አጠቃላይ</string>
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
<string name="device_settings_microphone_mode_description">የትኛው AirPod እንደ ማይክሮፎን ንእ ላይ ይወላ</string>
<string name="device_settings_microphone_mode_description">የትኛው ኢርባድ እንደ ማይክሮፎን ጥቅም ላይ እንደሚው</string>
<string name="device_settings_microphone_mode_auto">ራስ-ሰር</string>
<string name="device_settings_microphone_mode_right">ቀኝ</string>
<string name="device_settings_microphone_mode_left">ግራ</string>
+35 -37
View File
@@ -6,8 +6,8 @@
<string name="general_copy_action">نسخ</string>
<string name="general_thank_you_label">شكرًا لك</string>
<string name="general_upgrade_action">ترقية</string>
<string name="common_feature_requires_pro_msg">قم بالترقية للفتح.</string>
<string name="common_upgrade_required_label">يتطلب ترقية</string>
<string name="common_feature_requires_pro_msg">رقّي للفتح.</string>
<string name="common_upgrade_required_label">يتطلب الترقية</string>
<string name="general_donate_action">تبرع</string>
<string name="general_check_action">تحقق</string>
<string name="general_close_action">إغلاق</string>
@@ -20,28 +20,26 @@
<string name="general_example_label">مثال: %s</string>
<string name="upgrade_capod_label">ترقية CAPod</string>
<string name="upgrade_capod_description">احصل على ميزات إضافية وادعم المطور.</string>
<string name="upgrade_preamble">تم تطوير CAPod من قِبل شخص واحد. الترقية تفتح ميزات إضافية وتساعد في إبقاء التطبيق حياً.</string>
<string name="upgrade_preamble">تم تطوير تطبيق CAPod بواسطة شخص واحد. يتيح التحديث ميزات إضافية ويساعد في استمرار عمل التطبيق.</string>
<string name="upgrade_benefit_themes">تخصيص السمة</string>
<string name="upgrade_benefit_autoplay">تشغيل تلقائي وإيقاف مؤقت تلقائي</string>
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح الغطاء والاتصال</string>
<string name="upgrade_benefit_widgets">عناصر واجهة الشاشة الرئيسية</string>
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدمة</string>
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز — الضوضاء والعصا والمزيد</string>
<string name="upgrade_benefit_support">دعم المطور</string>
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز الخاص بك.</string>
<string name="upgrade_screen_options_description">نفس الميزات، بتسعير مختلف. يشمل الاشتراك تجربة مجانية ويمكن إلغاؤه في أي وقت.</string>
<string name="upgrade_screen_subscription_trial_action">ابدأ الإصدار التجريبي المجاني</string>
<string name="upgrade_screen_subscription_action">اشترك سنوياً</string>
<string name="upgrade_benefit_autoplay">التشغيل التلقائي &amp; الإيقاف التلقائي</string>
<string name="upgrade_benefit_popups">نافذة منبثقة عند فتح العلبة &amp; الاتصال</string>
<string name="upgrade_benefit_widgets">عناصر الشاشة الرئيسة</string>
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدّمة</string>
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز &#8212; الضوضاء، &amp; السيقان، &amp; المزيد</string>
<string name="upgrade_benefit_support">دعم المطوّر</string>
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز المستخدَم.</string>
<string name="upgrade_screen_options_description">نفس الميزات، أسعار مختلفة. يشمل الاشتراك فترة تجريبية مجانية ويمكن إلغاؤه في أي وقت.</string>
<string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string>
<string name="upgrade_screen_subscription_action">الاشتراك سنويًا</string>
<string name="upgrade_screen_subscription_action_hint">%s/سنة</string>
<string name="upgrade_screen_iap_action">شراء لمرة واحدة</string>
<string name="upgrade_screen_iap_action_hint">شراء لمرة واحدة: %s</string>
<string name="upgrade_screen_restore_purchase_action">استعادة المُشتَرَى</string>
<string name="upgrade_screen_iap_action">اشترِ مرة واحدة</string>
<string name="upgrade_screen_iap_action_hint">الشراء لمرة واحدة: %s</string>
<string name="upgrade_screen_restore_purchase_action">استعادة المشتريات</string>
<string name="upgrade_screen_restore_purchase_message">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
<string name="upgrade_screen_restore_troubleshooting_msg">إذا اشتريت مؤخراً، قد يستغرق Google Play لحظة للمزامنة.</string>
<string name="upgrade_screen_restore_sync_patience_hint">حاول مرة أخرى بعد بضع دقائق إذا لم يظهر شراؤك.</string>
<string name="upgrade_screen_restore_multiaccount_hint">تأكد من تسجيل دخولك بنفس حساب Google المستخدم للشراء.</string>
<string name="settings_monitor_mode_label">وضع المراقبة</string>
<string name="settings_monitor_mode_description">تحت أي ظروف يراقب هذا التطبيق بيانات البلوتوث.</string>
<string name="upgrade_screen_restore_troubleshooting_msg">إذا اشتريت مؤخّرًا، فقد يستغرق الأمر بعض الوقت حتى تتم مزامنة حسابك على Google Play.</string>
<string name="upgrade_screen_restore_sync_patience_hint">حاول مرة أخرى بعد بضع دقائق إذا لم يظهر طلبك.</string>
<string name="upgrade_screen_restore_multiaccount_hint">تأكّد من تسجيل دخولك باستخدام حساب Google نفسه المستخدَم في عملية الشراء.</string>
<string name="settings_monitor_connected_notification_label">إشعار إضافي</string>
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
@@ -49,13 +47,11 @@
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopplay_label">تشغيل تلقائي</string>
<string name="settings_autoplay_description">بدء تشغيل الصوت عند ارتداء الجهاز.</string>
<string name="settings_autoplay_description">استئناف الموسيقى عند ارتداء السماعات مجددًا، ولكن فقط إذا أوقفها الإيقاف التلقائي. الإيقافات التي قمت بتشغيلها بنفسك (الهاتف، ذراع AirPods، وضع السكون) تبقى متوقفة.</string>
<string name="settings_start_music_on_wear_label">تشغيل الموسيقى عند الارتداء</string>
<string name="settings_start_music_on_wear_description">يحاول دائمًا تشغيل الموسيقى عند وضع السماعات، حتى بعد الإيقاف اليدوي.</string>
<string name="settings_eardetection_info_label">ملاحظة اكتشاف الأذن</string>
<string name="settings_eardetection_info_description">إذا كان اكتشاف الأذن يعمل لسماعة واحدة فقط، فهذا قيد من Apple. يتم اكتشاف \"السماعة الأساسية\" فقط (المستخدمة للميكروفون). قم بالتكوين على أجهزة Apple: الإعدادات → Bluetooth → AirPods → الميكروفون.</string>
<string name="settings_fake_data_label">بيانات وهمية</string>
<string name="settings_fake_data_description">عرض بيانات وهمية، أي محاكاة الأجهزة غير الموجودة.</string>
<string name="settings_debug_label">إعدادات التصحيح</string>
<string name="settings_debug_description">إعدادات إضافية للمساعدة في استكشاف المشكلات المتعلقة بالتطبيق وإصلاحها.</string>
<string name="settings_signal_minimum_label">الحد الأدنى لجودة الإشارة</string>
<string name="settings_signal_minimum_description">الحد الأدنى لجودة الإشارة التي يجب أن يتمتع بها الجهاز حتى يعتبر ملكك.</string>
<string name="settings_autoconnect_label">اتصال تلقائي</string>
@@ -80,8 +76,6 @@
<string name="settings_popup_connected_label">إظهار نافذة منبثقة للاتصال</string>
<string name="settings_popup_connected_description">إظهار نافذة منبثقة عند اتصال الجهاز لأول مرة.</string>
<string name="settings_popup_info_not_in_app">تظهر النوافذ المنبثقة فقط عندما لا تكون داخل التطبيق.</string>
<string name="settings_popup_warning_manual_mode">تتطلب النوافذ المنبثقة تشغيل المراقب. وضع المراقبة الخاص بك مضبوط على \"عند فتح التطبيق\"، مما يوقف المراقب عند مغادرة التطبيق.</string>
<string name="general_fix_it_action">إصلاحه</string>
<string name="device_settings_experimental_title">ميزة تجريبية</string>
<string name="device_settings_experimental_description">لم يتم اختبار هذه الميزة على جميع الأجهزة بعد. إذا كان هناك شيء لا يعمل بشكل صحيح، يرجى فتح تقرير مشكلة مع سجل تصحيح.</string>
<string name="device_settings_experimental_action">فتح متتبع المشكلات</string>
@@ -122,10 +116,6 @@
<string name="settings_acknowledgements_label">شكر وتقدير</string>
<string name="settings_debug_autoreports_label">تقارير الأخطاء التلقائية</string>
<string name="settings_debug_autoreports_description">الإبلاغ تلقائيًا عن المشكلات، على سبيل المثال تفاصيل حول تعطل التطبيق حتى أتمكن من معرفة كيفية إصلاحه.</string>
<string name="settings_debug_mode_label">وضع التصحيح</string>
<string name="settings_debug_mode_description">عرض معلومات إضافية لاستكشاف المشكلات وإصلاحها.</string>
<string name="settings_blescanner_unfiltered_label">بيانات BLE غير المصفاة</string>
<string name="settings_blescanner_unfiltered_description">إزالة أي عوامل تصفية من ماسح BLE لعرض جميع بيانات BLE المذاعة. مفيد لإضافة دعم لأنواع سماعات الرأس الجديدة.</string>
<string name="settings_compatibility_mode_label">وضع التوافق</string>
<string name="settings_compatibility_mode_description">تعطيل التحسينات لتحسين التوافق. جرب هذا إذا كنت لا ترى أي بيانات.</string>
<string name="help_translate_label">الترجمة</string>
@@ -137,7 +127,14 @@
<string name="anc_widget_config_aap_required_hint">يتطلب هذا الودجت اتصالاً مباشراً (AAP) بجهازك. تأكد من إقران جهازك وتوصيله في التطبيق قبل استخدام هذا الودجت.</string>
<string name="anc_widget_no_anc_support_label">هذا الجهاز لا يدعم التحكم في الضوضاء</string>
<string name="anc_widget_aap_not_connected_label">غير متصل</string>
<string name="anc_widget_aap_not_connected_nearby_description">قريب ولكن غير متصل</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">غير قريب</string>
<string name="anc_widget_aap_connecting_label">جارٍ الاتصال…</string>
<string name="tile_anc_label">التحكم في الضوضاء</string>
<string name="tile_anc_subtitle_permission_required">الإذن مطلوب</string>
<string name="tile_anc_subtitle_no_device">لا يوجد جهاز</string>
<string name="tile_anc_subtitle_no_anc_support">لا يوجد تحكم في الضوضاء</string>
<string name="tile_anc_subtitle_bluetooth_off">البلوتوث مُغلق</string>
<string name="widget_config_screen_title">إعداد عنصر الواجهة</string>
<string name="widget_configuration_title">اختيار الجهاز</string>
<string name="widget_configuration_description">اختر ملف تعريف الجهاز الذي يجب أن تعرضه هذه الأداة.</string>
@@ -228,9 +225,6 @@
<string name="permission_required_title">الإذن التالي مطلوب:</string>
<string name="permission_system_alert_window_label">نافذة تنبيه النظام</string>
<string name="permission_system_alert_window_description">السماح لـ CAPod بالرسم فوق التطبيقات الأخرى لتمكين ميزة \"إظهار النافذة المنبثقة\".</string>
<string name="settings_monitor_mode_manual_label">عند فتح التطبيق</string>
<string name="settings_monitor_mode_automatic_label">عند اتصال الجهاز</string>
<string name="settings_monitor_mode_always_label">دائمًا</string>
<string name="settings_reaction_autoconnect_whenseen_label">عند الرؤية</string>
<string name="settings_reaction_autoconnect_caseopen_label">العلبة مفتوحة</string>
<string name="settings_reaction_autoconnect_inear_label">في الأذن</string>
@@ -262,6 +256,11 @@
<string name="anc_mode_transparency">الشفافية</string>
<string name="anc_mode_adaptive">تكيفي</string>
<string name="conversation_awareness_label">الوعي بالمحادثة</string>
<string name="settings_conversation_action_label">عند بدء الكلام</string>
<string name="settings_conversation_action_nothing_label">لا تفعل شيئًا</string>
<string name="settings_conversation_action_lower_volume_label">خفض مستوى الصوت</string>
<string name="settings_conversation_action_pause_label">إيقاف الوسائط</string>
<string name="settings_conversation_volume_reduction_label">تخفيض مستوى الصوت</string>
<string name="signal_badge_ble_cd">مسح BLE نشط</string>
<string name="signal_badge_key_irk_cd">تم التحقق من الهوية</string>
<string name="signal_badge_key_encrypted_cd">اتصال مشفر</string>
@@ -330,7 +329,6 @@
<string name="ui_theme_color_amber_label">كهرماني</string>
<string name="ui_theme_color_disabled_materialyou">يستخدم ألوان الخلفية</string>
<!-- Settings categories -->
<string name="settings_category_monitoring_label">المراقبة</string>
<string name="settings_category_appearance_label">المظهر</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">تغييرات غير محفوظة</string>
@@ -490,7 +488,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">عام</string>
<string name="device_settings_microphone_mode_label">ميكروفون</string>
<string name="device_settings_microphone_mode_description">أي AirPod يُستخدم كميكروفون</string>
<string name="device_settings_microphone_mode_description">سماعة الأذن المستخدمة كميكروفون</string>
<string name="device_settings_microphone_mode_auto">تلقائي</string>
<string name="device_settings_microphone_mode_right">يمين</string>
<string name="device_settings_microphone_mode_left">يسار</string>
+17 -19
View File
@@ -40,8 +40,6 @@
<string name="upgrade_screen_restore_troubleshooting_msg">Əgər bu yaxınlarda satın almısınızsa, Google Play-in sinxronlaşması bir az vaxt ala bilər.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Alışınız görünmürsə bir neçə dəqiqədən sonra yenidən cəhd edin.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Alış üçün istifadə edilmiş eyni Google hesabı ilə daxil olduğunuzu yoxlayın.</string>
<string name="settings_monitor_mode_label">İzləmə rejimi</string>
<string name="settings_monitor_mode_description">Bu tətbiqin Bluetooth məlumatlarını hansı şəraitdə izlədiyi.</string>
<string name="settings_monitor_connected_notification_label">Əlavə bildiriş</string>
<string name="settings_monitor_connected_notification_description">Cihaz qoşulduqda əlavə bildiriş göstərir. Bu, \"Cihaz statusu\" kanalını deaktiv etməklə daimi \"Cihaz yoxdur\" bildirişini gizlətməyə imkan verir.</string>
<string name="settings_keep_notification_after_disconnect_label">Ayrıldıqdan sonra bildirişi saxla</string>
@@ -49,13 +47,11 @@
<string name="settings_autopause_label">Avtomatik fasilə</string>
<string name="settings_autopause_description">Cihazı qulağınızdan çıxardıqda səsi dayandırın.</string>
<string name="settings_autopplay_label">Avtomatik oxutma</string>
<string name="settings_autoplay_description">Cihaz taxıldıqda səs oxutmasını başladın.</string>
<string name="settings_autoplay_description">Podlarınızı yenidən taxdıqda musiqini davam etdirin, lakin yalnız Avtomatik Fasilə onu dayandırdıqda. Özünüz dayandırdığınız fasilələr (telefon, AirPods sapı, yuxu) fasilə vəziyyətində qalır.</string>
<string name="settings_start_music_on_wear_label">Taxdıqda musiqini başlat</string>
<string name="settings_start_music_on_wear_description">Podlarınızı taxdıqda həmişə musiqi çalmağa çalışır, hətta əl ilə fasilə verdikdən sonra belə.</string>
<string name="settings_eardetection_info_label">Qulaq aşkarlama qeydi</string>
<string name="settings_eardetection_info_description">Qulaq aşkarlama yalnız bir qulaqlıq üçün işləyirsə, bu Apple məhdudiyyətidir. Yalnız \"əsas qulaqlıq\" (mikrofon üçün istifadə olunan) aşkarlanır. Apple cihazlarında tənzimləyin: Parametrlər → Bluetooth → AirPods → Mikrofon.</string>
<string name="settings_fake_data_label">Saxta məlumat</string>
<string name="settings_fake_data_description">Saxta məlumat göstər, yəni mövcud olmayan cihazları simulyasiya et.</string>
<string name="settings_debug_label">Sazlama parametrləri</string>
<string name="settings_debug_description">Tətbiqlə bağlı problemlərin aradan qaldırılmasına kömək edən əlavə parametrlər.</string>
<string name="settings_signal_minimum_label">Minimum siqnal keyfiyyəti</string>
<string name="settings_signal_minimum_description">Bir cihazın sizə aid hesab edilməsi üçün malik olmalı olduğu minimum siqnal keyfiyyəti.</string>
<string name="settings_autoconnect_label">Avtomatik qoşulma</string>
@@ -80,8 +76,6 @@
<string name="settings_popup_connected_label">Qoşulma pop-up-ını göstər</string>
<string name="settings_popup_connected_description">Cihaz ilk dəfə qoşulduqda pop-up göstər.</string>
<string name="settings_popup_info_not_in_app">Açılan pəncərələr yalnız tətbiqdə olmadığınız zaman göstərilir.</string>
<string name="settings_popup_warning_manual_mode">Açılan pəncərələr monitorun işləməsini tələb edir. Monitor rejiminizdə \"Tətbiq açıq olduqda\" seçilmişdir — bu, tətbiqdən çıxdığınız zaman monitoru dayandırır.</string>
<string name="general_fix_it_action">Düzəlt</string>
<string name="device_settings_experimental_title">Eksperimental funksiya</string>
<string name="device_settings_experimental_description">Bu funksiya hələ bütün cihazlarda sınaqdan keçirilməyib. Bir şey düzgün işləmirsə, debug loqu ilə problem bildirin.</string>
<string name="device_settings_experimental_action">Problem İzləyicisini Aç</string>
@@ -122,10 +116,6 @@
<string name="settings_acknowledgements_label">Təşəkkürlər</string>
<string name="settings_debug_autoreports_label">Avtomatik səhv hesabatları</string>
<string name="settings_debug_autoreports_description">Problemləri avtomatik olaraq bildirir, məsələn, tətbiqin çökməsi ilə bağlı təfərrüatları, beləliklə onu necə düzəldəcəyimi anlaya bilim.</string>
<string name="settings_debug_mode_label">Sazlama rejimi</string>
<string name="settings_debug_mode_description">Problemlərin aradan qaldırılması üçün əlavə məlumat göstərin.</string>
<string name="settings_blescanner_unfiltered_label">Filtrlənməmiş BLE məlumatları</string>
<string name="settings_blescanner_unfiltered_description">Bütün yayımlanan BLE məlumatlarını göstərmək üçün BLE skanerindən hər hansı bir filtri çıxarın. Yeni qulaqlıq növləri üçün dəstək əlavə etmək üçün faydalıdır.</string>
<string name="settings_compatibility_mode_label">Uyğunluq rejimi</string>
<string name="settings_compatibility_mode_description">Uyğunluğu yaxşılaşdırmaq üçün optimallaşdırmaları deaktiv edin. Heç bir məlumat görmürsünüzsə, bunu sınayın.</string>
<string name="help_translate_label">Tərcümə</string>
@@ -137,7 +127,14 @@
<string name="anc_widget_config_aap_required_hint">Bu vidcet cihazınızla birbaşa bağlantı (AAP) tələb edir. Bu vidceti istifadə etməzdən əvvəl cihazınızın tətbiqdə cütləşdirilmiş və qoşulu olduğuna əmin olun.</string>
<string name="anc_widget_no_anc_support_label">Bu cihaz səs-küy idarəetməsini dəstəkləmir</string>
<string name="anc_widget_aap_not_connected_label">Qoşulmayıb</string>
<string name="anc_widget_aap_not_connected_nearby_description">Yaxındadır, lakin qoşulmayıb</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">Yaxında deyil</string>
<string name="anc_widget_aap_connecting_label">Qoşulur…</string>
<string name="tile_anc_label">Səs-küy Nəzarəti</string>
<string name="tile_anc_subtitle_permission_required">İcazə tələb olunur</string>
<string name="tile_anc_subtitle_no_device">Cihaz yoxdur</string>
<string name="tile_anc_subtitle_no_anc_support">Səs-küy nəzarəti yoxdur</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth söndürülüb</string>
<string name="widget_config_screen_title">Vidcet Konfiqurasiyası</string>
<string name="widget_configuration_title">Cihaz seçin</string>
<string name="widget_configuration_description">Bu vidcetin hansı cihaz profilini göstərəcəyini seçin.</string>
@@ -220,16 +217,13 @@
<string name="permission_required_title">Aşağıdakı icazə tələb olunur:</string>
<string name="permission_system_alert_window_label">Sistem xəbərdarlıq pəncərəsi</string>
<string name="permission_system_alert_window_description">\"Pop-up göstər\" xüsusiyyətini mümkün etmək üçün CAPod-a digər tətbiqlərin üzərində çəkməyə icazə verin.</string>
<string name="settings_monitor_mode_manual_label">Tətbiq açıq olduqda</string>
<string name="settings_monitor_mode_automatic_label">Cihaz qoşulduqda</string>
<string name="settings_monitor_mode_always_label">Həmişə</string>
<string name="settings_reaction_autoconnect_whenseen_label">Görüldüyündə</string>
<string name="settings_reaction_autoconnect_caseopen_label">Qutu açıqdır</string>
<string name="settings_reaction_autoconnect_inear_label">Qulaqda</string>
<string name="pods_dual_left_label">Sol qulaqlıq</string>
<string name="pods_dual_right_label">Sağ qulaqlıq</string>
<string name="pods_dual_left_short_label">S</string>
<string name="pods_dual_right_short_label">SĚ</string>
<string name="pods_dual_right_short_label">Sğ</string>
<string name="pods_case_label">Qutu</string>
<string name="battery_unavailable_label">Batareya məlumatı yoxdur</string>
<string name="pods_case_status_open_label">Açıq</string>
@@ -254,6 +248,11 @@
<string name="anc_mode_transparency">Şəffaflıq</string>
<string name="anc_mode_adaptive">Adaptiv</string>
<string name="conversation_awareness_label">Söhbət Məlumatlılığı</string>
<string name="settings_conversation_action_label">Danışmağa başladıqda</string>
<string name="settings_conversation_action_nothing_label">Heç nə etmə</string>
<string name="settings_conversation_action_lower_volume_label">Səsi azalt</string>
<string name="settings_conversation_action_pause_label">Medianı dayandır</string>
<string name="settings_conversation_volume_reduction_label">Səs azaldılması</string>
<string name="signal_badge_ble_cd">BLE skanı aktiv</string>
<string name="signal_badge_key_irk_cd">Kimlik təsdiqləndi</string>
<string name="signal_badge_key_encrypted_cd">Şifrəli bağlantı</string>
@@ -322,7 +321,6 @@
<string name="ui_theme_color_amber_label">Kəhrəba</string>
<string name="ui_theme_color_disabled_materialyou">Divar kăğizi rənglərini istifadə edir</string>
<!-- Settings categories -->
<string name="settings_category_monitoring_label">Monitorinq</string>
<string name="settings_category_appearance_label">Görünüş</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Saxlanmamış dəyişikliklər</string>
@@ -470,7 +468,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">Ümumi</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
<string name="device_settings_microphone_mode_description">Mikrofonun hansı AirPod tərəfindən istifadə edildiyini</string>
<string name="device_settings_microphone_mode_description">Mikrofon kimi hansı qulaqlıq istifadə olunur</string>
<string name="device_settings_microphone_mode_auto">Avtomatik</string>
<string name="device_settings_microphone_mode_right">Sağ</string>
<string name="device_settings_microphone_mode_left">Sol</string>
+16 -18
View File
@@ -40,8 +40,6 @@
<string name="upgrade_screen_restore_troubleshooting_msg">Калі вы нядаўна зрабілі пакупку, Google Play можа спатрэбіцца некаторы час для сінхранізацыі.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Паспрабуйце яшчэ раз праз некалькі хвілін, калі ваша пакупка не з\'яўляецца.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Пераканайцеся, што вы ўвайшлі ў той самы акаўнт Google, які быў выкарыстаны для пакупкі.</string>
<string name="settings_monitor_mode_label">Рэжым маніторынгу</string>
<string name="settings_monitor_mode_description">Пры якіх абставінах гэта праграма сочыць за данымі Bluetooth.</string>
<string name="settings_monitor_connected_notification_label">Дадатковае апавяшчэнне</string>
<string name="settings_monitor_connected_notification_description">Паказвае дадатковае апавяшчэнне пры падключэнні прылады. Гэта дазваляе схаваць пастаяннае апавяшчэнне \"Няма прылад\" шляхам адключэння канала \"Стан прылады\".</string>
<string name="settings_keep_notification_after_disconnect_label">Захаваць апавяшчэнне пасля адключэння</string>
@@ -49,13 +47,11 @@
<string name="settings_autopause_label">Аўтаматычная паўза</string>
<string name="settings_autopause_description">Прыпыняць аўдыя пры выманні прылады з вуха.</string>
<string name="settings_autopplay_label">Аўтаматычнае прайграванне</string>
<string name="settings_autoplay_description">Пачынаць прайграванне аўдыя пры надзяванні прылады.</string>
<string name="settings_autoplay_description">Аднавіць музыку пры паўторным надзяванні навушнікаў, але толькі калі яе спыніла аўтапаўза. Паўзы, якія вы самі ўключылі (тэлефон, ножка AirPods, сон), застаюцца.</string>
<string name="settings_start_music_on_wear_label">Пачынаць музыку пры надзяванні</string>
<string name="settings_start_music_on_wear_description">Заўсёды спрабуе прайграць музыку, калі вы надзяваеце навушнікі, нават пасля ручной паўзы.</string>
<string name="settings_eardetection_info_label">Заўвага пра вызначэнне вуха</string>
<string name="settings_eardetection_info_description">Калі вызначэнне вуха працуе толькі для аднаго навушніка, гэта абмежаванне Apple. Вызначаецца толькі \"асноўны навушнік\" (выкарыстоўваецца для мікрафона). Наладзьце на прыладах Apple: Налады → Bluetooth → AirPods → Мікрафон.</string>
<string name="settings_fake_data_label">Падробленыя даныя</string>
<string name="settings_fake_data_description">Паказваць падробленыя даныя, г.зн. імітаваць неіснуючыя прылады.</string>
<string name="settings_debug_label">Налады адладкі</string>
<string name="settings_debug_description">Дадатковыя налады для ліквідацыі непаладак з праграмай.</string>
<string name="settings_signal_minimum_label">Мінімальная якасць сігналу</string>
<string name="settings_signal_minimum_description">Мінімальная якасць сігналу, якую павінна мець прылада, каб лічыцца вашай.</string>
<string name="settings_autoconnect_label">Аўтаматычнае падключэнне</string>
@@ -80,8 +76,6 @@
<string name="settings_popup_connected_label">Паказаць усплывальнае акно падключэння</string>
<string name="settings_popup_connected_description">Паказваць усплывальнае акно пры першым падключэнні прылады.</string>
<string name="settings_popup_info_not_in_app">Усплывальныя вокны паказваюцца толькі тады, калі вы не знаходзіцеся ў прыкладанні.</string>
<string name="settings_popup_warning_manual_mode">Усплывальныя вокны патрабуюць працы манітора. Рэжым манітора ўсталяваны на «Калі прыкладанне адкрыта», які спыняе маніторынг пры выхадзе з прыкладання.</string>
<string name="general_fix_it_action">Выправіць</string>
<string name="device_settings_experimental_title">Эксперыментальная функцыя</string>
<string name="device_settings_experimental_description">Гэта функцыя яшчэ не была праверана на ўсіх прыладах. Калі нешта не працуе правільна, калі ласка, стварыце запыт з журналам адладкі.</string>
<string name="device_settings_experimental_action">Адкрыць трэкер задач</string>
@@ -122,10 +116,6 @@
<string name="settings_acknowledgements_label">Падзякі</string>
<string name="settings_debug_autoreports_label">Аўтаматычныя справаздачы пра памылкі</string>
<string name="settings_debug_autoreports_description">Аўтаматычна паведамляе пра праблемы, напрыклад, звесткі пра збой праграмы, каб я мог зразумець, як гэта выправіць.</string>
<string name="settings_debug_mode_label">Рэжым адладкі</string>
<string name="settings_debug_mode_description">Паказваць дадатковую інфармацыю для ліквідацыі непаладак.</string>
<string name="settings_blescanner_unfiltered_label">Нефільтраваныя даныя BLE</string>
<string name="settings_blescanner_unfiltered_description">Выдаліце ўсе фільтры са сканера BLE, каб паказаць усе трансляваныя даныя BLE. Карысна для дадання падтрымкі новых тыпаў навушнікаў.</string>
<string name="settings_compatibility_mode_label">Рэжым сумяшчальнасці</string>
<string name="settings_compatibility_mode_description">Адключыце аптымізацыі для паляпшэння сумяшчальнасці. Паспрабуйце гэта, калі вы не бачыце ніякіх даных.</string>
<string name="help_translate_label">Пераклад</string>
@@ -137,7 +127,14 @@
<string name="anc_widget_config_aap_required_hint">Гэты віджэт патрабуе прамога злучэння (AAP) з вашай прыладай. Пераканайцеся, што ваша прылада спалучана і падключана ў праграме перад выкарыстаннем гэтага віджэта.</string>
<string name="anc_widget_no_anc_support_label">Гэта прылада не падтрымлівае кіраванне шумам</string>
<string name="anc_widget_aap_not_connected_label">Не падключана</string>
<string name="anc_widget_aap_not_connected_nearby_description">Побач, але не падключана</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">Не побач</string>
<string name="anc_widget_aap_connecting_label">Падключэнне…</string>
<string name="tile_anc_label">Кіраванне шумам</string>
<string name="tile_anc_subtitle_permission_required">Патрабуецца дазвол</string>
<string name="tile_anc_subtitle_no_device">Няма прылады</string>
<string name="tile_anc_subtitle_no_anc_support">Няма кіравання шумам</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth выключаны</string>
<string name="widget_config_screen_title">Канфігурацыя віджэта</string>
<string name="widget_configuration_title">Выбраць прыладу</string>
<string name="widget_configuration_description">Выберыце, які профіль прылады павінен адлюстроўваць гэты віджэт.</string>
@@ -224,9 +221,6 @@
<string name="permission_required_title">Патрабуецца наступны дазвол:</string>
<string name="permission_system_alert_window_label">Сістэмнае акно папярэджання</string>
<string name="permission_system_alert_window_description">Дазвольце CAPod маляваць паверх іншых праграм, каб зрабіць магчымай функцыю \"Паказаць усплывальнае акно\".</string>
<string name="settings_monitor_mode_manual_label">Калі праграма адкрыта</string>
<string name="settings_monitor_mode_automatic_label">Калі прылада падключана</string>
<string name="settings_monitor_mode_always_label">Заўсёды</string>
<string name="settings_reaction_autoconnect_whenseen_label">Калі бачна</string>
<string name="settings_reaction_autoconnect_caseopen_label">Корпус адкрыты</string>
<string name="settings_reaction_autoconnect_inear_label">У вуху</string>
@@ -258,6 +252,11 @@
<string name="anc_mode_transparency">Празрыстасць</string>
<string name="anc_mode_adaptive">Адаптыўны</string>
<string name="conversation_awareness_label">Усведамленне размовы</string>
<string name="settings_conversation_action_label">Калі вы пачынаеце гаварыць</string>
<string name="settings_conversation_action_nothing_label">Нічога не рабіць</string>
<string name="settings_conversation_action_lower_volume_label">Зменшыць гучнасць</string>
<string name="settings_conversation_action_pause_label">Паставіць медыя на паўзу</string>
<string name="settings_conversation_volume_reduction_label">Зніжэнне гучнасці</string>
<string name="signal_badge_ble_cd">BLE-сканаванне актыўнае</string>
<string name="signal_badge_key_irk_cd">Асоба пацверджана</string>
<string name="signal_badge_key_encrypted_cd">Зашыфраванае злучэнне</string>
@@ -326,7 +325,6 @@
<string name="ui_theme_color_amber_label">Бурштынавы</string>
<string name="ui_theme_color_disabled_materialyou">Выкарыстоўвае колеры шпалеры</string>
<!-- Settings categories -->
<string name="settings_category_monitoring_label">Маніторынг</string>
<string name="settings_category_appearance_label">Знешні выгляд</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Незахаваныя змены</string>
@@ -480,7 +478,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">Агульныя</string>
<string name="device_settings_microphone_mode_label">Мікрафон</string>
<string name="device_settings_microphone_mode_description">Які AirPod выкарыстоўваецца як мікрафон</string>
<string name="device_settings_microphone_mode_description">Які навушнік выкарыстоўваецца як мікрафон</string>
<string name="device_settings_microphone_mode_auto">Аўта</string>
<string name="device_settings_microphone_mode_right">Правы</string>
<string name="device_settings_microphone_mode_left">Левы</string>
+16 -18
View File
@@ -40,8 +40,6 @@
<string name="upgrade_screen_restore_troubleshooting_msg">Ако наскоро сте направили покупка, може да отнеме малко, докато Google Play синхронизира.</string>
<string name="upgrade_screen_restore_sync_patience_hint">Опитайте отново след няколко минути, ако покупката ви не се появи.</string>
<string name="upgrade_screen_restore_multiaccount_hint">Уверете се, че сте влезли с акаунта в Google, използван за покупката.</string>
<string name="settings_monitor_mode_label">Режим на наблюдение</string>
<string name="settings_monitor_mode_description">При какви обстоятелства това приложение следи Bluetooth данните.</string>
<string name="settings_monitor_connected_notification_label">Допълнително известие</string>
<string name="settings_monitor_connected_notification_description">Показва допълнително известие, когато устройство е свързано. Това ви позволява да скриете постоянното известие \"Няма устройства\" чрез деактивиране на канала \"Състояние на устройството\".</string>
<string name="settings_keep_notification_after_disconnect_label">Запази известието след прекъсване</string>
@@ -49,13 +47,11 @@
<string name="settings_autopause_label">Автоматична пауза</string>
<string name="settings_autopause_description">Поставяне на аудио на пауза при премахване на устройството от ухото ви.</string>
<string name="settings_autopplay_label">Автоматично възпроизвеждане</string>
<string name="settings_autoplay_description">Стартиране на аудио възпроизвеждане, когато устройството се носи.</string>
<string name="settings_autoplay_description">Възобновява музиката, когато отново сложите слушалките, но само ако е спряна от Автоматично пауза. Паузите, направени от вас (телефон, дръжка на AirPods, заспиване), остават.</string>
<string name="settings_start_music_on_wear_label">Стартиране на музика при поставяне</string>
<string name="settings_start_music_on_wear_description">Винаги се опитва да пусне музика, когато сложите слушалките, дори след ръчна пауза.</string>
<string name="settings_eardetection_info_label">Бележка за разпознаване на ухото</string>
<string name="settings_eardetection_info_description">Ако разпознаването на ухото работи само за една слушалка, това е ограничение на Apple. Само \"основната слушалка\" (използвана за микрофон) се разпознава. Конфигурирайте на Apple устройства: Настройки → Bluetooth → AirPods → Микрофон.</string>
<string name="settings_fake_data_label">Фалшиви данни</string>
<string name="settings_fake_data_description">Показване на фалшиви данни, т.е. симулиране на устройства, които не съществуват.</string>
<string name="settings_debug_label">Настройки за отстраняване на грешки</string>
<string name="settings_debug_description">Допълнителни настройки за подпомагане на отстраняването на проблеми с приложението.</string>
<string name="settings_signal_minimum_label">Минимално качество на сигнала</string>
<string name="settings_signal_minimum_description">Минималното качество на сигнала, което устройството трябва да има, за да се счита за ваше.</string>
<string name="settings_autoconnect_label">Автоматично свързване</string>
@@ -80,8 +76,6 @@
<string name="settings_popup_connected_label">Показване на изскачащ прозорец за връзка</string>
<string name="settings_popup_connected_description">Показване на изскачащ прозорец, когато устройството се свърже за първи път.</string>
<string name="settings_popup_info_not_in_app">Изскачащите прозорци се показват само когато не сте в приложението.</string>
<string name="settings_popup_warning_manual_mode">Изскачащите прозорци изискват мониторът да работи. Режимът на монитора е зададен на „Когато приложението е отворено“, което спира монитора, когато напуснете приложението.</string>
<string name="general_fix_it_action">Поправи го</string>
<string name="device_settings_experimental_title">Експериментална функция</string>
<string name="device_settings_experimental_description">Тази функция все още не е тествана на всички устройства. Ако нещо не работи правилно, моля отворете проблем с дневник за отстраняване на грешки.</string>
<string name="device_settings_experimental_action">Отвори проследявач на проблеми</string>
@@ -122,10 +116,6 @@
<string name="settings_acknowledgements_label">Благодарности</string>
<string name="settings_debug_autoreports_label">Автоматични доклади за грешки</string>
<string name="settings_debug_autoreports_description">Автоматично докладва проблеми, напр. подробности за срив на приложението, за да мога да разбера как да го поправя.</string>
<string name="settings_debug_mode_label">Режим за отстраняване на грешки</string>
<string name="settings_debug_mode_description">Показване на допълнителна информация за отстраняване на проблеми.</string>
<string name="settings_blescanner_unfiltered_label">Нефилтрирани BLE данни</string>
<string name="settings_blescanner_unfiltered_description">Премахнете всички филтри от BLE скенера, за да се покажат всички излъчвани BLE данни. Полезно за добавяне на поддръжка за нови типове слушалки.</string>
<string name="settings_compatibility_mode_label">Режим на съвместимост</string>
<string name="settings_compatibility_mode_description">Деактивирайте оптимизациите, за да подобрите съвместимостта. Опитайте това, ако не виждате никакви данни.</string>
<string name="help_translate_label">Превод</string>
@@ -137,7 +127,14 @@
<string name="anc_widget_config_aap_required_hint">Този уиджет изисква директна връзка (AAP) с вашето устройство. Уверете се, че устройството е сдвоено и свързано в приложението преди да използвате този уиджет.</string>
<string name="anc_widget_no_anc_support_label">Това устройство не поддържа контрол на шума</string>
<string name="anc_widget_aap_not_connected_label">Не е свързано</string>
<string name="anc_widget_aap_not_connected_nearby_description">Наблизо, но не свързано</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">Не е наблизо</string>
<string name="anc_widget_aap_connecting_label">Свързване…</string>
<string name="tile_anc_label">Шумов контрол</string>
<string name="tile_anc_subtitle_permission_required">Необходимо е разрешение</string>
<string name="tile_anc_subtitle_no_device">Няма устройство</string>
<string name="tile_anc_subtitle_no_anc_support">Без шумов контрол</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth е изключен</string>
<string name="widget_config_screen_title">Настройка на джаджата</string>
<string name="widget_configuration_title">Избор на устройство</string>
<string name="widget_configuration_description">Изберете кой профил на устройство да показва това приспособление.</string>
@@ -220,9 +217,6 @@
<string name="permission_required_title">Необходимо е следното разрешение:</string>
<string name="permission_system_alert_window_label">Системен изскачащ прозорец</string>
<string name="permission_system_alert_window_description">Позволете на CAPod да рисува върху други приложения, за да направи функцията \"Покажи изскачащ прозорец\" възможна.</string>
<string name="settings_monitor_mode_manual_label">Когато приложението е отворено</string>
<string name="settings_monitor_mode_automatic_label">Когато устройството е свързано</string>
<string name="settings_monitor_mode_always_label">Винаги</string>
<string name="settings_reaction_autoconnect_whenseen_label">Когато е видяно</string>
<string name="settings_reaction_autoconnect_caseopen_label">Калъфът е отворен</string>
<string name="settings_reaction_autoconnect_inear_label">В ухото</string>
@@ -254,6 +248,11 @@
<string name="anc_mode_transparency">Прозрачност</string>
<string name="anc_mode_adaptive">Адаптивен</string>
<string name="conversation_awareness_label">Разговорна осъзнатост</string>
<string name="settings_conversation_action_label">Когато започнете да говорите</string>
<string name="settings_conversation_action_nothing_label">Без действие</string>
<string name="settings_conversation_action_lower_volume_label">Намаляване на силата на звука</string>
<string name="settings_conversation_action_pause_label">Пауза на медията</string>
<string name="settings_conversation_volume_reduction_label">Намаляване на силата на звука</string>
<string name="signal_badge_ble_cd">BLE сканирането е активно</string>
<string name="signal_badge_key_irk_cd">Самоличността е потвърдена</string>
<string name="signal_badge_key_encrypted_cd">Шифрована връзка</string>
@@ -322,7 +321,6 @@
<string name="ui_theme_color_amber_label">Къхъленожълт</string>
<string name="ui_theme_color_disabled_materialyou">Използва цветовете на тапета</string>
<!-- Settings categories -->
<string name="settings_category_monitoring_label">Мониторинг</string>
<string name="settings_category_appearance_label">Изглед</string>
<!-- Unsaved changes dialog -->
<string name="general_unsaved_changes_title">Незапазени промени</string>
@@ -470,7 +468,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">Общи</string>
<string name="device_settings_microphone_mode_label">Микрофон</string>
<string name="device_settings_microphone_mode_description">Кой AirPod се използва като микрофон</string>
<string name="device_settings_microphone_mode_description">Коя слушалка се използва като микрофон</string>
<string name="device_settings_microphone_mode_auto">Автом.</string>
<string name="device_settings_microphone_mode_right">Десен</string>
<string name="device_settings_microphone_mode_left">Ляв</string>

Some files were not shown because too many files have changed in this diff Show More