Compare commits

..
48 Commits
Author SHA1 Message Date
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
d4rken-org-releaser[bot] fb104cbe61 Release: 5.1.2-rc0 2026-05-01 06:56:07 +00:00
darken 97cbca7a6e fix(ci): Resolve bot slug via action output to avoid JWT-only endpoint 2026-05-01 08:55:15 +02:00
darken 02278dab17 chore(ci): Use GitHub App token for release-prepare push 2026-05-01 08:50:32 +02:00
darken 2240bd7ae5 chore(ci): Drop env gate on release-prepare to keep two-click approval 2026-05-01 08:16:33 +02:00
darken bc4096cb57 chore(ci): Run bats + shellcheck on release tooling 2026-05-01 08:00:15 +02:00
darken 5a0ba1b0bd chore(ci): Move release process to GitHub Actions 2026-05-01 08:00:15 +02:00
darken d04abdf80c General: Fix translation issues across multiple locales
Targeted character / escape / vandalism fixes:

- tr: %80 -> 80%% (UnknownFormatConversionException crash risk)

- pt: replace Cyrillic 'не' with 'não' in autoconnect description

- sk: fix 'ladUciaho' -> 'ladiaceho' (corruption)

- ms: restore 'Discord' brand name

- ca: 'maxima' -> 'minima' for signal_quality_title; fix 'suprimrira' typo

- zh-rTW: fix homophones and Japanese-kanji variants (4 strings)

- zh-rHK: Simplified -> Traditional for press_action_fast_forward

- zu: remove spurious 'Zn' prefix in 2 strings (vandalism)

- lv: fix wrong cedilla character in 3 Bluetooth a11y descriptions

- kmr-rTR: fix 4 encoding artifacts (a-feminine-ordinal substituted for e-circumflex)

- ko: fix 2 Hangul typos (conversation_awareness, allow_off)

Garbled translations removed (fall back to English source):

- hy-rAM: 13 press_controls/press_action strings

- mn-rMN: 2 strings (aap_unavailable_description, listening_mode_cycle_minimum)

- lt: settings_popup_warning_manual_mode

- mk-rMK: device_settings_allow_off_description (Syriac char vandalism)

- lo-rLA: signal_badge_ble_cd (English fallback was effectively unchanged)

- km-rKH: device_settings_microphone_mode_label (Thai script in Khmer file)
2026-05-01 05:19:34 +02:00
darken 0609352cc9 General: Update app translations from Crowdin 2026-05-01 05:19:34 +02:00
darken abde0b7d6a feat(reactions): Add discovery hint and outline settings icon 2026-04-30 17:46:27 +02:00
darken b952aff38d fix(widget): Re-render widgets reactively after initial session start
Glance only calls provideGlance() once per widget session; subsequent update() calls recompose the existing composition without re-running provideGlance. The previous capture-once approach left widgets frozen at initial state because the composition had no reactive State to read.

Subscribe to a widgetDeviceFlow(profileId) inside provideContent that pre-filters by WidgetDeviceKey, so the composition recomposes on visible state changes without firing on every BLE advertisement. Replace updateAll() with explicit per-GlanceId update() calls in WidgetManager, with a platform-id fallback when Glance returns no IDs.
2026-04-30 16:44:57 +02:00
darken c8f5ceda34 fix(monitor): Auto-tune BLE scanner mode by foreground state and connections
Replaces the user-facing scanner mode setting with an automatic policy that picks LOW_LATENCY when a profile-paired device is connected, BALANCED in the foreground, and LOW_POWER in the background. The TroubleShooter scopes a temporary LOW_LATENCY override via a refcounted withTemporaryOverride block so overlapping callers stay correct.

Fixes a regression where the controller could block BLE scanning entirely if BLUETOOTH_CONNECT was missing or the HEADSET profile proxy stalled, and adds a reactive bondedDeviceAddresses flow so bond changes propagate without waiting for an unrelated input. Cleans up the now-dead scanner mode strings across all locales and unused ScannerMode fields.
2026-04-30 15:26:45 +02:00
darken e6dbd2d660 fix(reaction): Restore BLE-only autoplay and stop false reactions on app start
- Apply seenLastAt freshness to all unauthenticated BLE samples (worn and not-worn). The earlier scoping to not-worn-only collapsed the second worn sample for BLE-only autoplay confirmation, so the staged play never fired.

- Replace distinctUntilChangedBy with a manual filter so worn samples that need to reset an active pause debounce (count went up) can pass through even when the monitor key is otherwise identical.

- Skip BLE-only autoplay confirmation for trusted sources. With BLE_IRK_MATCH and AAP, autoplay now fires on the first not-worn -> worn transition, mirroring the pause-debounce skip on the same sources.

- Skip the reaction entirely when the previous emission had no live evidence (NO_LIVE_BLE). Prevents app-process-start from synthesising a fake not-worn -> worn transition and firing autoplay while the user is already wearing the pods. Same guard handles mid-session BLE gap recoveries.

- Add MonitorFlowTests covering process-start-worn, genuine-insertion-after-startup, mid-session BLE-gap recovery, IRK-matched immediate autoplay, BLE-only autoplay confirmation, 3-sample pause debounce, and rebound-tolerated debounce reset.
2026-04-29 18:14:51 +02:00
darken 228d7d1f0c fix(reaction): Tighten auto-pause debounce after review findings
- Commit pending pause when a trusted source (AAP / BLE_IRK_MATCH) corroborates the not-worn condition mid-debounce, instead of dropping pending silently.

- Scope debounceFreshness to not-worn samples only; identical both-in samples no longer pass distinctUntilChangedBy and can't accidentally trigger BLE-only auto-play confirmation.

- Add resetTolerance to PendingPauseDebounce so a single corrupt count-up advert no longer kills a legitimate pending pause; reorder reset checks so rawDecision.shouldPlay resets immediately.

- Drop bleKeyState from the INFO autoPause log; source already encodes trust without leaking key-configuration state to logcat.

- Add flow-level MonitorFlowTests verifying the distinctUntilChangedBy interaction with seenLastAt freshness, plus the #557-direction test (AAP-worn vs corrupt-BLE-not-worn) and rebound-tolerance test.

- Clarify in BLE_ANONYMOUS KDoc that the path is unreachable in production via DeviceMonitor.primaryDevice.
2026-04-29 18:14:51 +02:00
darken 67fead3589 fix(reaction): Debounce auto-pause for unauthenticated BLE sources
Classifies the ear-detection source (AAP / BLE_IRK_MATCH / BLE_PROFILE_FALLBACK / BLE_ANONYMOUS / NO_LIVE_BLE) and applies a 3-sample debounce only to unauthenticated BLE paths. AAP and IRK-authenticated BLE pass through unchanged.

Also tightens toEarDetectionState() to prefer AAP aggregate over BLE per-side bits whenever AAP EarDetection is present, and suppresses pause on NO_LIVE_BLE (cache-only state) to avoid firing without live evidence.
2026-04-29 18:14:51 +02:00
darken d0aab583d2 fix(presscontrols): Auto-exit when device disconnects 2026-04-28 22:39:42 +02:00
darken 367c8d28ff chore: Drop stale app-common exclude from _config.yml
app-common module was merged into app/ in be8f4919; the exclude has been a no-op since.
2026-04-28 12:08:23 +02:00
darken b04e1fe628 chore(ci): Also rebuild Pages on main pushes for content edits
Adds push: [main] alongside workflow_dispatch so edits to README.md, _config.yml, _layouts, or the CHANGELOG.md template publish without a manual dispatch. The chain step in release-tag.yml still runs after release publish to guarantee the new release is in site.github.releases by the time Pages rebuilds — concurrency: cancel-in-progress: false serialises the two runs.

Adds an if: github.ref == 'refs/heads/main' guard on the deploy job so workflow_dispatch from a non-main branch builds for verification but doesn't deploy.
2026-04-28 12:08:23 +02:00
darken ea3ef1ff3b chore: Drop dead Gemfile excludes and ignore Jekyll artifacts
After moving Gemfile/Gemfile.lock to fastlane/, the - Gemfile / - Gemfile.lock entries in _config.yml's exclude list are no-ops; the parent fastlane exclude already covers everything inside.

.gitignore picks up _site/, .jekyll-cache/, vendor/bundle/ so local Jekyll runs don't leave tracked artifacts.
2026-04-28 12:08:23 +02:00
darken ff8b9cf6ed chore(ci): Chain Pages deploy from release-tag.yml and adopt org refinements
release: published events triggered by secrets.GITHUB_TOKEN do not start new workflow runs (only workflow_dispatch and repository_dispatch are exceptions). The Pages workflow's release: published trigger would never have fired in production since release-tag.yml's softprops/action-gh-release uses GITHUB_TOKEN to publish.

Fix: drop the release: published trigger and have release-tag.yml's release-github job explicitly run gh workflow run pages.yml --ref main after the release is created. release-github gains actions: write to authorize the dispatch.

Also adopts refinements from sibling org PRs (permission-pilot#356, bluemusic#220):

- Top-level permissions reduced to contents: read; pages: write and id-token: write moved to the deploy job only (least privilege)

- JEKYLL_GITHUB_TOKEN on the build step so jekyll-github-metadata authenticates when fetching site.github.releases

- Sanity-check step (test -f _site/index.html && _site/CNAME) fails fast if Jekyll produced nothing

- Explicit upload-pages-artifact path: ./_site matches the build's destination

- Verify fastlane Bundler wiring step (bundle exec fastlane --version) lets workflow_dispatch dry_run=true exercise the relocated Gemfile before the next real release
2026-04-28 12:08:23 +02:00
darken 96bc3f5df7 chore(ci): Move Gemfile to fastlane/ for tidier layout
The root Gemfile only ever declared the fastlane gem and lived next to fastlane configuration anyway. Moving it under fastlane/ matches that ownership and keeps the repo root cleaner.

release-gplay job now sets BUNDLE_GEMFILE=fastlane/Gemfile and runs ruby/setup-ruby with working-directory=fastlane so bundler-cache resolves the moved Gemfile. fastlane lanes still run from the repo root.
2026-04-28 12:08:23 +02:00
darken 1891a6d73a chore(ci): Migrate Pages to custom workflow with Node 24 actions
Replaces the auto pages-build-deployment (which still uses Node-20 actions/checkout@v4 and actions/upload-artifact@v4) with a custom workflow using configure-pages@v6, jekyll-build-pages@v1.0.13, upload-pages-artifact@v5, deploy-pages@v5.

Triggered by release publication so the changelog Liquid template (which reads site.github.releases) only rebuilds when a release actually exists. workflow_dispatch is kept for manual rebuilds when debugging Pages content.
2026-04-28 12:08:23 +02:00
darken 698415237d chore(ci): Drop unused Android build-cache step 2026-04-28 12:08:23 +02:00
darken 7ef672a11f chore(ci): Bump action-gh-release to v3.0.0 (Node 24) 2026-04-28 12:08:23 +02:00
darken f67e8aa7d1 fix: Harden foreground service exception handler 2026-04-25 23:40:10 +02:00
darken bcb4bdff08 fix(overview): Prevent crash from duplicate LazyList keys 2026-04-25 23:39:59 +02:00
734 changed files with 7110 additions and 1589 deletions
+1
View File
@@ -43,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
- `pull-requests.md` — PR title and description conventions
- `agent-instructions.md` — Sub-agent delegation and critical thinking
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
+78
View File
@@ -0,0 +1,78 @@
# Release Process
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
## Dispatch
```bash
# Plan only — no commit, no tag, no push.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
# Real cut.
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 (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
| Input | Default | Notes |
|---|---|---|
| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` |
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
| `dry_run` | `true` | Default is plan-only. |
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
## Local
```bash
./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current
./tools/release/bump.sh --mode=check
bats tools/release/bump.bats
```
## Channel mapping
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|---|---|---|---|---|---|
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% |
| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console.
## Rollback
| Stage reached | Steps |
|---|---|
| Bump on `main`, downstream not started | `git push origin :refs/tags/v<bad>`, `git revert <bump-sha>`, push |
| GitHub release created | Above + `gh release delete v<bad> --yes --cleanup-tag` |
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
## Auth setup
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
- `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:
- The main-branch ruleset (PR + status check requirements)
- The tag ruleset (creation restriction on `v*`)
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
## Defense in depth
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
## Stuck-dispatch recovery
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)
+1 -10
View File
@@ -30,13 +30,4 @@ runs:
~/.gradle/caches
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
restore-keys: |
${{ runner.os }}-gradle-caches-
- name: Cache Android Global Build-Cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.android/build-cache
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-android-build-cache-
${{ runner.os }}-gradle-caches-
+27 -1
View File
@@ -3,6 +3,9 @@ name: Code tests & eval
on:
push:
branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request:
branches: [ main ]
@@ -78,4 +81,27 @@ jobs:
with:
persist-credentials: false
- name: Validate metadata lengths
run: bash fastlane/check_metadata_length.sh
run: bash fastlane/check_metadata_length.sh
check-release-tooling:
name: Release tooling
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Install bats and shellcheck
run: |
sudo apt-get update
sudo apt-get install -y bats shellcheck
- name: Shellcheck bump.sh
run: shellcheck tools/release/bump.sh
- name: Run bump.sh unit tests
run: bats tools/release/bump.bats
- name: Verify version.properties + VERSION are consistent
run: ./tools/release/bump.sh --mode=check
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
paths-ignore:
- VERSION
- version.properties
pull_request:
branches:
- main
+66
View File
@@ -0,0 +1,66 @@
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:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Build Jekyll site
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
- name: Build with Jekyll
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 #v1.0.13
with:
source: ./
destination: ./_site
env:
JEKYLL_GITHUB_TOKEN: ${{ github.token }}
- name: Sanity-check Jekyll output
run: test -f _site/index.html && test -f _site/CNAME
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #v5.0.0
with:
path: ./_site
deploy:
name: Deploy to GitHub Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #v5.0.0
+235
View File
@@ -0,0 +1,235 @@
name: Release prepare
on:
workflow_dispatch:
inputs:
bump_kind:
description: 'How to bump the version (ignored if version_override is set)'
type: choice
options: [build, patch, minor, major]
default: build
version_type:
description: 'Channel for the new version (keep-current preserves current type)'
type: choice
options: [keep-current, rc, beta]
default: keep-current
version_override:
description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)'
type: string
default: ''
expected_current:
description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)'
type: string
default: ''
dry_run:
description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.'
type: boolean
default: true
permissions:
contents: read
concurrency:
group: release-prepare-main
cancel-in-progress: false
jobs:
compute-and-validate:
name: Compute and validate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
new_name: ${{ steps.plan.outputs.new_name }}
new_code: ${{ steps.plan.outputs.new_code }}
current_name: ${{ steps.plan.outputs.current_name }}
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }}
steps:
- name: Guard ref must be main
run: |
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "Must dispatch from main, got ${GITHUB_REF}" >&2
exit 1
fi
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Compute and validate
id: plan
run: |
set -euo pipefail
args=("--mode=plan")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then
args+=("--expected-current=${INPUT_EXPECTED_CURRENT}")
fi
./tools/release/bump.sh "${args[@]}" | tee plan.txt
{
grep -E '^current_name=' plan.txt
grep -E '^new_name=' plan.txt
grep -E '^new_code=' plan.txt
} >> "$GITHUB_OUTPUT"
- name: Tag collision check (local + remote)
env:
NEW_NAME: ${{ steps.plan.outputs.new_name }}
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Write step summary
env:
CURRENT_NAME: ${{ steps.plan.outputs.current_name }}
NEW_NAME: ${{ steps.plan.outputs.new_name }}
NEW_CODE: ${{ steps.plan.outputs.new_code }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
{
echo "## Release plan"
echo
echo "| | |"
echo "|---|---|"
echo "| Current | \`${CURRENT_NAME}\` |"
echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Dry run | \`${DRY_RUN}\` |"
echo
echo "### bump.sh output"
echo
echo '```'
cat plan.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
push-and-dispatch:
name: Push and dispatch
needs: compute-and-validate
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-22.04
permissions:
contents: read
env:
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }}
NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }}
CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }}
steps:
- name: Mint App token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
with:
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Resolve bot identity
id: bot
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
set -euo pipefail
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
- name: Checkout main with credentials
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}
- 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 between jobs)
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
echo "Local tag v${NEW_NAME} already exists" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
echo "Remote tag v${NEW_NAME} already exists" >&2
exit 1
fi
- name: Apply bump
run: |
set -euo pipefail
args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}")
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
else
args+=("--bump-kind=${INPUT_BUMP_KIND}")
args+=("--version-type=${INPUT_VERSION_TYPE}")
fi
./tools/release/bump.sh "${args[@]}"
- name: Verify post-write state matches plan
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}"
- name: Configure git identity
env:
BOT_USER_NAME: ${{ steps.bot.outputs.user_name }}
BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
run: |
git config user.name "${BOT_USER_NAME}"
git config user.email "${BOT_USER_EMAIL}"
- name: Commit and tag
run: |
set -euo pipefail
git add version.properties VERSION
git commit -m "Release: ${NEW_NAME}"
git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}"
- name: Atomic push (commit + tag)
run: |
set -euo pipefail
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
- name: Write step summary
run: |
set -euo pipefail
{
echo "## Released"
echo
echo "| | |"
echo "|---|---|"
echo "| Tag | \`v${NEW_NAME}\` |"
echo "| Version code | \`${NEW_CODE}\` |"
echo "| Bump commit | on \`main\` |"
echo "| Downstream | dispatched \`release-tag.yml\` |"
echo
echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload."
} >> "$GITHUB_STEP_SUMMARY"
+58 -2
View File
@@ -14,11 +14,54 @@ on:
permissions:
contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
validate-tag:
name: Validate tag
runs-on: ubuntu-22.04
steps:
- name: Check tag-name format
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
echo "Releases must be cut via the 'Release prepare' workflow." >&2
exit 1
fi
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 1
persist-credentials: false
- name: Verify version.properties matches tag
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Strip leading 'v' to get the bare version name.
tag_name="${REF_NAME#v}"
# bump.sh in check mode emits current_name=...
parsed=$(./tools/release/bump.sh --mode=check)
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
if [[ "$current_name" != "$tag_name" ]]; then
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
exit 1
fi
release-github:
needs: validate-tag
name: Create GitHub release
permissions:
contents: write
actions: write
runs-on: ubuntu-22.04
environment: foss-production
steps:
@@ -61,7 +104,7 @@ jobs:
- name: Create pre-release
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
with:
prerelease: true
tag_name: ${{ github.ref_name }}
@@ -73,7 +116,7 @@ jobs:
- name: Create release
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
with:
prerelease: false
tag_name: ${{ github.ref_name }}
@@ -83,10 +126,19 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger GitHub Pages deployment
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run pages.yml --ref main
release-gplay:
needs: validate-tag
name: Create Google Play release
runs-on: ubuntu-22.04
environment: gplay-production
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
steps:
- name: Decode Keystore
env:
@@ -122,6 +174,10 @@ jobs:
with:
ruby-version: 3.3.6
bundler-cache: true
working-directory: fastlane
- name: Verify fastlane Bundler wiring
run: bundle exec fastlane --version
- name: Assemble beta and upload to Google Play
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
+14 -1
View File
@@ -17,5 +17,18 @@
.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/
protocol-research/
_site/
.jekyll-cache/
vendor/bundle/
+1 -1
View File
@@ -1 +1 @@
5.1.1-rc0 50101000
5.1.5-rc0 50105000
-3
View File
@@ -20,9 +20,6 @@ exclude:
- buildSrc
- gradle/wrapper
- fastlane
- Gemfile
- Gemfile.lock
- crowdin*
- app
- app-common
- CONTRIBUTING.md
+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,16 +38,25 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
@Composable
internal fun DashboardContent() = PreviewWrapper {
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
val devices = if (showAap) {
listOf(
MockPodDataProvider.dualPodMonitoredWithAap(),
MockPodDataProvider.singlePodMonitoredWithAap(),
MockPodDataProvider.unknownMonitored(),
)
} else {
listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
)
}
OverviewScreen(
state = OverviewViewModel.State(
now = MOCK_NOW,
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
devices = devices,
isDebugMode = false,
isBluetoothEnabled = true,
profiles = listOf(
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
@Composable
internal fun AddProfileContent() = PreviewWrapper {
val pairedAirPods = BluetoothDevice2(
address = "AA:BB:CC:DD:EE:FF",
name = "AirPods Pro",
seenFirstAt = MOCK_NOW,
)
val bondedItems = listOf(
DeviceProfileCreationViewModel.BondedDeviceItem(
device = pairedAirPods,
claimedByProfile = null,
),
DeviceProfileCreationViewModel.BondedDeviceItem(
device = BluetoothDevice2(
address = "11:22:33:44:55:66",
name = "Living Room TV",
seenFirstAt = MOCK_NOW,
),
claimedByProfile = null,
),
)
DeviceProfileCreationScreen(
state = DeviceProfileCreationViewModel.State(
isEditMode = false,
name = "",
name = "My AirPods Pro",
nameError = null,
selectedModel = null,
selectedModel = PodModel.AIRPODS_PRO2,
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
identityKey = null,
encryptionKey = null,
selectedDevice = null,
bondedDeviceItems = emptyList(),
selectedDevice = pairedAirPods,
bondedDeviceItems = bondedItems,
minimumSignalQuality = 0.15f,
canSave = false,
canSave = true,
),
onBack = {},
onSave = {},
@@ -212,7 +241,7 @@ private fun PreviewDashboardLight() = DashboardContent()
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
@Composable
private fun PreviewDashboardDark() = DashboardContent()
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
@Composable
+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 -26
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,7 +18,9 @@ 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
@@ -30,7 +28,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.system.exitProcess
@HiltAndroidApp
open class App : Application() {
@@ -45,19 +42,19 @@ open class App : Application() {
super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
var foregroundExceptionHandled = false
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) {
foregroundExceptionHandled = true
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
}
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)
@@ -86,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
}
@@ -0,0 +1,85 @@
package eu.darken.capod.common
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
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 eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppForegroundState @Inject constructor(
@AppScope appScope: CoroutineScope,
dispatcherProvider: DispatcherProvider,
) {
val isForeground: StateFlow<Boolean> = callbackFlow {
val lifecycle = runCatching { ProcessLifecycleOwner.get().lifecycle }
.getOrElse {
log(TAG, WARN) { "Failed to access process lifecycle, assuming background: ${it.asLog()}" }
trySend(false)
close()
return@callbackFlow
}
trySend(safeCurrentForegroundState())
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> trySend(true)
Lifecycle.Event.ON_STOP -> trySend(false)
else -> Unit
}
}
runCatching { lifecycle.addObserver(observer) }
.onFailure {
log(TAG, WARN) { "Failed to observe process lifecycle, assuming background: ${it.asLog()}" }
trySend(false)
close()
}
awaitClose {
runCatching { lifecycle.removeObserver(observer) }
.onFailure { log(TAG, WARN) { "Failed to remove process lifecycle observer: ${it.asLog()}" } }
}
}
.flowOn(dispatcherProvider.MainImmediate)
.catch {
log(TAG, WARN) { "Foreground state flow failed, assuming background: ${it.asLog()}" }
emit(false)
}
.distinctUntilChanged()
.onEach { log(TAG) { "isForeground=$it" } }
.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
initialValue = safeCurrentForegroundState(),
)
private fun safeCurrentForegroundState(): Boolean = runCatching {
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}.getOrElse {
log(TAG, WARN) { "Failed to read process lifecycle state, assuming background: ${it.asLog()}" }
false
}
companion object {
private val TAG = logTag("App", "ForegroundState")
}
}
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -113,22 +114,7 @@ class BleScanner @Inject constructor(
null
}
val flushJob = if (!disableDirectScanCallback) {
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} else {
null
}
var flushJob: Job? = null
val filterList = when {
useOffloadedFiltering -> filters.toList()
@@ -167,26 +153,55 @@ class BleScanner @Inject constructor(
setReportDelay(delay)
}.build()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
try {
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
}
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
try {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
scanner.flushPendingScanResults(callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
close(e)
break
}
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
}
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "startScan() denied: ${e.message}" }
forwarderConsumer?.cancel()
close(e)
return@callbackFlow
}
awaitClose {
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
try {
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
} catch (e: SecurityException) {
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
}
log(TAG) { "BleScanner stopped" }
}
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
import java.time.Instant
data class BluetoothDevice2(
internal val internal: BluetoothDevice,
val address: BluetoothAddress,
val name: String?,
val seenFirstAt: Instant,
) {
val address: BluetoothAddress
get() = internal.address
val name: String?
get() = internal.name
}
internal val internal: BluetoothDevice? = null,
)
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
}
.map { device ->
BluetoothDevice2(
address = device.address,
name = device.name,
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
val wrappedDevices = rawDevices.map { device ->
BluetoothDevice2(
address = device.address,
name = device.name,
internal = device,
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
@@ -318,6 +322,74 @@ class BluetoothManager2 @Inject constructor(
emit(wrappedDevices)
}
@android.annotation.SuppressLint("MissingPermission")
private fun queryBondedAddresses(): Set<BluetoothAddress> =
adapter?.bondedDevices?.map { it.address }?.toSet() ?: emptySet()
val bondedDeviceAddresses: Flow<Set<BluetoothAddress>> = callbackFlow {
fun sendBondedAddresses(): Boolean = try {
trySend(queryBondedAddresses())
true
} catch (e: Exception) {
log(TAG, WARN) { "Error querying bonded device addresses: $e" }
trySend(emptySet())
close(e)
false
}
if (!sendBondedAddresses()) return@callbackFlow
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return
sendBondedAddresses()
}
}
try {
context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to register bond-state receiver: $e" }
close(e)
return@callbackFlow
}
awaitClose {
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
log(TAG, WARN) { "Error unregistering bond-state receiver: $e" }
}
}
}
.retryWhen { cause, attempt ->
log(TAG, WARN) { "bondedDeviceAddresses Flow failed (attempt ${attempt + 1}): $cause" }
when {
cause is SecurityException -> {
delay(3_000L)
true
}
attempt < 3 -> {
delay(1000 * (attempt + 1))
true
}
else -> false
}
}
.catch { e ->
log(TAG, ERROR) { "bondedDeviceAddresses Flow failed after retries: $e" }
emit(emptySet())
}
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "bondedDeviceAddresses" }
.stateIn(
scope = appScope + dispatcherProvider.IO,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 5_000L,
replayExpirationMillis = 0L,
),
initialValue = emptySet(),
)
private var _isNudgeAvailable: Boolean = true
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
@@ -340,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
@android.annotation.SuppressLint("MissingPermission")
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
return try {
val target = device.internal ?: return false
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
.apply { isAccessible = true }
val result = method.invoke(device.internal, alias) as? Boolean ?: false
val result = method.invoke(target, alias) as? Boolean ?: false
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
result
} catch (e: Exception) {
@@ -356,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
try {
log(TAG) { "Nudging Android connection to $device" }
val target = device.internal ?: return@map false
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted
} catch (e: Exception) {
@@ -1,25 +1,11 @@
package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class ScannerMode(
val identifier: String,
@StringRes val labelRes: Int
) {
@SerialName("scanner.mode.lowpower") LOW_POWER(
"scanner.mode.lowpower",
R.string.settings_scanner_mode_lowpower_label
),
@SerialName("scanner.mode.balanced") BALANCED(
"scanner.mode.balanced",
R.string.settings_scanner_mode_balanced_label
),
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
"scanner.mode.lowlatency",
R.string.settings_scanner_mode_lowlatency_label
),
}
enum class ScannerMode {
@SerialName("scanner.mode.lowpower") LOW_POWER,
@SerialName("scanner.mode.balanced") BALANCED,
@SerialName("scanner.mode.lowlatency") LOW_LATENCY,
}
@@ -180,18 +180,21 @@ object MockPodDataProvider {
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed",
label = "My AirPods Pro",
ble = airPodsProMixed(),
aap = null,
)
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = null,
)
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap",
label = "My AirPods Pro",
ble = airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
@@ -227,12 +230,14 @@ object MockPodDataProvider {
fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single",
label = "AirPods Max",
ble = airPodsMax(),
aap = null,
)
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-single-aap",
label = "AirPods Max",
ble = airPodsMaxCharging(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.asLogSummary
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -15,13 +16,20 @@ object Bugs {
message: String,
exception: Throwable
) {
log(TAG, VERBOSE) { "Reporting $exception" }
log(tag, ERROR) { "$message\n${exception.asLog()}" }
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
reporter?.notify(exception) ?: run {
log(TAG, WARN) { "Bug tracking not initialized yet." }
val bugReporter = reporter
if (bugReporter == null) {
runCatching { log(TAG, WARN) { "Bug tracking not initialized yet." } }
return
}
runCatching { bugReporter.notify(exception) }
.onFailure { failure ->
runCatching { log(TAG, WARN) { "Bug reporter failed: ${failure.asLog()}" } }
}
}
private val TAG = logTag("Bugs")
}
}
@@ -61,16 +61,19 @@ object Logging {
message: String
) {
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
snapshot
.filter { it.isLoggable(priority) }
.forEach {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
snapshot.forEach {
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
if (isLoggable) {
runCatching {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
}
}
}
}
fun clearAll() {
@@ -110,14 +113,28 @@ inline fun log(
}
}
fun Throwable.asLog(): String {
fun Throwable.asLog(): String = runCatching {
val stringWriter = StringWriter(256)
val printWriter = PrintWriter(stringWriter, false)
printStackTrace(printWriter)
printWriter.flush()
return stringWriter.toString()
stringWriter.toString()
}.getOrElse { renderFailure ->
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
}
fun Throwable.asLogSummary(): String {
val throwableClass = javaClass.name
val throwableMessage = safeMessage()
return if (throwableMessage.isNullOrBlank()) {
throwableClass
} else {
"$throwableClass: $throwableMessage"
}
}
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
@PublishedApi
internal fun Any.logTagViaCallSite(): String {
val javaClass = this::class.java
@@ -1,11 +1,10 @@
package eu.darken.capod.common.permissions
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.R
import eu.darken.capod.common.withinApiLevel
@@ -18,7 +17,7 @@ enum class Permission(
val permissionId: String,
val isScanBlocking: Boolean = false,
val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
},
) {
BLUETOOTH(
@@ -87,4 +86,4 @@ enum class Permission(
fun Permission.isRequired(context: Context): Boolean = when {
!withinApiLevel(minApiLevel, maxApiLevel) -> false
else -> !isGranted(context)
}
}
@@ -9,7 +9,6 @@ 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.ScannerMode
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.serialization.SerializationCapod
@@ -44,8 +43,6 @@ class GeneralSettings @Inject constructor(
val keepConnectedNotificationAfterDisconnect =
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false)
val scannerMode = dataStore.createValue("core.scanner.mode", ScannerMode.BALANCED, json, onErrorFallbackToDefault = true)
val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
@@ -79,6 +76,8 @@ class GeneralSettings @Inject constructor(
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.navigation.LocalNavigationController
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationController
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var upgradeRepo: UpgradeRepo
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
private val warmIntents = SingleEventFlow<Intent>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
navCtrl.setup(backStack)
LaunchedEffect(Unit) {
// Cold-start intent (the activity's launching intent).
consumeUpgradeExtra(intent)
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
// directly from onNewIntent) guarantees navCtrl.setup() has run.
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
}
CapodTheme(state = themeState) {
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
consumeUpgradeExtra(intent)
setIntent(intent)
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
warmIntents.tryEmit(intent)
}
private fun consumeUpgradeExtra(intent: Intent?) {
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.components
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
}
@DrawableRes
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
}
@@ -386,7 +386,7 @@ fun DeviceSettingsScreen(
val showSoundSection =
(features.hasPersonalizedVolume && personalizedVol != null) ||
(features.hasToneVolume && toneVol != null) ||
features.hasMicrophoneMode
(features.hasMicrophoneMode && device.microphoneMode != null)
if (showSoundSection) {
item("sound_section") {
SoundCard(
@@ -404,7 +404,7 @@ fun DeviceSettingsScreen(
}
// ── Controls ─────────────────────────────────
val showControlsSection = features.hasStemConfig ||
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
(features.hasPressSpeed && device.pressSpeed != null) ||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
@@ -439,6 +439,11 @@ class DeviceSettingsViewModel @Inject constructor(
fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" }
val profileId = targetProfileId.value ?: return@launch
val device = deviceMonitor.getDeviceForProfile(profileId)
if (device?.isAapReady != true) {
log(TAG, INFO) { "navToPressControls(): aborted, device not AAP-ready" }
return@launch
}
navTo(Nav.Main.PressControls(profileId = profileId))
}
@@ -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) {
@@ -133,7 +133,10 @@ internal fun NoiseControlCard(
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
type = InfoBoxType.INFO,
action = {
TextButton(onClick = onPressControlsClick) {
TextButton(
onClick = onPressControlsClick,
enabled = enabled,
) {
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
}
},
@@ -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)
@@ -59,6 +59,7 @@ import eu.darken.capod.main.ui.overview.cards.DualPodsCard
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
@@ -67,6 +68,22 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
internal fun profiledDeviceKey(
device: PodDevice,
index: Int,
duplicateProfileIds: Set<String>,
): String {
val pid = requireNotNull(device.profileId)
return if (pid !in duplicateProfileIds) {
"profiled:$pid"
} else {
"profiled:$pid:${device.identifier ?: "idx:$index"}"
}
}
internal fun unmatchedDeviceKey(device: PodDevice, index: Int): String =
"unmatched:${device.identifier ?: "idx:$index"}"
@Composable
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
ErrorEventHandler(vm)
@@ -158,7 +175,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
onDeviceSettings = { device ->
if (currentState.showReactionsHint) vm.dismissReactionsHint()
vm.goToDeviceSettings(device)
},
onEditProfile = { device -> vm.goToEditProfile(device) },
onToggleDeviceExpansion = { device ->
device.profileId?.let { vm.toggleDeviceExpansion(it) }
@@ -286,9 +306,21 @@ fun OverviewScreen(
// 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) {
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
item(key = "reactions_hint") {
ReactionsMovedHintCard()
}
}
val duplicateProfileIds = state.visibleProfiledDevices
.mapNotNull { it.profileId }
.groupingBy { it }
.eachCount()
.filterValues { it > 1 }
.keys
itemsIndexed(
items = state.visibleProfiledDevices,
key = { _, device -> requireNotNull(device.profileId) },
key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
) { index, device ->
val isCollapsed = !state.isExpanded(device, index)
val isToggleable = state.isToggleable(device, index)
@@ -337,10 +369,10 @@ fun OverviewScreen(
}
if (state.showUnmatchedDevices) {
items(
itemsIndexed(
items = state.unmatchedDevices,
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
) { device ->
key = { index, device -> unmatchedDeviceKey(device, index) },
) { _, device ->
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
@@ -414,6 +446,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
),
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
showReactionsHint = true,
),
onRequestPermission = {},
onBluetoothSettings = {},
@@ -4,6 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
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.logging.Logging.Priority.INFO
@@ -22,6 +23,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -130,7 +132,9 @@ class OverviewViewModel @Inject constructor(
upgradeRepo.upgradeInfo,
showUnmatchedDevices,
userExpansionOverrides,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds ->
generalSettings.reactionsHintDismissed.flow,
profilesRepo.hadLegacyReactionData,
) { _, permissions, devices, isDebugMode, 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()
@@ -145,6 +149,7 @@ class OverviewViewModel @Inject constructor(
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
)
}.asLiveState()
@@ -160,6 +165,7 @@ class OverviewViewModel @Inject constructor(
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false,
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
@@ -170,7 +176,7 @@ class OverviewViewModel @Inject constructor(
val profiledDevices: List<PodDevice> by lazy {
devices.filter { it.profileId != null }.sortedWith(
compareBy<PodDevice> { deviceTierRank(it) }
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
}
@@ -252,6 +258,13 @@ class OverviewViewModel @Inject constructor(
}
}
fun dismissReactionsHint() {
log(TAG, INFO) { "dismissReactionsHint()" }
launch {
generalSettings.reactionsHintDismissed.value(true)
}
}
fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(permission)
@@ -272,12 +285,5 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
/** Connection tier rank for sorting: lower = higher priority. */
internal fun deviceTierRank(device: PodDevice): Int = when {
device.isSystemConnected -> 0
device.isLive -> 1
else -> 2
}
}
}
@@ -29,8 +29,8 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -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
@@ -168,7 +166,7 @@ fun DualPodsCard(
}
if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
OutlinedIconButton(onClick = onDeviceSettings) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -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),
@@ -0,0 +1,89 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
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
@Composable
fun ReactionsMovedHintCard(
modifier: Modifier = Modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(12.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(10.dp))
Text(
text = stringResource(R.string.overview_reactions_hint_title),
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = stringResource(R.string.overview_reactions_hint_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 18.sp,
)
}
}
}
}
@Preview2
@Composable
private fun ReactionsMovedHintCardPreview() = PreviewWrapper {
ReactionsMovedHintCard()
}
@@ -28,8 +28,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -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)
@@ -150,7 +152,7 @@ fun SinglePodsCard(
}
if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
OutlinedIconButton(onClick = onDeviceSettings) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -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),
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return
val isAapConnected = currentState.device?.isAapConnected == true
var hasSeenAapConnected by rememberSaveable(profileId) { mutableStateOf(false) }
var didAutoNavigate by rememberSaveable(profileId) { mutableStateOf(false) }
LaunchedEffect(profileId, isAapConnected) {
if (isAapConnected) {
hasSeenAapConnected = true
} else if (hasSeenAapConnected && !didAutoNavigate) {
didAutoNavigate = true
vm.navUp()
}
}
PressControlsScreen(
state = currentState,
snackbarHostState = snackbarHostState,
@@ -19,7 +19,6 @@ 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.twotone.SettingsBluetooth
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
@@ -45,7 +44,6 @@ 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 eu.darken.capod.common.bluetooth.ScannerMode
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
@@ -70,7 +68,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
state = it,
onNavigateUp = { vm.navUp() },
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
onDebugSettings = { vm.goToDebugSettings() },
@@ -90,7 +87,6 @@ fun GeneralSettingsScreen(
state: GeneralSettingsViewModel.State,
onNavigateUp: () -> Unit,
onMonitorModeSelected: (MonitorMode) -> Unit,
onScannerModeSelected: (ScannerMode) -> Unit,
onShowConnectedNotificationChanged: (Boolean) -> Unit,
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
onDebugSettings: () -> Unit,
@@ -104,7 +100,6 @@ fun GeneralSettingsScreen(
modifier: Modifier = Modifier,
) {
var showMonitorModeDialog by remember { mutableStateOf(false) }
var showScannerModeDialog by remember { mutableStateOf(false) }
var showColorDialog by remember { mutableStateOf(false) }
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
@@ -138,14 +133,6 @@ fun GeneralSettingsScreen(
onClick = { showMonitorModeDialog = true },
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_scanner_mode_label),
subtitle = stringResource(state.scannerMode.labelRes),
icon = Icons.TwoTone.SettingsBluetooth,
onClick = { showScannerModeDialog = true },
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
}
@@ -313,20 +300,6 @@ fun GeneralSettingsScreen(
)
}
if (showScannerModeDialog) {
ListPreferenceDialog(
title = stringResource(R.string.settings_scanner_mode_label),
entries = ScannerMode.entries,
selectedEntry = state.scannerMode,
onEntrySelected = {
onScannerModeSelected(it)
showScannerModeDialog = false
},
entryLabel = { stringResource(it.labelRes) },
onDismiss = { showScannerModeDialog = false },
)
}
if (showColorDialog) {
ThemeColorSelectorDialog(
selectedColor = state.themeState.color,
@@ -342,7 +315,6 @@ fun GeneralSettingsScreen(
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
isPro = isPro,
monitorMode = MonitorMode.AUTOMATIC,
scannerMode = ScannerMode.BALANCED,
showConnectedNotification = true,
keepNotificationAfterDisconnect = false,
isOffloadedFilteringDisabled = false,
@@ -358,7 +330,6 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
state = previewGeneralState(isPro = true),
onNavigateUp = {},
onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
@@ -375,7 +346,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
state = previewGeneralState(isPro = false),
onNavigateUp = {},
onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {},
@@ -1,7 +1,6 @@
package eu.darken.capod.main.ui.settings.general
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
@@ -32,7 +31,6 @@ class GeneralSettingsViewModel @Inject constructor(
data class State(
val isPro: Boolean,
val monitorMode: MonitorMode,
val scannerMode: ScannerMode,
val showConnectedNotification: Boolean,
val keepNotificationAfterDisconnect: Boolean,
val isOffloadedFilteringDisabled: Boolean,
@@ -46,12 +44,11 @@ class GeneralSettingsViewModel @Inject constructor(
val state = combine(
combine(
generalSettings.monitorMode.flow,
generalSettings.scannerMode.flow,
generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { monitorMode, scannerMode, showNotif, keepNotif ->
) { monitorMode, showNotif, keepNotif ->
@Suppress("USELESS_CAST")
arrayOf<Any>(monitorMode as Any, scannerMode as Any, showNotif as Any, keepNotif as Any)
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
},
combine(
generalSettings.isOffloadedFilteringDisabled.flow,
@@ -67,9 +64,8 @@ class GeneralSettingsViewModel @Inject constructor(
State(
isPro = isPro,
monitorMode = general[0] as MonitorMode,
scannerMode = general[1] as ScannerMode,
showConnectedNotification = general[2] as Boolean,
keepNotificationAfterDisconnect = general[3] as Boolean,
showConnectedNotification = general[1] as Boolean,
keepNotificationAfterDisconnect = general[2] as Boolean,
isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
@@ -82,11 +78,6 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.monitorMode.valueBlocking = mode
}
fun setScannerMode(mode: ScannerMode) {
log(TAG, INFO) { "setScannerMode($mode)" }
generalSettings.scannerMode.valueBlocking = mode
}
fun setShowConnectedNotification(enabled: Boolean) {
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
@@ -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)
}
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class AncGlanceWidget : GlanceAppWidget() {
@@ -43,21 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: AncWidgetEntryPoint
val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try {
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent {
@@ -77,25 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
}
provideContent {
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
val widthDp = LocalSize.current.width
val heightDp = LocalSize.current.height
val state = try {
val config = ep.widgetSettings().getWidgetConfig(appWidgetId)
val profileId = config.profileId
val theme = config.theme
// Glance keeps the content session alive and does not restart provideGlance()
// for every update(). Observe a widget-key-deduped device flow so visible state
// changes update active sessions without recomposing on every BLE advertisement.
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro
val liveDevice = devices.firstOrNull { it.profileId == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
val profileLabel = profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label
val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
.getOrDefault(false)
val initialDevice = remember(config.profileId) {
config.profileId?.let { pid ->
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
.getOrNull()
}
}
val device by config.profileId
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
?.collectAsState(initial = initialDevice)
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
val profileLabel = config.profileId?.let { pid ->
runCatching {
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
}
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
.getOrNull()
}
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
val widthCells = getCellsForSize(widthDp.value.toInt())
val heightCells = getCellsForSize(heightDp.value.toInt())
@@ -111,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
AncWidgetRenderStateMapper.map(
context = context,
device = device,
theme = theme,
theme = config.theme,
isPro = isPro,
hasConfiguredProfile = profileId != null,
hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel,
layout = layout,
)
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
} else {
AncWidgetRenderState.Message(
theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.updateAll
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
fun aapConnectionManager(): AapConnectionManager
fun widgetSettings(): WidgetSettings
fun deviceMonitor(): DeviceMonitor
fun widgetManager(): WidgetManager
}
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
val device = ep.deviceMonitor().getDeviceForProfile(profileId)
if (device == null) {
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
AncGlanceWidget().updateAll(context)
ep.widgetManager().refreshWidgets()
return
}
val address = device.address
if (address == null) {
log(TAG, ERROR) { "onAction: device has no address" }
AncGlanceWidget().updateAll(context)
ep.widgetManager().refreshWidgets()
return
}
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
}
AncGlanceWidget().updateAll(context)
ep.widgetManager().refreshWidgets()
}
companion object {
@@ -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
}
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class BatteryGlanceWidget : GlanceAppWidget() {
@@ -43,21 +45,15 @@ class BatteryGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: WidgetEntryPoint
val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try {
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent {
@@ -78,38 +74,51 @@ class BatteryGlanceWidget : GlanceAppWidget() {
}
provideContent {
// Composable reads — must be outside try-catch
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
val widthDp = LocalSize.current.width
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
val state = try {
val config = ep.widgetSettings().getWidgetConfig(appWidgetId)
val profileId = config.profileId
val theme = config.theme
// Glance keeps the content session alive and does not restart provideGlance()
// for every update(). Observe a widget-key-deduped device flow so visible state
// changes update active sessions without recomposing on every BLE advertisement.
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro
val liveDevice = devices.firstOrNull { it.profileId == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
val profileLabel = profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label
val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
.getOrDefault(false)
val initialDevice = remember(config.profileId) {
config.profileId?.let { pid ->
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
.getOrNull()
}
}
val device by config.profileId
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
?.collectAsState(initial = initialDevice)
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
val profileLabel = config.profileId?.let { pid ->
runCatching {
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
}
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
.getOrNull()
}
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
WidgetRenderStateMapper.map(
context = context,
device = device,
theme = theme,
theme = config.theme,
isPro = isPro,
hasConfiguredProfile = profileId != null,
hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel,
layout = layout,
)
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
} else {
WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
@@ -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 ""
@@ -0,0 +1,11 @@
package eu.darken.capod.main.ui.widget
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
internal fun DeviceMonitor.widgetDeviceFlow(profileId: String): Flow<PodDevice?> = devices
.map { devices -> devices.firstOrNull { it.profileId == profileId } }
.distinctUntilChangedBy { it?.toWidgetKey() }
@@ -1,31 +1,41 @@
package eu.darken.capod.main.ui.widget
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
/**
* Snapshot of the [PodDevice] fields that affect widget rendering. Used to
* gate widget refreshes so RSSI / reliability / scan-counter churn doesn't
* cause a refresh on every BLE advertisement.
* gate widget refreshes so RSSI / reliability / scan-counter / timestamp churn
* doesn't cause a refresh on every BLE advertisement.
*/
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?,
val isHeadsetBeingCharged: Boolean?,
val isLeftInEar: Boolean?,
val isRightInEar: Boolean?,
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>,
)
internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
profileId = profileId,
profileLabel = label,
model = model,
batteryLeft = batteryLeft,
batteryRight = batteryRight,
@@ -35,6 +45,13 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
isRightPodCharging = isRightPodCharging,
isCaseCharging = isCaseCharging,
isHeadsetBeingCharged = isHeadsetBeingCharged,
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
hasBleAdvertisement = ble != null,
ancMode = ancMode?.current,
pendingAncMode = pendingAncMode,
visibleAncModes = visibleAncModes,
)
@@ -1,8 +1,18 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import dagger.hilt.android.qualifiers.ApplicationContext
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.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
import javax.inject.Singleton
@@ -13,7 +23,66 @@ class WidgetManager @Inject constructor(
) {
suspend fun refreshWidgets() {
BatteryGlanceWidget().updateAll(context)
AncGlanceWidget().updateAll(context)
val manager = GlanceAppWidgetManager(context)
refreshWidget(
name = "battery",
widget = BatteryGlanceWidget(),
providerClass = BatteryGlanceWidget::class.java,
receiverClass = WidgetProvider::class.java,
manager = manager,
)
refreshWidget(
name = "anc",
widget = AncGlanceWidget(),
providerClass = AncGlanceWidget::class.java,
receiverClass = AncWidgetProvider::class.java,
manager = manager,
)
}
private suspend fun <T : GlanceAppWidget> refreshWidget(
name: String,
widget: T,
providerClass: Class<T>,
receiverClass: Class<*>,
manager: GlanceAppWidgetManager,
) {
val ids = manager.getGlanceIds(providerClass).ifEmpty {
val appWidgetIds = AppWidgetManager.getInstance(context)
.getAppWidgetIds(ComponentName(context, receiverClass))
.toList()
if (appWidgetIds.isNotEmpty()) {
log(TAG, WARN) { "refresh($name): Glance IDs empty, falling back to platform ids=$appWidgetIds" }
}
appWidgetIds.mapNotNull { appWidgetId ->
runCatching { manager.getGlanceIdBy(appWidgetId) }
.onFailure { error ->
log(TAG, ERROR) { "refresh($name): failed to resolve widgetId=$appWidgetId: ${error.asLog()}" }
}
.getOrNull()
}
}
log(TAG, VERBOSE) { "refresh($name): ids=${ids.toAppWidgetIds(manager)}" }
ids.forEach { id ->
runCatching { widget.update(context, id) }
.onFailure { error ->
log(TAG, ERROR) { "refresh($name): update failed for id=${id.toAppWidgetId(manager)}: ${error.asLog()}" }
}
}
}
private fun List<GlanceId>.toAppWidgetIds(manager: GlanceAppWidgetManager): List<Int> =
map { it.toAppWidgetId(manager) }
private fun GlanceId.toAppWidgetId(manager: GlanceAppWidgetManager): Int = runCatching {
manager.getAppWidgetId(this)
}.getOrElse {
AppWidgetManager.INVALID_APPWIDGET_ID
}
companion object {
val TAG = logTag("Widget", "Manager")
}
}
@@ -7,7 +7,6 @@ import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
object WidgetRenderStateMapper {
@@ -43,15 +42,15 @@ object WidgetRenderStateMapper {
layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeft.toBatteryFloat(),
leftPercent = device.batteryLeft,
leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true,
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRight.toBatteryFloat(),
rightPercent = device.batteryRight,
rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true,
caseIcon = device.caseIcon,
casePercent = device.batteryCase.toBatteryFloat(),
casePercent = device.batteryCase,
caseCharging = device.isCaseCharging == true,
)
@@ -63,7 +62,7 @@ object WidgetRenderStateMapper {
layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes,
percent = device.batteryHeadset.toBatteryFloat(),
percent = device.batteryHeadset,
batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == true,
@@ -4,7 +4,10 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
@@ -20,6 +23,7 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.toReactionConfig
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
connectedAddresses = connectedAddresses,
)
}.onEach { liveState ->
persistLiveDevices(liveState.liveDevices)
persistLiveDevices(liveState.liveDevices + aapOnlyForPersistence(liveState))
}
/**
* AAP-only profiles whose state didn't make it into [LiveMergeState.liveDevices] (no BLE pod
* in the current scan). Without this, [persistLiveDevices] would only ever see BLE-backed
* devices, and AAP-delivered DeviceInfo (earbud serials, marketing version) for an out-of-BLE
* pod would never reach the cache even though the AAP socket is alive.
*/
private fun aapOnlyForPersistence(state: LiveMergeState): List<PodDevice> {
val coveredProfileIds = state.liveDevices.mapNotNull { it.profileId }.toSet()
return state.profiles.mapNotNull { profile ->
if (profile.id in coveredProfileIds) return@mapNotNull null
val aap = state.aapStates.forProfile(profile) ?: return@mapNotNull null
PodDevice(
profileId = profile.id,
label = profile.label,
ble = null,
aap = aap,
profileAddress = profile.address,
profileModel = profile.model,
profileKeyState = profile.toBleKeyState(),
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
isSystemConnected = profile.address in state.connectedAddresses,
)
}
}
val devices: Flow<List<PodDevice>> = combine(
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
dedupedLiveDevices + nonLiveDevices
}.replayingShare(appScope)
private val reportedPersistFailures = mutableSetOf<String>()
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
for (device in devices) {
val profileId = device.profileId ?: continue
val existing = deviceStateCache.cachedStates.value[profileId]
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
try {
val existing = deviceStateCache.cachedStates.value[profileId]
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
log(TAG, VERBOSE) { "Persisting state for $profileId" }
deviceStateCache.save(profileId, newState)
log(TAG, VERBOSE) { "Persisting state for $profileId" }
deviceStateCache.save(profileId, newState)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to persist state for $profileId: ${e.asLog()}" }
if (reportedPersistFailures.add(profileId)) {
runCatching { Bugs.report(tag = TAG, message = "persistLiveDevices failed for $profileId", exception = e) }
}
}
}
}
@@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
@@ -138,18 +139,24 @@ data class PodDevice(
}
}
// Battery — AAP preferred, BLE fallback, then cached
val batteryLeft: Float?
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
// for unknown to keep the type primitive; this is the boundary that previously emitted
// Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
val batteryLeft: Float
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
?: cached?.left?.percent ?: BATTERY_UNKNOWN
val batteryRight: Float?
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
val batteryRight: Float
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
?: cached?.right?.percent ?: BATTERY_UNKNOWN
val batteryCase: Float?
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
val batteryCase: Float
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
?: cached?.case?.percent ?: BATTERY_UNKNOWN
val batteryHeadset: Float?
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
val batteryHeadset: Float
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
/** True when at least one displayed battery value was filled from cache (not live). */
val isBatteryCached: Boolean
@@ -205,18 +212,27 @@ data class PodDevice(
}
} ?: (ble as? DualApplePods)?.primaryPod
private fun AapSetting.EarDetection.PodPlacement.isInEar(): Boolean =
this == AapSetting.EarDetection.PodPlacement.IN_EAR
private fun AapSetting.EarDetection.samePlacementInEarOrNull(): Boolean? =
if (primaryPod == secondaryPod) primaryPod.isInEar() else null
// Ear detection — AAP preferred (lower latency), BLE fallback.
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
val isLeftInEar: Boolean?
get() {
val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
} else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
if (earDetection != null) {
if (primary != null) {
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
earDetection.primaryPod.isInEar()
} else {
earDetection.secondaryPod.isInEar()
}
}
earDetection.samePlacementInEarOrNull()?.let { return it }
}
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
}
@@ -225,12 +241,15 @@ data class PodDevice(
get() {
val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
} else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
if (earDetection != null) {
if (primary != null) {
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
earDetection.primaryPod.isInEar()
} else {
earDetection.secondaryPod.isInEar()
}
}
earDetection.samePlacementInEarOrNull()?.let { return it }
}
return (ble as? HasEarDetectionDual)?.isRightPodInEar
}
@@ -0,0 +1,26 @@
package eu.darken.capod.monitor.core
/**
* Connection tier rank used to sort devices by user-perceived "primary":
* lower rank = higher priority. System-connected devices come first, then
* any live device (BLE or AAP), then profiled-but-offline.
*
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
* non-tier-ranked for reaction flows that want "any profiled device".
*/
fun PodDevice.tierRank(): Int = when {
isSystemConnected -> 0
isLive -> 1
else -> 2
}
/**
* Picks the user-perceived primary profiled device: lowest [tierRank], with
* the user's profile-list order as the tiebreaker.
*/
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
filter { it.profileId != null }
.minWithOrNull(
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
log(TAG) { "AAP connecting to $address (${profile.label})" }
try {
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break
} catch (retryException: Exception) {
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
aapManager.connect(address, bonded.internal!!, profile.model)
log(TAG) { "AAP reconnected to $address" }
break
} catch (e: Exception) {
@@ -218,7 +211,7 @@ class AapAutoConnect @Inject constructor(
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded != null) {
aapManager.connect(address, bonded.internal, detectedModel)
aapManager.connect(address, bonded.internal!!, detectedModel)
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
}
} catch (e: Exception) {
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
companion object {
private val TAG = logTag("Monitor", "AapAutoConnect")
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
private val CONNECT_TIMEOUT = 5.seconds
}
}
@@ -27,6 +27,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@@ -45,12 +47,13 @@ import javax.inject.Singleton
class BlePodMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val bleScanner: BleScanner,
private val bleScanModeController: BleScanModeController,
private val podFactory: PodFactory,
private val timeSource: TimeSource,
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
permissionTool: PermissionTool,
private val permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
@@ -88,8 +91,10 @@ class BlePodMonitor @Inject constructor(
log(
TAG,
Logging.Priority.WARN
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
false
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
permissionTool.recheck()
delay(3000)
true
} else {
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
@@ -128,7 +133,7 @@ class BlePodMonitor @Inject constructor(
)
private fun createBleScanner() = combine(
generalSettings.scannerMode.flow,
bleScanModeController.scannerMode,
debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
@@ -159,13 +164,27 @@ class BlePodMonitor @Inject constructor(
else -> ProximityPairing.getBleScanFilter()
}
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
flow {
emitAll(
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
)
}.catch { cause ->
if (cause is SecurityException) {
log(TAG, Logging.Priority.WARN) {
"BLE scanner failed due to missing permission, rechecking permissions: ${cause.asLog()}"
}
permissionTool.recheck()
emit(emptyList())
} else {
throw cause
}
}
}
.map { it.onlyNewAndUnique() }
@@ -0,0 +1,114 @@
package eu.darken.capod.monitor.core.ble
import eu.darken.capod.common.AppForegroundState
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BleScanModeController @Inject constructor(
@AppScope appScope: CoroutineScope,
appForegroundState: AppForegroundState,
profilesRepo: DeviceProfilesRepo,
bluetoothManager: BluetoothManager2,
) {
private val overrideCounts = MutableStateFlow<Map<ScannerMode, Int>>(emptyMap())
private val activeOverride: Flow<ScannerMode?> = overrideCounts.map { it.topPriority() }
val scannerMode: Flow<ScannerMode> = combine(
activeOverride,
appForegroundState.isForeground,
profilesRepo.profiles,
bluetoothManager.connectedDevices
.onStart { emit(emptyList()) }
.catch {
log(TAG, Logging.Priority.WARN) { "connectedDevices failed, treating as empty: $it" }
emit(emptyList())
},
bluetoothManager.bondedDeviceAddresses,
) { override, isForeground, profiles, connectedDevices, bondedAddresses ->
resolveScannerMode(
overrideMode = override,
isForeground = isForeground,
profileAddresses = profiles.mapNotNull { it.address }.toSet(),
bondedAddresses = bondedAddresses,
connectedAddresses = connectedDevices.map { it.address }.toSet(),
)
}
.distinctUntilChanged()
.onEach { log(TAG) { "Effective scanner mode: $it" } }
.setupCommonEventHandlers(TAG) { "scannerMode" }
.replayingShare(appScope)
suspend fun <T> withTemporaryOverride(mode: ScannerMode, block: suspend () -> T): T {
log(TAG) { "withTemporaryOverride($mode) acquire" }
overrideCounts.update { it.adjust(mode, +1) }
try {
return block()
} finally {
overrideCounts.update { it.adjust(mode, -1) }
log(TAG) { "withTemporaryOverride($mode) release" }
}
}
private fun Map<ScannerMode, Int>.adjust(mode: ScannerMode, delta: Int): Map<ScannerMode, Int> {
val current = this[mode] ?: 0
val next = current + delta
return when {
next <= 0 -> this - mode
else -> this + (mode to next)
}
}
private fun Map<ScannerMode, Int>.topPriority(): ScannerMode? = OVERRIDE_PRIORITY.firstOrNull { containsKey(it) }
companion object {
private val TAG = logTag("Bluetooth", "ScannerMode")
private val OVERRIDE_PRIORITY = listOf(ScannerMode.LOW_LATENCY, ScannerMode.BALANCED, ScannerMode.LOW_POWER)
}
}
internal fun resolveScannerMode(
overrideMode: ScannerMode?,
isForeground: Boolean,
profileAddresses: Set<BluetoothAddress>,
bondedAddresses: Set<BluetoothAddress>,
connectedAddresses: Set<BluetoothAddress>,
): ScannerMode {
if (overrideMode != null) return overrideMode
val connectedProfileAddresses = profileAddresses.normalized()
.intersect(bondedAddresses.normalized())
.intersect(connectedAddresses.normalized())
return when {
connectedProfileAddresses.isNotEmpty() -> ScannerMode.LOW_LATENCY
isForeground -> ScannerMode.BALANCED
else -> ScannerMode.LOW_POWER
}
}
private fun Iterable<BluetoothAddress>.normalized(): Set<BluetoothAddress> = this
.map { it.uppercase(Locale.US) }
.toSet()
@@ -36,7 +36,9 @@ data class CachedDeviceState(
) {
val deviceInfo: AapDeviceInfo?
get() {
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
if (deviceName == null && serialNumber == null && firmwareVersion == null
&& leftEarbudSerial == null && rightEarbudSerial == null && marketingVersion == null
) return null
return AapDeviceInfo(
name = deviceName ?: "",
modelNumber = "",
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import java.time.Duration
import java.time.Instant
@@ -14,7 +16,7 @@ import java.time.Instant
* Returns null if:
* - The device is not live (cached-only)
* - The device has no profile
* - All live battery values are null
* - All live battery values AND live DeviceInfo are null (nothing fresh to persist)
* - The state hasn't changed from [existing] (dedup)
*/
fun PodDevice.toCachedState(
@@ -24,15 +26,18 @@ fun PodDevice.toCachedState(
if (!isLive) return null
val pid = profileId ?: return null
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
val liveDeviceInfo = aap?.deviceInfo
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
) return null
val newState = CachedDeviceState(
profileId = pid,
model = model,
@@ -60,40 +65,37 @@ fun PodDevice.toCachedState(
}
private fun mergeBatterySlot(
livePercent: Float?,
livePercent: Float,
existing: CachedBatterySlot?,
now: Instant,
): CachedBatterySlot? {
if (livePercent == null) return existing
if (existing == null) return CachedBatterySlot(livePercent, now)
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
if (!isKnownBattery(livePercent)) return existing
val current = existing ?: return CachedBatterySlot(livePercent, now)
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
}
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
if (hasSlotTimestampChanged(old.left, new.left)) return true
if (hasSlotTimestampChanged(old.right, new.right)) return true
if (hasSlotTimestampChanged(old.case, new.case)) return true
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
return old.left?.percent != new.left?.percent
|| old.right?.percent != new.right?.percent
|| old.case?.percent != new.case?.percent
|| old.headset?.percent != new.headset?.percent
|| old.isLeftCharging != new.isLeftCharging
if (hasSlotChanged(old.left, new.left)) return true
if (hasSlotChanged(old.right, new.right)) return true
if (hasSlotChanged(old.case, new.case)) return true
if (hasSlotChanged(old.headset, new.headset)) return true
return old.isLeftCharging != new.isLeftCharging
|| old.isRightCharging != new.isRightCharging
|| old.isCaseCharging != new.isCaseCharging
|| old.isHeadsetCharging != new.isHeadsetCharging
|| old.deviceName != new.deviceName
|| old.serialNumber != new.serialNumber
|| old.firmwareVersion != new.firmwareVersion
|| old.leftEarbudSerial != new.leftEarbudSerial
|| old.rightEarbudSerial != new.rightEarbudSerial
|| old.marketingVersion != new.marketingVersion
}
private fun hasSlotTimestampChanged(
old: CachedBatterySlot?,
new: CachedBatterySlot?,
): Boolean {
if (old == null || new == null) return false
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
if (old == null && new == null) return false
if (old == null || new == null) return true
if (old.percent != new.percent) return true
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
}
@@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
import javax.inject.Inject
import kotlin.math.roundToInt
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.device, device.getLabel(context))
}
private fun percentToInt(percent: Float?): Int {
if (percent == null) return 0
return (percent * 100).roundToInt().coerceIn(0, 100)
}
private fun percentToInt(percent: Float): Int =
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
}
@@ -22,7 +22,9 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -37,7 +39,9 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -52,10 +56,13 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
@@ -74,6 +81,7 @@ enum class PodModel(
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasSleepDetection = true,
@@ -314,7 +322,6 @@ enum class PodModel(
"Beats Solo Pro",
R.drawable.device_beats_headphones,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1881"), // headphones
@@ -343,7 +350,6 @@ enum class PodModel(
"Beats Studio 3",
R.drawable.device_beats_studio3,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1914"), // headphones
@@ -410,6 +416,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
@@ -426,6 +433,7 @@ enum class PodModel(
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
@@ -444,6 +452,8 @@ enum class PodModel(
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_beats_fitpro_left,
@@ -458,6 +468,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -471,6 +482,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -484,6 +496,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -16,6 +16,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,7 +27,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Manages a single AAP L2CAP connection to a device.
@@ -39,6 +44,7 @@ internal class AapConnection(
private val profile: AapDeviceProfile,
private val socketFactory: L2capSocketFactory,
timeSource: TimeSource,
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
) {
private val engine = AapSessionEngine(profile, timeSource)
@@ -68,7 +74,7 @@ internal class AapConnection(
try {
val sock = socketFactory.createSocket(device, PSM)
sock.connect()
sock.connectCancellable()
socket = sock
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
@@ -212,15 +218,47 @@ internal class AapConnection(
}
private fun cleanupSocket() {
socket?.closeQuietly()
socket = null
}
private suspend fun BluetoothSocket.connectCancellable() {
val result = CompletableDeferred<Result<Unit>>()
val cancelled = AtomicBoolean(false)
val connectThread = Thread(
{
val outcome = runCatching { connect() }
result.complete(outcome)
if (cancelled.get() && outcome.isSuccess) closeQuietly()
},
"AAP-L2CAP-connect-${device.address}",
).apply {
isDaemon = true
start()
}
try {
socket?.close()
withTimeout(connectTimeout) {
result.await().getOrThrow()
}
} catch (e: Exception) {
cancelled.set(true)
closeQuietly()
connectThread.interrupt()
throw e
}
}
private fun BluetoothSocket.closeQuietly() {
try {
close()
} catch (_: Exception) {
}
socket = null
}
companion object {
private const val PSM = 0x1001
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
private val TAG = logTag("AAP", "Connection")
}
}
}
@@ -21,17 +21,18 @@ import kotlin.math.roundToInt
const val BATTERY_UNKNOWN = -1f
fun Float?.toBatteryFloat(): Float = this ?: BATTERY_UNKNOWN
fun isKnownBattery(percent: Float): Boolean = percent.isFinite() && percent >= 0f
fun Float.toBatteryOrNull(): Float? = takeIf { it >= 0f }
fun batteryProgress(percent: Float): Float =
if (isKnownBattery(percent)) percent.coerceIn(0f, 1f) else 0f
fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun formatBatteryPercent(context: Context, percent: Float): String =
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
else context.getString(R.string.general_value_not_available_label)
@DrawableRes
fun getBatteryDrawable(percent: Float?): Int = when {
percent == null -> R.drawable.ic_baseline_battery_unknown_24
fun getBatteryDrawable(percent: Float): Int = when {
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
@@ -42,8 +43,8 @@ fun getBatteryDrawable(percent: Float?): Int = when {
else -> R.drawable.ic_baseline_battery_0_bar_24
}
fun getBatteryIcon(percent: Float?): ImageVector = when {
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
fun getBatteryIcon(percent: Float): ImageVector = when {
!isKnownBattery(percent) -> Icons.AutoMirrored.TwoTone.BatteryUnknown
percent > 0.95f -> Icons.TwoTone.BatteryFull
percent > 0.80f -> Icons.TwoTone.Battery6Bar
percent > 0.65f -> Icons.TwoTone.Battery5Bar
@@ -44,8 +44,18 @@ data class AirPodsMax2(
return pubFlags.isBitSet(0)
}
// Aggregate "any wear sensor active" — NOT "both earcups worn".
// Observed on A3454: bit 5 of pubStatus is always set while advertising and
// no longer carries the wear flag (unlike Max gen 1). Bits 1 and 3 of
// pubStatus reflect the two earcup sensors (same byte positions used by
// DualApplePods, but without the primary/flip semantics).
//
// We OR them because some Android pairings only see one of the two bits
// reliably (issue #548: "both worn" advertises as 0x23 — bit 1 only —
// while macOS sees 0x2B with both bits set). AND-ing would falsely report
// "not worn" during normal use on those phones.
override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5)
get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
@@ -65,6 +65,30 @@ class DeviceProfilesRepo @Inject constructor(
if (!settings.reactionMigrationDone.valueBlocking) {
migrateLegacyReactions()
}
detectLegacyReactionData()
}
}
/**
* Detects whether this install ever wrote to the pre-migration global reaction DataStore.
* The legacy reader is a read-only probe; the legacy keys persist on old installs even
* after [migrateLegacyReactions] has run, so this check works retroactively for users who
* already migrated. Used by the Overview hint to target only existing users who actually
* configured reactions before the per-device move.
*/
private suspend fun detectLegacyReactionData() {
val hadData = try {
val legacy = LegacyReactionSettingsReader(context, json).read()
legacy.autoPause || legacy.autoPlay || legacy.autoConnect ||
legacy.showPopUpOnCaseOpen || legacy.showPopUpOnConnection ||
legacy.onePodMode
} catch (e: Exception) {
log(TAG, WARN) { "Failed to detect legacy reaction data: ${e.message}" }
false
}
if (hadData != settings.hadLegacyReactionData.valueBlocking) {
settings.hadLegacyReactionData.valueBlocking = hadData
}
}
@@ -106,6 +130,8 @@ class DeviceProfilesRepo @Inject constructor(
val profiles: Flow<List<DeviceProfile>> = settings.profiles.flow.map { it.profiles }
val hadLegacyReactionData: Flow<Boolean> = settings.hadLegacyReactionData.flow
suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock {
val currentContainer = settings.profiles.valueBlocking
checkAddressUniqueness(profile, currentContainer.profiles)
@@ -34,5 +34,6 @@ class DeviceProfilesSettings @Inject constructor(
val singleToMultiMigrationDone = dataStore.createValue("profiles.migration.v2.done", false)
val defaultProfileCreated = dataStore.createValue("profiles.default.v2.created", false)
val reactionMigrationDone = dataStore.createValue("profiles.reactions.migration.done", false)
val hadLegacyReactionData = dataStore.createValue("profiles.reactions.had_legacy_data", false)
}
@@ -24,6 +24,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -261,7 +262,7 @@ private fun ProfileRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onDeviceSettings) {
OutlinedIconButton(onClick = onDeviceSettings) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -2,6 +2,8 @@ package eu.darken.capod.reaction.core.playpause
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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
@@ -11,13 +13,15 @@ import eu.darken.capod.common.flow.withPrevious
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.ble.devices.ApplePods
import eu.darken.capod.reaction.core.playpause.PlayPause.Companion.PAUSE_DEBOUNCE_SAMPLES
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -30,6 +34,9 @@ class PlayPause @Inject constructor(
fun monitor() = run {
var pendingPlayConfirmation: PendingPlayConfirmation? = null
var pendingPauseDebounce: PendingPauseDebounce? = null
var hasLastMonitorKey = false
var lastMonitorKey: PlayPauseMonitorKey? = null
deviceMonitor.primaryDevice()
.map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true }
@@ -39,12 +46,18 @@ class PlayPause @Inject constructor(
bluetoothManager.connectedDevices
} else {
pendingPlayConfirmation = null
pendingPauseDebounce = null
hasLastMonitorKey = false
lastMonitorKey = null
emptyFlow()
}
}
.flatMapLatest { connected ->
if (connected.isEmpty()) {
pendingPlayConfirmation = null
pendingPauseDebounce = null
hasLastMonitorKey = false
lastMonitorKey = null
log(TAG) { "No known devices connected." }
emptyFlow()
} else {
@@ -53,9 +66,20 @@ class PlayPause @Inject constructor(
}
}
// Cache persistence can update battery timestamps without changing any reaction-relevant state.
.distinctUntilChangedBy { it?.toPlayPauseMonitorKey() }
.filter { device ->
val key = device?.toPlayPauseMonitorKey()
val shouldEmit = !hasLastMonitorKey ||
key != lastMonitorKey ||
device?.isPauseDebounceResetCandidate(pendingPauseDebounce) == true
if (shouldEmit) {
hasLastMonitorKey = true
lastMonitorKey = key
}
shouldEmit
}
.onEach { device ->
log(TAG, VERBOSE) { "Post-distinct: profileId=${device?.profileId}" }
log(TAG, VERBOSE) { "Post-monitor-filter: profileId=${device?.profileId}" }
}
.withPrevious()
.filter { (previous, current) ->
@@ -63,7 +87,13 @@ class PlayPause @Inject constructor(
// Use profileId (stable across BLE address rotations) rather than BLE identifier.
// Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause).
val match = previous.profileId != null && previous.profileId == current.profileId
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
if (!match) {
log(TAG, WARN) { "Main device switched, skipping reaction." }
if (pendingPauseDebounce != null) {
log(TAG, DEBUG) { "Pause debounce reset: profile change" }
pendingPauseDebounce = null
}
}
match
}
.onEach { (previous, current) ->
@@ -76,6 +106,26 @@ class PlayPause @Inject constructor(
if (reactions == null) {
log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" }
pendingPlayConfirmation = null
if (pendingPauseDebounce != null) {
log(TAG, DEBUG) { "Pause debounce reset: no reactions on device" }
pendingPauseDebounce = null
}
return@onEach
}
// Skip the reaction when previous was a no-live-evidence emission (cache-only
// baseline emitted at process start, or after a >20s BLE gap that evicted the
// device from the live cache). PodDevice.isBeingWorn returns null for those,
// and toEarDetectionState() coerces null -> false, which would produce a
// fake "not-worn -> worn" transition the moment live BLE arrives — firing
// an unwanted autoPlay on app start while the user is wearing the pods.
if (previous?.earDetectionSource() == EarDetectionSource.NO_LIVE_BLE) {
log(TAG, VERBOSE) { "Previous emission has no live evidence; skipping reaction." }
pendingPlayConfirmation = null
if (pendingPauseDebounce != null) {
log(TAG, DEBUG) { "Pause debounce reset: previous emission lacked live evidence" }
pendingPauseDebounce = null
}
return@onEach
}
@@ -109,6 +159,10 @@ class PlayPause @Inject constructor(
else -> {
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
pendingPlayConfirmation = null
if (pendingPauseDebounce != null) {
log(TAG, DEBUG) { "Pause debounce reset: device lost ear detection" }
pendingPauseDebounce = null
}
return@onEach
}
}
@@ -116,6 +170,8 @@ class PlayPause @Inject constructor(
val isCurrentlyPlaying = mediaControl.isPlaying
val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap
val source = current.earDetectionSource()
// Evaluate what action to take
val rawDecision = evaluatePlayPauseAction(
previous = prevState,
@@ -125,11 +181,18 @@ class PlayPause @Inject constructor(
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
)
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
// Trusted sources (AAP, BLE_IRK_MATCH) skip staging — symmetric to the
// pause debounce, which also skips for these sources. Without this gate,
// an IRK-matched device with no live AAP would stage a BLE-only confirmation
// that never confirms (the second worn sample has no freshness on the
// monitor key for IRK_MATCH so it gets collapsed).
val shouldStageBleOnlyPlay = rawDecision.shouldPlay &&
reactions.autoPlay &&
!reactions.onePodMode &&
current.hasDualPods &&
current.aap?.aapEarDetection == null
(source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS)
val confirmation = applyBleOnlyPlayConfirmation(
pending = pendingPlayConfirmation,
@@ -151,7 +214,36 @@ class PlayPause @Inject constructor(
log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" }
}
val decision = confirmation.decision
val debounceResult = applyPauseDebounce(
pending = pendingPauseDebounce,
profileId = current.profileId,
source = source,
rawDecision = confirmation.decision,
currentState = currState,
autoPauseEnabled = reactions.autoPause,
)
pendingPauseDebounce = debounceResult.pending
when (debounceResult.event) {
PauseDebounceEvent.STARTED -> log(TAG, DEBUG) {
"Pause debounce started: source=$source, initialPodCount=${debounceResult.pending?.initialPodCount}, " +
"remaining=${debounceResult.pending?.confirmationsRemaining}"
}
PauseDebounceEvent.ADVANCED -> log(TAG, DEBUG) {
"Pause debounce advanced: remaining=${debounceResult.pending?.confirmationsRemaining}, " +
"currentPodCount=${currState.podCount}"
}
PauseDebounceEvent.RESET -> log(TAG, DEBUG) {
"Pause debounce reset: source=$source, currentPodCount=${currState.podCount}, " +
"rawShouldPlay=${confirmation.decision.shouldPlay}"
}
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
"Pause debounce committed: source=$source confirmed pause"
}
PauseDebounceEvent.NONE -> {}
}
val decision = debounceResult.decision
if (decision.usedRecentCapPauseOverride) {
log(TAG, VERBOSE) {
"Resume override: recent CAP pause window is active, allowing play despite playing=true"
@@ -171,8 +263,15 @@ class PlayPause @Inject constructor(
}
decision.shouldPause && reactions.autoPause -> {
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" }
mediaControl.sendPause()
val pauseSent = mediaControl.sendPause()
log(TAG, INFO) {
"autoPause triggered: source=$source, " +
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
"podCount=${prevState.podCount}->${currState.podCount}, " +
"aapEar=${current.aap?.aapEarDetection != null}, " +
"aapConn=${current.aap?.connectionState}, " +
"pauseSent=$pauseSent, reason=${decision.reason}"
}
}
decision.shouldPause && !reactions.autoPause -> {
@@ -319,6 +418,144 @@ class PlayPause @Inject constructor(
)
}
/**
* Sample-count debounce for pause decisions when the ear-detection source is an
* unauthenticated BLE advertisement.
*
* RF interference can produce a single corrupt advert that decodes as not-worn,
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
* 3 consecutive not-worn samples before firing.
*
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
* — subsequent samples after the initial detection are not-worn → not-worn, and
* [evaluateNormalMode] returns no action for those.
*
* Confirmation rule: a sample with `currentState.podCount <= pending.initialPodCount`
* counts as a confirmation. An *increase* (pod returned) clears pending.
*
* Trusted sources ([EarDetectionSource.AAP], [EarDetectionSource.BLE_IRK_MATCH]) skip
* debounce and pass through [rawDecision] unchanged; any active [pending] is cleared.
*/
internal fun applyPauseDebounce(
pending: PendingPauseDebounce?,
profileId: String?,
source: EarDetectionSource,
rawDecision: PlayPauseDecision,
currentState: EarDetectionState,
autoPauseEnabled: Boolean,
): PauseDebounceResult {
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS
val activePending = pending?.takeIf { it.profileId == profileId }
// NO_LIVE_BLE: no fresh evidence anywhere (ble == null, aap absent or no EarDetection).
// Suppress shouldPause — a worn → not-worn transition derived from null/cached state is
// not real removal evidence. Also clears any active pending since stale samples must not
// advance confirmations.
if (source == EarDetectionSource.NO_LIVE_BLE) {
val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE
return PauseDebounceResult(
decision = rawDecision.copy(
shouldPause = false,
reason = "${rawDecision.reason} (suppressed: no live BLE evidence)",
),
pending = null,
event = event,
)
}
// Non-debounce-eligible trusted sources (AAP / BLE_IRK_MATCH) and disabled debounce:
// pass raw decision through and clear any active pending. Special case: if a
// pending was active and the trusted source still shows the not-worn condition
// (currentState.podCount <= initialPodCount and no play decision), commit the
// pause now — the trusted source corroborates what BLE-debounce was waiting on.
if (!needsDebounce || !autoPauseEnabled || profileId == null) {
if (activePending != null && autoPauseEnabled &&
currentState.podCount <= activePending.initialPodCount &&
!rawDecision.shouldPlay
) {
return PauseDebounceResult(
decision = PlayPauseDecision(
shouldPlay = false,
shouldPause = true,
reason = "Pause confirmed by trusted source ($source) after BLE-debounce",
),
pending = null,
event = PauseDebounceEvent.COMMITTED,
)
}
val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE
return PauseDebounceResult(decision = rawDecision, pending = null, event = event)
}
// First detection: no active pending, raw decision wants to pause → start debounce.
if (activePending == null) {
if (!rawDecision.shouldPause) {
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE)
}
if (PAUSE_DEBOUNCE_SAMPLES <= 0) {
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE)
}
return PauseDebounceResult(
decision = rawDecision.copy(
shouldPause = false,
reason = "${rawDecision.reason} (debouncing, $PAUSE_DEBOUNCE_SAMPLES confirmation(s) needed)",
),
pending = PendingPauseDebounce(
profileId = profileId,
initialPodCount = currentState.podCount,
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
),
event = PauseDebounceEvent.STARTED,
)
}
// Confirmation phase: pending exists.
// Reset cases — checked in order of authority:
// 1. Raw decision wants to play → genuine play signal, reset immediately.
// 2. Pod count went up → tolerate one rebound sample (corrupt count-up
// protection), reset only on the second consecutive count-up.
if (rawDecision.shouldPlay) {
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.RESET)
}
if (currentState.podCount > activePending.initialPodCount) {
if (activePending.resetTolerance > 0) {
return PauseDebounceResult(
decision = rawDecision.copy(
shouldPause = false,
reason = "Debouncing pause (rebound tolerated)",
),
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
event = PauseDebounceEvent.ADVANCED,
)
}
return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.RESET)
}
// Confirmation: count <= initialPodCount, decrement remaining.
val remaining = activePending.confirmationsRemaining - 1
if (remaining <= 0) {
return PauseDebounceResult(
decision = PlayPauseDecision(
shouldPlay = false,
shouldPause = true,
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
),
pending = null,
event = PauseDebounceEvent.COMMITTED,
)
}
return PauseDebounceResult(
decision = rawDecision.copy(
shouldPause = false,
reason = "Debouncing pause ($remaining confirmation(s) remaining)",
),
pending = activePending.copy(confirmationsRemaining = remaining),
event = PauseDebounceEvent.ADVANCED,
)
}
data class EarDetectionState(
val leftInEar: Boolean?, // null for single pod devices
val rightInEar: Boolean?, // null for single pod devices
@@ -389,6 +626,46 @@ class PlayPause @Inject constructor(
val stagedConfirmation: Boolean,
)
/**
* Trust classification for the source of the current ear-detection reading.
*
* - [AAP]: EarDetection setting from an active AAP (L2CAP) session — error-corrected,
* identity-authenticated. Trusted; debounce skipped.
* - [BLE_IRK_MATCH]: BLE advertisement whose RPA was verified against this profile's
* identity key. Identity-authenticated; debounce skipped.
* - [BLE_PROFILE_FALLBACK]: BLE advertisement assigned to a profile via signal-quality
* fallback (no IRK match). Could be a stray advert from a nearby pair. Debounced.
* - [BLE_ANONYMOUS]: BLE advertisement with no profile match. Filtered out by
* [primaryDevice] in production (devices without profileId are dropped before
* reaching the reaction layer); kept here defensively in case the upstream filter
* changes. Note: [PendingPauseDebounce] is profile-keyed, so a null profileId can
* never sustain pending state even if this branch is hit.
* - [NO_LIVE_BLE]: No live BLE snapshot at all (cache-only or empty). Not debounced —
* the cached state is not "fresh evidence" so it must not advance the debounce counter.
* Any active pending is cleared.
*/
enum class EarDetectionSource { AAP, BLE_IRK_MATCH, BLE_PROFILE_FALLBACK, BLE_ANONYMOUS, NO_LIVE_BLE }
data class PendingPauseDebounce(
val profileId: String,
val initialPodCount: Int,
val confirmationsRemaining: Int,
// Tolerates one count-up rebound sample before the pending is reset. Mirrors the
// count-down debounce on the pause side: a single corrupt advert that briefly
// shows a pod returning shouldn't kill the pending, since the next sample may
// confirm the pods are still out.
val resetTolerance: Int = 1,
)
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
enum class PauseDebounceEvent { NONE, STARTED, ADVANCED, RESET, COMMITTED }
data class PauseDebounceResult(
val decision: PlayPauseDecision,
val pending: PendingPauseDebounce?,
val event: PauseDebounceEvent = PauseDebounceEvent.NONE,
)
internal data class PlayPauseMonitorKey(
val profileId: String?,
val autoPlay: Boolean,
@@ -402,10 +679,44 @@ class PlayPause @Inject constructor(
val isEitherPodInEar: Boolean?,
val hasAapEarDetection: Boolean,
val hasBleSnapshot: Boolean,
val source: EarDetectionSource,
// Set only for debounce-eligible sources (BLE_PROFILE_FALLBACK / BLE_ANONYMOUS) so
// that monitor distinct filtering doesn't collapse repeated identical not-worn samples
// and the debounce counter can advance.
//
// Caveat: BlePodMonitor.preferCaseContextPod can keep an existing case-context
// snapshot in place over an incoming non-case-context snapshot, preserving the old
// seenLastAt. This is fail-closed (delays a legitimate unauthenticated-BLE pause
// rather than firing a false one), so accepted as a trade-off.
val debounceFreshness: Instant?,
)
internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey =
PlayPauseMonitorKey(
internal fun PodDevice.earDetectionSource(): EarDetectionSource {
if (aap?.aapEarDetection != null) return EarDetectionSource.AAP
if (ble == null) return EarDetectionSource.NO_LIVE_BLE
val applePod = ble as? ApplePods
return when {
applePod?.meta?.isIRKMatch == true -> EarDetectionSource.BLE_IRK_MATCH
ble.meta.profile != null -> EarDetectionSource.BLE_PROFILE_FALLBACK
else -> EarDetectionSource.BLE_ANONYMOUS
}
}
internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey {
val source = earDetectionSource()
// Freshness applies to all unauthenticated samples (both worn and not-worn) so
// that monitor distinct filtering doesn't collapse repeated identical samples:
// - not-worn samples must pass through to advance the pause debounce counter
// - worn samples must pass through to satisfy applyBleOnlyPlayConfirmation,
// which requires a 2nd identical worn sample to confirm a staged play
// (see commit 6825abaa "Guard BLE-only autoplay")
// The 2-sample autoplay confirmation IS the debounce on the play side, mirroring
// the pause debounce. isPauseDebounceResetCandidate() remains as a defense-in-depth
// backstop for the rare case where seenLastAt doesn't advance between samples
// (e.g. BlePodMonitor.preferCaseContextPod preserves the prior snapshot).
val needsFreshness = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS
return PlayPauseMonitorKey(
profileId = profileId,
autoPlay = reactions.autoPlay,
autoPause = reactions.autoPause,
@@ -418,20 +729,45 @@ class PlayPause @Inject constructor(
isEitherPodInEar = isEitherPodInEar,
hasAapEarDetection = aap?.aapEarDetection != null,
hasBleSnapshot = ble != null,
source = source,
debounceFreshness = if (needsFreshness) ble?.seenLastAt else null,
)
}
private fun PodDevice.isPauseDebounceResetCandidate(pending: PendingPauseDebounce?): Boolean {
val activePending = pending?.takeIf { it.profileId == profileId } ?: return false
val source = earDetectionSource()
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS
if (!needsDebounce || !hasEarDetection) return false
return toEarDetectionState().podCount > activePending.initialPodCount
}
/**
* Converts a dual-pod [PodDevice] to [EarDetectionState] for reaction evaluation.
* Prefers per-side values (left/right) when available, falls back to AAP aggregate
* state (isBeingWorn/isEitherPodInEar) when per-side mapping is unknown.
*
* When AAP EarDetection is present, prefers AAP aggregate state — even if per-side
* (left/right) values are non-null via BLE fallback. This avoids letting BLE per-side
* bits drive the decision when AAP has authoritative aggregate state but
* resolvedPrimaryPod is unknown (cmd 0x0008 not received or cleared by role swap).
*
* When AAP is absent, prefers per-side values (BLE) when available, falling back to
* AAP aggregate as a last resort (which is identical to BLE aggregate in this case).
*/
private fun PodDevice.toEarDetectionState(): EarDetectionState {
internal fun PodDevice.toEarDetectionState(): EarDetectionState {
if (aap?.aapEarDetection != null) {
return EarDetectionState.fromAapAggregate(
isBeingWorn = isBeingWorn ?: false,
isEitherPodInEar = isEitherPodInEar ?: false,
)
}
val left = isLeftInEar
val right = isRightInEar
if (left != null && right != null) {
return EarDetectionState.fromDualPod(left = left, right = right)
}
// Per-side unavailable (resolvedPrimaryPod is null) — use aggregate AAP state.
return EarDetectionState.fromAapAggregate(
isBeingWorn = isBeingWorn ?: false,
isEitherPodInEar = isEitherPodInEar ?: false,
@@ -440,5 +776,11 @@ class PlayPause @Inject constructor(
companion object {
private val TAG = logTag("Reaction", "PlayPause")
/**
* Number of additional confirmations required before an unauthenticated-BLE pause
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
*/
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
}
}
@@ -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,
@@ -16,6 +16,7 @@ import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.ble.BleScanModeController
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
@@ -40,6 +41,7 @@ class TroubleShooterViewModel @Inject constructor(
private val generalSettings: GeneralSettings,
private val profilesRepo: DeviceProfilesRepo,
private val blePodMonitor: BlePodMonitor,
private val bleScanModeController: BleScanModeController,
private val deviceMonitor: DeviceMonitor,
private val debugSettings: DebugSettings,
private val timeSource: TimeSource,
@@ -84,39 +86,119 @@ class TroubleShooterViewModel @Inject constructor(
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
log(TAG, INFO) { "troubleShootBle()" }
generalSettings.scannerMode.valueBlocking = ScannerMode.LOW_LATENCY
run {
progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
run {
progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Headphones found, nothing to troubleshoot.")
return@override
} else {
progress("Headphones not detected.\n")
}
}
if (mainDevice != null) {
success("Headphones found, nothing to troubleshoot.")
return@launch
} else {
progress("Headphones not detected.\n")
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
{ hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
debugSettings.showUnfiltered.valueBlocking = unfiltered
val start = timeSource.elapsedRealtime()
val devices = withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
}
}
run {
progress("Checking if we can receive BLE data at all.")
if (doScan(false, false, false, true).isNotEmpty()) return@run
if (doScan(false, false, true, true).isNotEmpty()) return@run
if (doScan(true, true, true, true).isNotEmpty()) return@run
if (doScan(true, true, false, true).isNotEmpty()) return@run
if (doScan(true, false, true, true).isNotEmpty()) return@run
if (doScan(true, false, false, true).isNotEmpty()) return@run
if (doScan(false, true, true, true).isNotEmpty()) return@run
if (doScan(false, true, false, true).isNotEmpty()) return@run
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
debugSettings.showUnfiltered.valueBlocking = false
return@override
}
}
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
{ hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
debugSettings.showUnfiltered.valueBlocking = unfiltered
progress("We received at least some BLE data.\n")
val start = timeSource.elapsedRealtime()
val devices = withTimeoutOrNull(STEP_TIME) {
run {
progress("Checking for supported headphones.")
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@override
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Found your headphones, new BLE settings worked :)!")
return@override
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = withTimeoutOrNull(STEP_TIME) {
val start = timeSource.elapsedRealtime()
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
@@ -124,122 +206,40 @@ class TroubleShooterViewModel @Inject constructor(
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
if (otherDevices.isEmpty()) {
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
return@override
}
}
run {
progress("Checking if we can receive BLE data at all.")
if (doScan(false, false, false, true).isNotEmpty()) return@run
if (doScan(false, false, true, true).isNotEmpty()) return@run
if (doScan(true, true, true, true).isNotEmpty()) return@run
if (doScan(true, true, false, true).isNotEmpty()) return@run
if (doScan(true, false, true, true).isNotEmpty()) return@run
if (doScan(true, false, false, true).isNotEmpty()) return@run
if (doScan(false, true, true, true).isNotEmpty()) return@run
if (doScan(false, true, false, true).isNotEmpty()) return@run
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxBy { it.signalQuality }
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
debugSettings.showUnfiltered.valueBlocking = false
log(TAG, INFO) { "Candidate is $candidate" }
return@launch
}
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
progress("We received at least some BLE data.\n")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
run {
progress("Checking for supported headphones.")
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@launch
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Found your headphones, new BLE settings worked :)!")
return@launch
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = withTimeoutOrNull(STEP_TIME) {
val start = timeSource.elapsedRealtime()
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
if (otherDevices.isEmpty()) {
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
return@launch
}
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxBy { it.signalQuality }
log(TAG, INFO) { "Candidate is $candidate" }
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
generalSettings.scannerMode.valueBlocking = ScannerMode.BALANCED
if (mainDevice != null) {
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
if (mainDevice != null) {
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
}
}
+2 -5
View File
@@ -46,8 +46,6 @@
<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>
<string name="settings_keep_notification_after_disconnect_description">Hou aan om die laaste bekende batteryvlakke te wys selfs nadat jou AirPods ontkoppel.</string>
<string name="settings_scanner_mode_label">Skandeerdermodus</string>
<string name="settings_scanner_mode_description">Moet die Bluetooth Lae Energie-dataskandeerder werkverrigting prioritiseer of energie bespaar?</string>
<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>
@@ -222,9 +220,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_scanner_mode_lowpower_label">Lae krag</string>
<string name="settings_scanner_mode_balanced_label">Gebalanseer</string>
<string name="settings_scanner_mode_lowlatency_label">Lae latensie</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dubbel druk om oproep te beëindig</string>
<string name="device_settings_open_cd">Maak toestel-instellings oop</string>
<string name="overview_card_missing_paired_device">Hierdie profiel het geen gekoppelde Bluetooth-toestel nie.</string>
<string name="overview_reactions_hint_title">Reaksies is per toestel</string>
<string name="overview_reactions_hint_body">Outo-speel, outo-pouse en opspringers word nou per toestel gekonfigureer. Tik op die instellings-ikoon op \'n kaart terwyl jou kopfone verbind is.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Algemeen</string>
<string name="device_settings_microphone_mode_label">Mikrofoon</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
<string name="settings_keep_notification_after_disconnect_description">AirPods ከተነተቁ በኋላም የመጨረሻውን የታወቀ የባትሪ ደረጃ ማሳየቱን ይቀጥሉ</string>
<string name="settings_scanner_mode_label">የቃኚ ሁነታ</string>
<string name="settings_scanner_mode_description">የብሉቱዝ ዝቅተኛ ኃይል ዳታ ቃኚው አፈጻጸምን ቅድሚያ መስጠት አለበት ወይስ ኃይል መቆጠብ አለበት?</string>
<string name="settings_autopause_label">ራስ-ሰር ለአፍታ ማቆም</string>
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
@@ -222,9 +220,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_scanner_mode_lowpower_label">ዝቅተኛ ኃይል</string>
<string name="settings_scanner_mode_balanced_label">ሚዛናዊ</string>
<string name="settings_scanner_mode_lowlatency_label">ዝቅተኛ መዘግየት</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ጥሪን ለመጨረስ ሁለት ጊዜ ጀን</string>
<string name="device_settings_open_cd">የመሣሪያ ቅንብሮችን ክፈት</string>
<string name="overview_card_missing_paired_device">ይህ መገለጫ የጋራ ብሉቱዝ መሳሪያ የለውም።</string>
<string name="overview_reactions_hint_title">ምላሾች በእያንዳንዱ መሳሪያ ናቸው</string>
<string name="overview_reactions_hint_body">የራስ-ማጫወት፣ የራስ-ማቆም እና ብቅ-ባይ መስኮቶች አሁን በእያንዳንዱ መሳሪያ ይዋቀራሉ። ጆሮ ማዳመጫዎ ሲገናኙ ካርዱ ላይ ያለውን የቅንብሮች አዶ መታ ያድርጉ።</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">አጠቃላይ</string>
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
<string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</string>
<string name="settings_scanner_mode_label">وضع الماسح الضوئي</string>
<string name="settings_scanner_mode_description">هل يجب أن يعطي ماسح بيانات البلوتوث منخفض الطاقة الأولوية للأداء أم يحافظ على الطاقة؟</string>
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopplay_label">تشغيل تلقائي</string>
@@ -230,9 +228,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_scanner_mode_lowpower_label">طاقة منخفضة</string>
<string name="settings_scanner_mode_balanced_label">متوازن</string>
<string name="settings_scanner_mode_lowlatency_label">تأخير منخفض</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>
@@ -490,6 +485,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ضغطة مزدوجة لإنهاء المكالمة</string>
<string name="device_settings_open_cd">فتح إعدادات الجهاز</string>
<string name="overview_card_missing_paired_device">هذا الملف الشخصي لا يحتوي على جهاز بلوتوث مقترن.</string>
<string name="overview_reactions_hint_title">ردود الفعل خاصة بكل جهاز</string>
<string name="overview_reactions_hint_body">يتم الآن تهيئة التشغيل التلقائي والإيقاف التلقائي والنوافذ المنبثقة لكل جهاز على حدة. انقر على أيقونة الإعدادات على البطاقة أثناء توصيل سماعاتك.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">عام</string>
<string name="device_settings_microphone_mode_label">ميكروفون</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<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>
<string name="settings_keep_notification_after_disconnect_description">AirPods ayrıldıqdan sonra belə sonuncu bilinən batareya səviyyələrini göstərməyə davam et</string>
<string name="settings_scanner_mode_label">Skaner rejimi</string>
<string name="settings_scanner_mode_description">Bluetooth Aşağı Enerji məlumat skaneri performansa üstünlük verməli, yoxsa enerjiyə qənaət etməlidir?</string>
<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>
@@ -222,9 +220,6 @@
<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_scanner_mode_lowpower_label">Aşağı güc</string>
<string name="settings_scanner_mode_balanced_label">Balanslaşdırılmış</string>
<string name="settings_scanner_mode_lowlatency_label">Aşağı gecikmə</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Zəngi bitirmək üçün iki dəfə basın</string>
<string name="device_settings_open_cd">Cihaz parametrlərini açın</string>
<string name="overview_card_missing_paired_device">Bu profilin cütləşdirilmiş Bluetooth cihazı yoxdur.</string>
<string name="overview_reactions_hint_title">Reaksiyalar hər cihaz üçün ayrıdır</string>
<string name="overview_reactions_hint_body">Avtomatik oxutma, avtomatik dayandırma və açılan pəncərələr artıq hər cihaz üçün ayrıca konfiqurasiya edilir. Qulaqlıqlarınız qoşulu olarkən kartdakı parametrlər ikonasına toxunun.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Ümumi</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Паказвае дадатковае апавяшчэнне пры падключэнні прылады. Гэта дазваляе схаваць пастаяннае апавяшчэнне \"Няма прылад\" шляхам адключэння канала \"Стан прылады\".</string>
<string name="settings_keep_notification_after_disconnect_label">Захаваць апавяшчэнне пасля адключэння</string>
<string name="settings_keep_notification_after_disconnect_description">Працягваць паказваць апошнія вядомыя ўзроўні зарадкі нават пасля адключэння вашых AirPods</string>
<string name="settings_scanner_mode_label">Рэжым сканера</string>
<string name="settings_scanner_mode_description">Ці павінен сканер даных Bluetooth Low Energy аддаваць перавагу прадукцыйнасці або эканоміць энергію?</string>
<string name="settings_autopause_label">Аўтаматычная паўза</string>
<string name="settings_autopause_description">Прыпыняць аўдыя пры выманні прылады з вуха.</string>
<string name="settings_autopplay_label">Аўтаматычнае прайграванне</string>
@@ -226,9 +224,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_scanner_mode_lowpower_label">Нізкае энергаспажыванне</string>
<string name="settings_scanner_mode_balanced_label">Збалансавана</string>
<string name="settings_scanner_mode_lowlatency_label">Нізкая затрымка</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>
@@ -480,6 +475,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Двайны націск для завяршэння выкліку</string>
<string name="device_settings_open_cd">Адкрыць налады прылады</string>
<string name="overview_card_missing_paired_device">З гэтым профілем не сапалучана прылада Bluetooth.</string>
<string name="overview_reactions_hint_title">Рэакцыі ўстаноўлены для кожнай прылады</string>
<string name="overview_reactions_hint_body">Аўтаўзнаўленне, аўтапаўза і ўсплывальныя вокны цяпер наладжваюцца для кожнай прылады. Націсніце значок налад на картцы, пакуль вашы навушнікі падключаны.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Агульныя</string>
<string name="device_settings_microphone_mode_label">Мікрафон</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Показва допълнително известие, когато устройство е свързано. Това ви позволява да скриете постоянното известие \"Няма устройства\" чрез деактивиране на канала \"Състояние на устройството\".</string>
<string name="settings_keep_notification_after_disconnect_label">Запази известието след прекъсване</string>
<string name="settings_keep_notification_after_disconnect_description">Продължавай да показваш последните известни нива на батерията дори след прекъсване на връзката с AirPods</string>
<string name="settings_scanner_mode_label">Режим на скенер</string>
<string name="settings_scanner_mode_description">Трябва ли скенерът за данни с ниска енергия на Bluetooth да даде приоритет на производителността или да пести енергия?</string>
<string name="settings_autopause_label">Автоматична пауза</string>
<string name="settings_autopause_description">Поставяне на аудио на пауза при премахване на устройството от ухото ви.</string>
<string name="settings_autopplay_label">Автоматично възпроизвеждане</string>
@@ -222,9 +220,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_scanner_mode_lowpower_label">Ниска мощност</string>
<string name="settings_scanner_mode_balanced_label">Балансиран</string>
<string name="settings_scanner_mode_lowlatency_label">Ниска латентност</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Двойно натискане за прекратяване на разговора</string>
<string name="device_settings_open_cd">Отворете настройките на устройството</string>
<string name="overview_card_missing_paired_device">Този профил няма сдвоено Bluetooth устройство.</string>
<string name="overview_reactions_hint_title">Реакциите са за всяко устройство поотделно</string>
<string name="overview_reactions_hint_body">Автоматичното възпроизвеждане, паузирането и изскачащите прозорци вече се конфигурират за всяко устройство поотделно. Натиснете иконата с настройки на картата, докато слушалките ви са свързани.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Общи</string>
<string name="device_settings_microphone_mode_label">Микрофон</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">একটি ডিভাইস সংযুক্ত হলে একটি অতিরিক্ত বিজ্ঞপ্তি দেখায়। এটি আপনাকে \"ডিভাইস স্ট্যাটাস\" চ্যানেল নিষ্ক্রিয় করে স্থায়ী \"কোনো ডিভাইস নেই\" বিজ্ঞপ্তি লুকাতে দেয়।</string>
<string name="settings_keep_notification_after_disconnect_label">সংযোগ বিচ্ছিন্নের পরে বিজ্ঞপ্তি রাখুন</string>
<string name="settings_keep_notification_after_disconnect_description">আপনার AirPods সংযোগ বিচ্ছিন্ন হওয়ার পরেও সর্বশেষ জানা ব্যাটারি স্তর দেখাতে থাকুন</string>
<string name="settings_scanner_mode_label">স্ক্যানার মোড</string>
<string name="settings_scanner_mode_description">ব্লুটুথ লো এনার্জি ডেটা স্ক্যানার কি কর্মক্ষমতাকে অগ্রাধিকার দেবে নাকি শক্তি সংরক্ষণ করবে?</string>
<string name="settings_autopause_label">স্বয়ংক্রিয় বিরতি</string>
<string name="settings_autopause_description">আপনার কান থেকে ডিভাইসটি সরানোর সময় অডিওতে বিরতি দিন।</string>
<string name="settings_autopplay_label">স্বয়ংক্রিয় প্লে</string>
@@ -222,9 +220,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_scanner_mode_lowpower_label">কম শক্তি</string>
<string name="settings_scanner_mode_balanced_label">ভারসাম্যপূর্ণ</string>
<string name="settings_scanner_mode_lowlatency_label">কম বিলম্ব</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">কল শেষ করতে দুবার চাপুন</string>
<string name="device_settings_open_cd">ডিভাইস সেটিংস খুলুন</string>
<string name="overview_card_missing_paired_device">এই প্রোফাইলে কোনো পেয়ার করা ব্লুটুথ ডিভাইস নেই।</string>
<string name="overview_reactions_hint_title">প্রতিক্রিয়াগুলি প্রতিটি ডিভাইসের জন্য আলাদা</string>
<string name="overview_reactions_hint_body">অটো-প্লে, অটো-পজ এবং পপ-আপগুলি এখন প্রতিটি ডিভাইসের জন্য আলাদাভাবে কনফিগার করা হয়েছে। আপনার হেডফোন সংযুক্ত থাকাকালীন একটি কার্ডে সেটিংস আইকনে ট্যাপ করুন।</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">সাধারণ</string>
<string name="device_settings_microphone_mode_label">মাইক্রোফোন</string>
+4 -7
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Mostra una notificació addicional quan hi ha un dispositiu connectat. Això permet ocultar la notificació permanent «Sense dispositius» desactivant el canal «Estat del dispositiu».</string>
<string name="settings_keep_notification_after_disconnect_label">Mantén les notificacions després de la desconnexió</string>
<string name="settings_keep_notification_after_disconnect_description">Continua mostrant els últims nivells de bateria coneguts fins i tot després que els AirPods es desconnectin</string>
<string name="settings_scanner_mode_label">Mode escàner</string>
<string name="settings_scanner_mode_description">L\'escàner de dades Bluetooth de baix consum hauria de prioritzar el rendiment o estalviar energia?</string>
<string name="settings_autopause_label">Pausa automàtica</string>
<string name="settings_autopause_description">Pausa l\'àudio quan ús tragueu el dispositiu de l\'orella.</string>
<string name="settings_autopplay_label">Reproducció automàtica</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Cal el permís següent:</string>
<string name="permission_system_alert_window_label">Finestra d\'alerta del sistema</string>
<string name="permission_system_alert_window_description">Permet que el CAPod dibuixi sobre altres aplicacions per fer possible la característica «Mostra emergent».</string>
<string name="settings_scanner_mode_lowpower_label">Baixa potència</string>
<string name="settings_scanner_mode_balanced_label">Equilibrat</string>
<string name="settings_scanner_mode_lowlatency_label">Baixa latència</string>
<string name="settings_monitor_mode_manual_label">Quan l\'aplicació està oberta</string>
<string name="settings_monitor_mode_automatic_label">Quan el dispositiu està connectat</string>
<string name="settings_monitor_mode_always_label">Sempre</string>
@@ -294,7 +289,7 @@
<string name="profiles_delete_action">Elimina</string>
<string name="profiles_basic_info_title">Informació del dispositiu</string>
<string name="profiles_basic_info_description">Configureu el nom del dispositiu, el model i l\'emparellament Bluetooth opcional.</string>
<string name="profiles_signal_quality_title">Qualitat de senyal màxima</string>
<string name="profiles_signal_quality_title">Qualitat de senyal mínima</string>
<string name="profiles_signal_quality_description">Només es detecten dispositius amb una intensitat de senyal superior a aquest llindar. Els valors més baixos augmenten el rang de detecció, però poden causar falsos positius. No configureu aquest valor massa alt: la recepció Bluetooth generalment és deficient i varia segons la distància i els obstacles.</string>
<string name="profiles_identitykey_label">Clau d\'identitat</string>
<string name="profilessettings_maindevice_identitykey_description">La clau de resolució d\'identitat (IRK) del dispositiu ajuda al CAPod a identificar-lo entre els dispositius propers.</string>
@@ -396,7 +391,7 @@
<string name="support_contact_send_action">Obre l\'aplicació de correu</string>
<string name="support_contact_no_email_app">No s\'ha trobat cap aplicació de correu en aquest dispositiu.</string>
<string name="support_contact_debuglog_delete_title">Voleu suprimir el registre de depuració?</string>
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimrirà permanentment.</string>
<string name="support_contact_debuglog_delete_message">Aquest registre de depuració se suprimirà permanentment.</string>
<!-- Post-share confirmation -->
<string name="support_debuglog_sent_title">S\'ha enviat el registre?</string>
<string name="support_debuglog_sent_message">El registre de depuració s\'ha enviat correctament? El registre s\'eliminarà.</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Doble toc per acabar la trucada</string>
<string name="device_settings_open_cd">Obre la configuració del dispositiu</string>
<string name="overview_card_missing_paired_device">Aquest perfil no té cap dispositiu Bluetooth emparellat.</string>
<string name="overview_reactions_hint_title">Les reaccions són per dispositiu</string>
<string name="overview_reactions_hint_body">La reproducció automàtica, la pausa automàtica i les finestres emergents ara es configuren per dispositiu. Toqueu la icona de configuració d\'una targeta mentre els vostres auriculars estan connectats.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">General</string>
<string name="device_settings_microphone_mode_label">Micròfon</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Zobrazí dodatečné oznámení, když je zařízení připojeno. To umožňuje skrýt trvalé oznámení \"Žádné zařízení\" vypnutím kanálu \"Stav zařízení\".</string>
<string name="settings_keep_notification_after_disconnect_label">Uchovat oznámení po odpojení</string>
<string name="settings_keep_notification_after_disconnect_description">Pokračovat v zobrazování poslední známé úrovně nabití baterie i po odpojení AirPods</string>
<string name="settings_scanner_mode_label">Režim skenování</string>
<string name="settings_scanner_mode_description">Má skenování Bluetooth Low Energy upřednostnit výkon, nebo úsporu energie?</string>
<string name="settings_autopause_label">Autom. pozastavení</string>
<string name="settings_autopause_description">Pozastavení zvuku při vyjmutí zařízení z ucha.</string>
<string name="settings_autopplay_label">Autom. přehrávání</string>
@@ -226,9 +224,6 @@
<string name="permission_required_title">Je vyžadováno následující oprávnění:</string>
<string name="permission_system_alert_window_label">Okno systémových upozornění</string>
<string name="permission_system_alert_window_description">Aby bylo možné použít unkci \"Zobrazit vyskakovací okno\", je nutné umožnit aplikaci CAPod vykreslení přes jiné aplikace.</string>
<string name="settings_scanner_mode_lowpower_label">Nízký výkon</string>
<string name="settings_scanner_mode_balanced_label">Vyvážený</string>
<string name="settings_scanner_mode_lowlatency_label">Nízká latence</string>
<string name="settings_monitor_mode_manual_label">Při otevření aplikace</string>
<string name="settings_monitor_mode_automatic_label">Při připojení zařízení</string>
<string name="settings_monitor_mode_always_label">Vždy</string>
@@ -480,6 +475,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dvojitý stisk pro ukončení hovoru</string>
<string name="device_settings_open_cd">Otevřít nastavení zařízení</string>
<string name="overview_card_missing_paired_device">Tento profil nemá spárované zařízení Bluetooth.</string>
<string name="overview_reactions_hint_title">Reakce jsou nastaveny pro každé zařízení</string>
<string name="overview_reactions_hint_body">Automatické přehrávání, automatické pozastavení a vyskakovací okna jsou nyní konfigurována pro každé zařízení. Klepněte na ikonu nastavení na kartě, když jsou vaše sluchátka připojena.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Obecné</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Viser en ekstra notifikation, når en enhed er tilsluttet. Dette lader dig skjule den permanente \"Ingen enheder\"-notifikation ved at deaktivere \"Enhedsstatus\"-kanalen.</string>
<string name="settings_keep_notification_after_disconnect_label">Behold notifikation efter afbrydelse</string>
<string name="settings_keep_notification_after_disconnect_description">Fortsæt med at vise de sidst kendte batteriniveauer, selv efter dine AirPods afbrydes</string>
<string name="settings_scanner_mode_label">Scannertilstand</string>
<string name="settings_scanner_mode_description">Skal Bluetooth Low Energy-datascanneren prioritere ydeevne eller spare på energien?</string>
<string name="settings_autopause_label">Auto-pause</string>
<string name="settings_autopause_description">Sæt lyd på pause, når du fjerner enheden fra dit øre.</string>
<string name="settings_autopplay_label">Auto-afspilning</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Følgende tilladelse er påkrævet:</string>
<string name="permission_system_alert_window_label">Systemalarmvindue</string>
<string name="permission_system_alert_window_description">Tillad CAPod at tegne over andre apps for at gøre funktionen \"Vis pop op\" mulig.</string>
<string name="settings_scanner_mode_lowpower_label">Lavt strømforbrug</string>
<string name="settings_scanner_mode_balanced_label">Balanceret</string>
<string name="settings_scanner_mode_lowlatency_label">Lav latens</string>
<string name="settings_monitor_mode_manual_label">Når appen er åben</string>
<string name="settings_monitor_mode_automatic_label">Når enheden er tilsluttet</string>
<string name="settings_monitor_mode_always_label">Altid</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dobbelt tryk for at afslutte opkald</string>
<string name="device_settings_open_cd">Åbn enhedsindstillinger</string>
<string name="overview_card_missing_paired_device">Denne profil har ingen parret Bluetooth-enhed.</string>
<string name="overview_reactions_hint_title">Reaktioner er per enhed</string>
<string name="overview_reactions_hint_body">Automatisk afspilning, automatisk pause og pop-ups er nu konfigureret per enhed. Tryk på indstillingsikonet på et kort, mens dine hovedtelefoner er tilsluttet.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Generel</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Zeigt eine zusätzliche Benachrichtigung an, wenn ein Gerät verbunden ist. Dadurch kannst du die permanente Benachrichtigung „Keine Geräte“ ausblenden, indem du den Kanal „Gerätestatus“ deaktivierst.</string>
<string name="settings_keep_notification_after_disconnect_label">Benachrichtigung nach Trennung beibehalten</string>
<string name="settings_keep_notification_after_disconnect_description">Zeigt weiterhin den letzten bekannten Akkustand an, auch wenn die Verbindung zu deinen AirPods getrennt wurde</string>
<string name="settings_scanner_mode_label">Scan-Modus</string>
<string name="settings_scanner_mode_description">Soll die Bluetooth Low Energy Überwachung Leistung priorisieren oder Energie sparen?</string>
<string name="settings_autopause_label">Automatische Pause</string>
<string name="settings_autopause_description">Musik pausieren, wenn der Kopfhörer vom Ohr entfernt wird.</string>
<string name="settings_autopplay_label">Automatisches abspielen</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Die folgende Genehmigung ist erforderlich:</string>
<string name="permission_system_alert_window_label">Systemwarnungsfenster</string>
<string name="permission_system_alert_window_description">Erlaube CAPod, PopUp Benachrichtigungen über anderen Apps anzuzeigen.</string>
<string name="settings_scanner_mode_lowpower_label">Geringer Strom</string>
<string name="settings_scanner_mode_balanced_label">Ausgewogen</string>
<string name="settings_scanner_mode_lowlatency_label">Geringe Latenz</string>
<string name="settings_monitor_mode_manual_label">Wenn die App geöffnet ist</string>
<string name="settings_monitor_mode_automatic_label">Wenn das Gerät verbunden ist</string>
<string name="settings_monitor_mode_always_label">Immer</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Zweimaliges Drücken zum Beenden des Anrufs</string>
<string name="device_settings_open_cd">Geräteeinstellungen öffnen</string>
<string name="overview_card_missing_paired_device">Dieses Profil hat kein gekoppeltes Bluetooth-Gerät.</string>
<string name="overview_reactions_hint_title">Reaktionen sind gerätespezifisch</string>
<string name="overview_reactions_hint_body">Auto-Play, Auto-Pause und Pop-ups werden jetzt pro Gerät konfiguriert. Tippe auf das Einstellungssymbol einer Karte, während deine Kopfhörer verbunden sind.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Allgemein</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Εμφανίζει μια επιπλέον ειδοποίηση όταν μια συσκευή είναι συνδεδεμένη. Αυτό σας επιτρέπει να αποκρύψετε τη μόνιμη ειδοποίηση \"Δεν υπάρχουν συσκευές\" απενεργοποιώντας το κανάλι \"Κατάσταση συσκευής\".</string>
<string name="settings_keep_notification_after_disconnect_label">Διατήρηση ειδοποίησης μετά την αποσύνδεση</string>
<string name="settings_keep_notification_after_disconnect_description">Συνεχίστε να εμφανίζετε τα τελευταία γνωστά επίπεδα μπαταρίας ακόμα και μετά την αποσύνδεση των AirPods σας</string>
<string name="settings_scanner_mode_label">Λειτουργία σαρωτή</string>
<string name="settings_scanner_mode_description">Ο σαρωτής δεδομένων Bluetooth Low Energy θα πρέπει να δώσει προτεραιότητα στην απόδοση ή στην εξοικονόμηση ενέργειας;</string>
<string name="settings_autopause_label">Αυτόματη παύση</string>
<string name="settings_autopause_description">Παύση ήχου κατά την αφαίρεση της συσκευής από το αυτί σας.</string>
<string name="settings_autopplay_label">Αυτόματη αναπαραγωγή</string>
@@ -222,9 +220,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_scanner_mode_lowpower_label">Χαμηλή ισχύς</string>
<string name="settings_scanner_mode_balanced_label">Ισορροπημένο</string>
<string name="settings_scanner_mode_lowlatency_label">Χαμηλή καθυστέρηση</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Διπλή πίεση για τερματισμό κλήσης</string>
<string name="device_settings_open_cd">Άνοιγμα ρυθμίσεων συσκευής</string>
<string name="overview_card_missing_paired_device">Αυτό το προφίλ δεν έχει συζευγμένη συσκευή Bluetooth.</string>
<string name="overview_reactions_hint_title">Οι αντιδράσεις είναι ανά συσκευή</string>
<string name="overview_reactions_hint_body">Η αυτόματη αναπαραγωγή, η αυτόματη παύση και τα αναδυόμενα παράθυρα διαμορφώνονται πλέον ανά συσκευή. Πατήστε το εικονίδιο ρυθμίσεων σε μια κάρτα ενώ τα ακουστικά σας είναι συνδεδεμένα.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Γενικά</string>
<string name="device_settings_microphone_mode_label">Μικρόφωνο</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Muestra una notificación extra cuando un dispositivo está conectado. Esto te permite ocultar la notificación permanente \"Sin dispositivos\" desactivando el canal \"Estado del dispositivo\".</string>
<string name="settings_keep_notification_after_disconnect_label">Mantener la notificación después de la desconexión</string>
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando los últimos niveles de batería conocidos incluso después de que tus AirPods se desconecten</string>
<string name="settings_scanner_mode_label">Modo escáner</string>
<string name="settings_scanner_mode_description">¿Debería el escáner de datos de Bluetooth de baja energía priorizar el rendimiento o conservar energía?</string>
<string name="settings_autopause_label">Pausa automática</string>
<string name="settings_autopause_description">Pausar el audio al quitar el dispositivo de tu oreja.</string>
<string name="settings_autopplay_label">Reproducción automática</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
<string name="permission_system_alert_window_label">Ventana emergente</string>
<string name="permission_system_alert_window_description">Permite que CAPod se superponga a otras aplicaciones para que la función «Mostrar ventana emergente».</string>
<string name="settings_scanner_mode_lowpower_label">Baja energía</string>
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
<string name="settings_scanner_mode_lowlatency_label">Baja latencia</string>
<string name="settings_monitor_mode_manual_label">Cuando la aplicación está abierta</string>
<string name="settings_monitor_mode_automatic_label">Cuando el dispositivo está conectado</string>
<string name="settings_monitor_mode_always_label">Siempre</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Doble pulsación para finalizar la llamada</string>
<string name="device_settings_open_cd">Abrir configuración del dispositivo</string>
<string name="overview_card_missing_paired_device">Este perfil no tiene ningún dispositivo Bluetooth emparejado.</string>
<string name="overview_reactions_hint_title">Las reacciones son por dispositivo</string>
<string name="overview_reactions_hint_body">La reproducción automática, la pausa automática y las ventanas emergentes ahora se configuran por dispositivo. Tocá el ícono de configuración en una tarjeta mientras tus auriculares estén conectados.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">General</string>
<string name="device_settings_microphone_mode_label">Micrófono</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Muestra una notificación adicional cuando un dispositivo está conectado. Esto te permite ocultar la notificación permanente \"Sin dispositivos\" desactivando el canal \"Estado del dispositivo\".</string>
<string name="settings_keep_notification_after_disconnect_label">Mantener la notificación después de la desconexión</string>
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando los últimos niveles de batería conocidos incluso después de que tus AirPods se desconecten</string>
<string name="settings_scanner_mode_label">Modo escáner</string>
<string name="settings_scanner_mode_description">¿Debería el escáner de datos de Bluetooth de baja energía priorizar el rendimiento o conservar energía?</string>
<string name="settings_autopause_label">Pausa automática</string>
<string name="settings_autopause_description">Pausar el audio al quitar el dispositivo de tu oreja.</string>
<string name="settings_autopplay_label">Reproducción automática</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
<string name="permission_system_alert_window_label">Ventana emergente</string>
<string name="permission_system_alert_window_description">Permite que CAPod se superponga a otras aplicaciones para que la función «Mostrar ventana emergente».</string>
<string name="settings_scanner_mode_lowpower_label">Baja energía</string>
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
<string name="settings_scanner_mode_lowlatency_label">Baja latencia</string>
<string name="settings_monitor_mode_manual_label">Cuando la aplicación está abierta</string>
<string name="settings_monitor_mode_automatic_label">Cuando el dispositivo está conectado</string>
<string name="settings_monitor_mode_always_label">Siempre</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Pulsación doble para terminar la llamada</string>
<string name="device_settings_open_cd">Abrir ajustes del dispositivo</string>
<string name="overview_card_missing_paired_device">Este perfil no tiene ningún dispositivo Bluetooth vinculado.</string>
<string name="overview_reactions_hint_title">Las reacciones son por dispositivo</string>
<string name="overview_reactions_hint_body">La reproducción automática, la pausa automática y las ventanas emergentes ahora se configuran por dispositivo. Toca el ícono de configuración en una tarjeta mientras tus auriculares estén conectados.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">General</string>
<string name="device_settings_microphone_mode_label">Micrófono</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Muestra una notificación adicional cuando un dispositivo está conectado. Esto le permite ocultar la notificación permanente \"Sin dispositivos\" deshabilitando el canal \"Estado del dispositivo\".</string>
<string name="settings_keep_notification_after_disconnect_label">Mantener la notificación después de la desconexión</string>
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando los últimos niveles de batería conocidos incluso después de que tus AirPods se desconecten</string>
<string name="settings_scanner_mode_label">Modo escáner</string>
<string name="settings_scanner_mode_description">¿Debería el escáner de datos de Bluetooth de baja energía priorizar el rendimiento o conservar energía?</string>
<string name="settings_autopause_label">Pausa automática</string>
<string name="settings_autopause_description">Pausar el audio al quitar el dispositivo de tu oído.</string>
<string name="settings_autopplay_label">Reproducción automática</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Se requiere el siguiente permiso:</string>
<string name="permission_system_alert_window_label">Ventana emergente</string>
<string name="permission_system_alert_window_description">Permite que CAPod se superponga a otras aplicaciones para que la función «Mostrar ventana emergente».</string>
<string name="settings_scanner_mode_lowpower_label">Baja energía</string>
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
<string name="settings_scanner_mode_lowlatency_label">Baja latencia</string>
<string name="settings_monitor_mode_manual_label">Cuando la aplicación está abierta</string>
<string name="settings_monitor_mode_automatic_label">Cuando el dispositivo está conectado</string>
<string name="settings_monitor_mode_always_label">Siempre</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Doble pulsación para finalizar llamada</string>
<string name="device_settings_open_cd">Abrir configuración del dispositivo</string>
<string name="overview_card_missing_paired_device">Este perfil no tiene ningún dispositivo Bluetooth emparejado.</string>
<string name="overview_reactions_hint_title">Las reacciones son por dispositivo</string>
<string name="overview_reactions_hint_body">La reproducción automática, la pausa automática y las ventanas emergentes ahora se configuran por dispositivo. Toca el icono de configuración en una tarjeta mientras tus auriculares están conectados.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">General</string>
<string name="device_settings_microphone_mode_label">Micrófono</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Kuvab seadme ühendumisel lisateavituse. See peidab pidevalt nähtava „Seadmed puuduvad“ teate, selleks keelates „Seadme olek“ kanali.</string>
<string name="settings_keep_notification_after_disconnect_label">Kuva teadet ka pärast eemaldamist</string>
<string name="settings_keep_notification_after_disconnect_description">Jätkake viimase teadaoleva akutaseme kuvamist isegi pärast AirPodside eemaldamist</string>
<string name="settings_scanner_mode_label">Skannimisrežiim</string>
<string name="settings_scanner_mode_description">Kas Bluetooth madalama energiatarbimisega andmete skanner eelistab jõudlust või mõõdukat energiahaldust?</string>
<string name="settings_autopause_label">Automaatne peatamine</string>
<string name="settings_autopause_description">Seadme kõrvast eemaldamisel peata esitamine.</string>
<string name="settings_autopplay_label">Automaatne esitamine</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Vaja on järgmist luba:</string>
<string name="permission_system_alert_window_label">Süsteemi teate aken</string>
<string name="permission_system_alert_window_description">Luba CAPodil toimetada teiste rakenduste kohal, et näha hüpikaknaid.</string>
<string name="settings_scanner_mode_lowpower_label">Madal võimsus</string>
<string name="settings_scanner_mode_balanced_label">Tasakaalustatud</string>
<string name="settings_scanner_mode_lowlatency_label">Madal latents</string>
<string name="settings_monitor_mode_manual_label">Kui rakendus on avatud</string>
<string name="settings_monitor_mode_automatic_label">Kui seade on ühendatud</string>
<string name="settings_monitor_mode_always_label">Alati</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Kahe vajutusega lõpeta kõne</string>
<string name="device_settings_open_cd">Ava seadme seaded</string>
<string name="overview_card_missing_paired_device">Sellel profiilil puudub seotud Bluetoothi seade.</string>
<string name="overview_reactions_hint_title">Reaktsioonid on seadmepõhised</string>
<string name="overview_reactions_hint_body">Automaatne esitus, automaatne paus ja hüpikaknad on nüüd seadmepõhiselt seadistatud. Puudutage kaardi seadete ikooni, kui kõrvaklapid on ühendatud.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Üldine</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Gailu bat konektatuta dagoenean jakinarazpen gehigarri bat erakusten du. Honek \"Gailurik ez\" jakinarazpen iraunkorra ezkutatzeko aukera ematen dizu \"Gailuaren egoera\" kanala desgaituz.</string>
<string name="settings_keep_notification_after_disconnect_label">Mantendu jakinarazpena deskonektatu ondoren</string>
<string name="settings_keep_notification_after_disconnect_description">Jarraitu azken bateria-maila ezagunak erakusten zure AirPods-ak deskonektatu ondoren ere</string>
<string name="settings_scanner_mode_label">Eskaner modua</string>
<string name="settings_scanner_mode_description">Bluetooth Energia Baxuko datuen eskanerrak errendimendua lehenetsi behar al du ala energia aurreztu?</string>
<string name="settings_autopause_label">Pausatze automatikoa</string>
<string name="settings_autopause_description">Pausatu audioa gailua belarritik kentzean.</string>
<string name="settings_autopplay_label">Erreprodukzio automatikoa</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Hurrengo baimena beharrezkoa da:</string>
<string name="permission_system_alert_window_label">Sistemaren alerta-leihoa</string>
<string name="permission_system_alert_window_description">Baimendu CAPod-i beste aplikazioen gainean marraztea \"Erakutsi pop-up-a\" ezaugarria posible egiteko.</string>
<string name="settings_scanner_mode_lowpower_label">Potentzia baxua</string>
<string name="settings_scanner_mode_balanced_label">Orekatua</string>
<string name="settings_scanner_mode_lowlatency_label">Latentzia baxua</string>
<string name="settings_monitor_mode_manual_label">Aplikazioa irekita dagoenean</string>
<string name="settings_monitor_mode_automatic_label">Gailua konektatuta dagoenean</string>
<string name="settings_monitor_mode_always_label">Beti</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Bikoitz sakatu deia amaitzeko</string>
<string name="device_settings_open_cd">Ireki gailuaren ezarpenak</string>
<string name="overview_card_missing_paired_device">Profil honek ez du Bluetooth gailurik parekaturik.</string>
<string name="overview_reactions_hint_title">Erreakzioak gailuka konfiguratuta daude</string>
<string name="overview_reactions_hint_body">Erreprodukzio automatikoa, geldialdia automatikoa eta laster-leihoak orain gailuka konfiguratzen dira. Sakatu txarteleko ezarpenak ikonoa aurikularrak konektatuta dituzun bitartean.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Orokorra</string>
<string name="device_settings_microphone_mode_label">Mikrofonoa</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">هنگامی که یک دستگاه متصل است، یک اعلان اضافی نشان می‌دهد. این به شما امکان می‌دهد اعلان دائمی \"بدون دستگاه\" را با غیرفعال کردن کانال \"وضعیت دستگاه\" پنهان کنید.</string>
<string name="settings_keep_notification_after_disconnect_label">حفظ اعلان پس از قطع اتصال</string>
<string name="settings_keep_notification_after_disconnect_description">نمایش آخرین سطوح شارژ شناخته‌شده حتی پس از قطع اتصال AirPods شما</string>
<string name="settings_scanner_mode_label">حالت اسکنر</string>
<string name="settings_scanner_mode_description">آیا اسکنر داده بلوتوث کم‌مصرف باید عملکرد را در اولویت قرار دهد یا انرژی را ذخیره کند؟</string>
<string name="settings_autopause_label">مکث خودکار</string>
<string name="settings_autopause_description">هنگام برداشتن دستگاه از گوش، صدا را متوقف کنید.</string>
<string name="settings_autopplay_label">پخش خودکار</string>
@@ -222,9 +220,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_scanner_mode_lowpower_label">کم‌مصرف</string>
<string name="settings_scanner_mode_balanced_label">متعادل</string>
<string name="settings_scanner_mode_lowlatency_label">تأخیر کم</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">دوبار فشار برای پایان دادن تماس</string>
<string name="device_settings_open_cd">باز کردن تنظیمات دستگاه</string>
<string name="overview_card_missing_paired_device">این پروفایل هیچ دستگاه بلوتوثی جفت‌شده‌ای ندارد.</string>
<string name="overview_reactions_hint_title">واکنش‌ها برای هر دستگاه جداگانه هستند</string>
<string name="overview_reactions_hint_body">پخش خودکار، توقف خودکار و پنجره‌های بازشو اکنون برای هر دستگاه پیکربندی می‌شوند. در حالی که هدفون‌های شما متصل هستند، روی آیکون تنظیمات روی یک کارت ضربه بزنید.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">عمومی</string>
<string name="device_settings_microphone_mode_label">میکروفون</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Näyttää lisäilmoituksen, kun laite on yhdistetty. Tämä antaa sinun piilottaa pysyvän \"Ei laitteita\" -ilmoituksen poistamalla \"Laitteen tila\" -kanavan käytöstä.</string>
<string name="settings_keep_notification_after_disconnect_label">Säilytä ilmoitus yhteyden katkaisun jälkeen</string>
<string name="settings_keep_notification_after_disconnect_description">Jatka viimeisten tunnettujen akkutasojen näyttämistä senkin jälkeen, kun AirPodit katkaisevat yhteyden</string>
<string name="settings_scanner_mode_label">Skanneritila</string>
<string name="settings_scanner_mode_description">Pitäisikö Bluetooth Low Energy -dataskannerin priorisoida suorituskykyä vai säästää energiaa?</string>
<string name="settings_autopause_label">Automaattinen tauko</string>
<string name="settings_autopause_description">Keskeytä ääni, kun poistat laitteen korvastasi.</string>
<string name="settings_autopplay_label">Automaattinen toisto</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Seuraava lupa vaaditaan:</string>
<string name="permission_system_alert_window_label">Järjestelmän hälytysikkuna</string>
<string name="permission_system_alert_window_description">Salli CAPod-sovelluksen piirtää muiden sovellusten päälle mahdollistaakseen \"Näytä ponnahdusikkuna\" -ominaisuuden.</string>
<string name="settings_scanner_mode_lowpower_label">Virransäästö</string>
<string name="settings_scanner_mode_balanced_label">Tasapainoinen</string>
<string name="settings_scanner_mode_lowlatency_label">Matala viive</string>
<string name="settings_monitor_mode_manual_label">Kun sovellus on auki</string>
<string name="settings_monitor_mode_automatic_label">Kun laite on yhdistetty</string>
<string name="settings_monitor_mode_always_label">Aina</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Kaksoispainallus lopettaa puhelun</string>
<string name="device_settings_open_cd">Avaa laiteasetukset</string>
<string name="overview_card_missing_paired_device">Tällä profiililla ei ole yhdistettyä Bluetooth-laitetta.</string>
<string name="overview_reactions_hint_title">Reaktiot ovat laitekohtaisia</string>
<string name="overview_reactions_hint_body">Automaattinen toisto, automaattinen tauko ja ponnahdusikkunat on nyt määritetty laitekohtaisesti. Napauta kortissa olevaa asetuskuvaketta, kun kuulokkeet ovat yhdistettynä.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Yleiset</string>
<string name="device_settings_microphone_mode_label">Mikrofoni</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Nagpapakita ng karagdagang notipikasyon kapag konektado ang isang device. Pinapayagan ka nitong itago ang permanenteng \"Walang mga device\" na notipikasyon sa pamamagitan ng pag-disable sa \"Katayuan ng device\" na channel.</string>
<string name="settings_keep_notification_after_disconnect_label">Panatilihin ang notipikasyon pagkatapos mag-disconnect</string>
<string name="settings_keep_notification_after_disconnect_description">Ipagpatuloy ang pagpapakita ng huling kilalang mga antas ng baterya kahit pagkatapos mag-disconnect ang iyong AirPods</string>
<string name="settings_scanner_mode_label">Mode ng scanner</string>
<string name="settings_scanner_mode_description">Dapat bang unahin ng Bluetooth Low Energy data scanner ang performance o magtipid ng enerhiya?</string>
<string name="settings_autopause_label">Auto pause</string>
<string name="settings_autopause_description">I-pause ang audio kapag inaalis ang device sa iyong tainga.</string>
<string name="settings_autopplay_label">Auto play</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Kailangan ang sumusunod na pahintulot:</string>
<string name="permission_system_alert_window_label">System Alert Window</string>
<string name="permission_system_alert_window_description">Payagan ang CAPod na mag-draw sa ibabaw ng ibang mga app para maging posible ang feature na \"Ipakita ang PopUp\".</string>
<string name="settings_scanner_mode_lowpower_label">Mababang enerhiya</string>
<string name="settings_scanner_mode_balanced_label">Balanse</string>
<string name="settings_scanner_mode_lowlatency_label">Mababang latency</string>
<string name="settings_monitor_mode_manual_label">Kapag bukas ang app</string>
<string name="settings_monitor_mode_automatic_label">Kapag konektado ang device</string>
<string name="settings_monitor_mode_always_label">Palagi</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dalawang pindutin para tapusin ang tawag</string>
<string name="device_settings_open_cd">Buksan ang mga setting ng device</string>
<string name="overview_card_missing_paired_device">Ang profile na ito ay walang naipares na Bluetooth device.</string>
<string name="overview_reactions_hint_title">Ang mga reaksyon ay bawat device</string>
<string name="overview_reactions_hint_body">Ang auto-play, auto-pause at mga pop-up ay naka-configure na ngayon bawat device. I-tap ang icon ng mga setting sa isang card habang nakakonekta ang iyong mga headphone.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Pangkalahatan</string>
<string name="device_settings_microphone_mode_label">Mikropono</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Affiche une notification supplémentaire si un appareil est connecté. Cela vous permet de masquer la notification permanente « Aucun appareil » en désactivant le canal « État de lappareil ».</string>
<string name="settings_keep_notification_after_disconnect_label">Garder la notification après déconnexion</string>
<string name="settings_keep_notification_after_disconnect_description">Afficher les derniers niveaux de batterie connus même après déconnexion de vos AirPods</string>
<string name="settings_scanner_mode_label">Mode analyse</string>
<string name="settings_scanner_mode_description">Lanalyseur des données « basse énergie » de Bluetooth devrait-il prioriser les performances ou préserver l’énergie?</string>
<string name="settings_autopause_label">Pause automatique</string>
<string name="settings_autopause_description">Mettre le son en pause si vous ôtez lappareil de votre oreille.</string>
<string name="settings_autopplay_label">Lecture automatique</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">Lautorisation suivante est nécessaire :</string>
<string name="permission_system_alert_window_label">Fenêtre dalerte système</string>
<string name="permission_system_alert_window_description">Permettre à CAPod dapparaître par-dessus les autres applis pour rendre la fonction « Afficher une fenêtre contextuelle » possible.</string>
<string name="settings_scanner_mode_lowpower_label">Puissance faible</string>
<string name="settings_scanner_mode_balanced_label">Équilibré</string>
<string name="settings_scanner_mode_lowlatency_label">Latence faible</string>
<string name="settings_monitor_mode_manual_label">Si lappli est ouverte</string>
<string name="settings_monitor_mode_automatic_label">Si lappareil est connecté</string>
<string name="settings_monitor_mode_always_label">Toujours</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Double appui pour terminer lappel</string>
<string name="device_settings_open_cd">Ouvrir les paramètres de lappareil</string>
<string name="overview_card_missing_paired_device">Ce profil n\'a aucun appareil Bluetooth associé.</string>
<string name="overview_reactions_hint_title">Les réactions sont par appareil</string>
<string name="overview_reactions_hint_body">La lecture automatique, la pause automatique et les fenêtres contextuelles sont désormais configurées par appareil. Appuyez sur licône des paramètres dune carte lorsque vos écouteurs sont connectés.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Général</string>
<string name="device_settings_microphone_mode_label">Microphone</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Mostra unha notificación adicional cando un dispositivo está conectado. Isto permíteche ocultar a notificación permanente de \"Sen dispositivos\" desactivando a canle \"Estado do dispositivo\".</string>
<string name="settings_keep_notification_after_disconnect_label">Manter notificación tras desconexión</string>
<string name="settings_keep_notification_after_disconnect_description">Seguir mostrando os últimos niveis de batería coñecidos mesmo despois de que os teus AirPods se desconecten</string>
<string name="settings_scanner_mode_label">Modo de escáner</string>
<string name="settings_scanner_mode_description">O escáner de datos Bluetooth de baixa enerxía debería priorizar o rendemento ou conservar enerxía?</string>
<string name="settings_autopause_label">Pausa automática</string>
<string name="settings_autopause_description">Pausar o son ao quitar o dispositivo da orella.</string>
<string name="settings_autopplay_label">Reprodución automática</string>
@@ -222,9 +220,6 @@
<string name="permission_required_title">É necesario o seguinte permiso:</string>
<string name="permission_system_alert_window_label">Xanela de alerta do sistema</string>
<string name="permission_system_alert_window_description">Permitir que CAPod debuxe sobre outras aplicacións para facer posible a función \"Mostrar PopUp\".</string>
<string name="settings_scanner_mode_lowpower_label">Baixo consumo</string>
<string name="settings_scanner_mode_balanced_label">Equilibrado</string>
<string name="settings_scanner_mode_lowlatency_label">Baixa latencia</string>
<string name="settings_monitor_mode_manual_label">Cando a aplicación está aberta</string>
<string name="settings_monitor_mode_automatic_label">Cando o dispositivo está conectado</string>
<string name="settings_monitor_mode_always_label">Sempre</string>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Prema dúas veces para finalizar a chamada</string>
<string name="device_settings_open_cd">Abrir a configuración do dispositivo</string>
<string name="overview_card_missing_paired_device">Este perfil non ten ningún dispositivo Bluetooth emparellado.</string>
<string name="overview_reactions_hint_title">As reaccións son por dispositivo</string>
<string name="overview_reactions_hint_body">A reprodución automática, a pausa automática e as ventás emerxentes agora configúranse por dispositivo. Toca a icona de axustes nunha tarxeta mentres os teus auriculares estean conectados.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Xeral</string>
<string name="device_settings_microphone_mode_label">Micrófono</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">जब कोई डिवाइस कनेक्ट होता है तो एक अतिरिक्त सूचना दिखाता है। यह आपको \"डिवाइस स्थिति\" चैनल को अक्षम करके स्थायी \"कोई डिवाइस नहीं\" सूचना को छिपाने की अनुमति देता है।</string>
<string name="settings_keep_notification_after_disconnect_label">डिस्कनेक्ट होने के बाद सूचना बनाए रखें</string>
<string name="settings_keep_notification_after_disconnect_description">आपके AirPods डिस्कनेक्ट होने के बाद भी अंतिम ज्ञात बैटरी स्तर दिखाना जारी रखें</string>
<string name="settings_scanner_mode_label">स्कैनर मोड</string>
<string name="settings_scanner_mode_description">क्या ब्लूटूथ लो एनर्जी डेटा स्कैनर को प्रदर्शन को प्राथमिकता देनी चाहिए या ऊर्जा बचानी चाहिए?</string>
<string name="settings_autopause_label">ऑटो-पॉज़</string>
<string name="settings_autopause_description">डिवाइस को अपने कान से हटाने पर ऑडियो रोकें।</string>
<string name="settings_autopplay_label">ऑटो-प्ले</string>
@@ -222,9 +220,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_scanner_mode_lowpower_label">कम बिजली</string>
<string name="settings_scanner_mode_balanced_label">संतुलित</string>
<string name="settings_scanner_mode_lowlatency_label">कम विलंब</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>
@@ -470,6 +465,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">कॉल समाप्त करने के लिए दो बार दबाएं</string>
<string name="device_settings_open_cd">डिवाइस सेटिंग्स खोलें</string>
<string name="overview_card_missing_paired_device">इस प्रोफ़ाइल में कोई पेयर किया गया Bluetooth डिवाइस नहीं है।</string>
<string name="overview_reactions_hint_title">रिएक्शन प्रति डिवाइस हैं</string>
<string name="overview_reactions_hint_body">ऑटो-प्ले, ऑटो-पॉज़ और पॉप-अप अब प्रति डिवाइस कॉन्फ़िगर किए गए हैं। जब आपके हेडफ़ोन कनेक्ट हों, तो कार्ड पर सेटिंग आइकन टैप करें।</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">सामान्य</string>
<string name="device_settings_microphone_mode_label">माइक्रोफ़ोन</string>
+2 -5
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">Prikazuje dodatnu obavijest kada je uređaj povezan. To vam omogućuje da sakrijete trajnu obavijest \"Nema uređaja\" onemogućavanjem kanala \"Status uređaja\".</string>
<string name="settings_keep_notification_after_disconnect_label">Zadrži obavijest nakon prekida veze</string>
<string name="settings_keep_notification_after_disconnect_description">Nastavi prikazivati zadnje poznate razine baterije i nakon što se vaši AirPods odspoje</string>
<string name="settings_scanner_mode_label">Način skeniranja</string>
<string name="settings_scanner_mode_description">Treba li Bluetooth Low Energy skener podataka dati prednost performansama ili štedjeti energiju?</string>
<string name="settings_autopause_label">Automatska pauza</string>
<string name="settings_autopause_description">Pauzirajte zvuk kada uklonite uređaj iz uha.</string>
<string name="settings_autopplay_label">Automatska reprodukcija</string>
@@ -224,9 +222,6 @@
<string name="permission_required_title">Sljedeća dozvola je potrebna:</string>
<string name="permission_system_alert_window_label">Prozor sistemskog upozorenja</string>
<string name="permission_system_alert_window_description">Dopustite CAPod-u crtanje preko drugih aplikacija kako bi omogućili značajku \"Prikaži skočni prozor\".</string>
<string name="settings_scanner_mode_lowpower_label">Niska potrošnja</string>
<string name="settings_scanner_mode_balanced_label">Uravnoteženo</string>
<string name="settings_scanner_mode_lowlatency_label">Niska latencija</string>
<string name="settings_monitor_mode_manual_label">Kada je aplikacija otvorena</string>
<string name="settings_monitor_mode_automatic_label">Kada je uređaj povezan</string>
<string name="settings_monitor_mode_always_label">Uvijek</string>
@@ -475,6 +470,8 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dvostruki pritisak za završetak poziva</string>
<string name="device_settings_open_cd">Otvori postavke uređaja</string>
<string name="overview_card_missing_paired_device">Ovaj profil nema uparenog Bluetooth uređaja.</string>
<string name="overview_reactions_hint_title">Reakcije su po uređaju</string>
<string name="overview_reactions_hint_body">Automatska reprodukcija, automatska pauza i skočni prozori sada se konfiguriraju po uređaju. Dodirnite ikonu postavki na kartici dok su vaše slušalice spojene.</string>
<!-- New device settings -->
<string name="device_settings_category_general_label">Općenito</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>

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