Compare commits

...
49 Commits
Author SHA1 Message Date
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
darken 70d9432b2b Release: 5.1.1-rc0 2026-04-25 21:58:56 +02:00
darken 9d06839a1c General: Update app translations from Crowdin 2026-04-25 21:58:10 +02:00
darken 97f294e5f7 fix(widget): Persist theme settings across device reboot
Closes #549
2026-04-25 20:39:40 +02:00
darken a40599c1dc feat(settings): Tailor device settings to connection state
- Hide reactions and AAP sections unless device is classically connected

- Move advanced-settings-unavailable card to the bottom of the list

- Show 'device not nearby' infobox when out of range

- Show missing-paired-device banner with edit-profile action

- Replace pending banner with snackbar on user-initiated change
2026-04-25 19:00:19 +02:00
darken 175aef9e2f feat(logging): Log raw advertisement and decoded payload bytes 2026-04-25 16:19:21 +02:00
darken 36ae35918a refactor(widget): Move WidgetDeviceKey into widget package 2026-04-25 16:19:21 +02:00
darken 60ad1943b6 fix(widget): Gate refresh on display fields, drop log to VERBOSE 2026-04-25 16:19:21 +02:00
darken 5e727e907d refactor: Drop per-emission flow logger and redundant widget logs 2026-04-25 16:19:21 +02:00
darken 5360f54ea8 fix(privacy): Stop logging AirPods encryption keys 2026-04-25 16:19:21 +02:00
darken 486efe6e65 refactor: Reduce debug log noise from redundant state emissions 2026-04-25 16:19:21 +02:00
darken c2ff1f111e chore(reaction): Rename sleep notification channel label 2026-04-25 10:21:30 +02:00
darken 26b72726a7 feat(reaction): Pause media when AirPods detect sleep 2026-04-25 10:21:30 +02:00
darken f35fe968c6 docs(aap): Document SET_BAND_EDGES as RF band gating 2026-04-24 17:54:53 +02:00
darken 498f040a57 feat(aap): Drop experimental warning on Optimized Charge toggle
Verified on a real AirPods Pro 3: toggling flips pod charging state from CHARGING_OPTIMIZED to CHARGING and persists across reconnects. Apple-bool wire format is confirmed, so the 'experimental' warning box is no longer warranted.
2026-04-24 17:27:48 +02:00
darken 59b3106c3b feat(aap): Surface Optimized Charge indicator and Pro 3 toggle
Adds a per-battery 'Optimized' chip on the overview card when pods report wire value 0x05 (CHARGING_OPTIMIZED), which was already decoded but collapsed into a plain 'Charging' in the UI. On AirPods Pro 3, also adds a user-facing toggle for the device-side Optimized Charge Limit (AAP setting 0x3B).

- Decode setting 0x3B via decodeAppleBool so unknown values fall through instead of coercing to false

- Bypass ear-detection queue for SetDynamicEndOfCharge so the toggle works while pods sit in the closed case

- Expose per-slot ChargingState? on PodDevice; StatusChipRow renders 'Optimized' for CHARGING_OPTIMIZED, 'Charging' for CHARGING

- New BatteryCard in device settings with experimental warning (pattern matches Sleep Detection)

- Generic settingRejectedEvents flow alongside the existing offRejectedEvents so the toggle can show a dedicated snackbar on verification failure
2026-04-24 17:27:48 +02:00
darken 89e8b83be4 docs(aap): Revise PME hypothesis to Personal Medical Equipment 2026-04-24 15:27:18 +02:00
darken f0531796dd feat(device-settings): Surface hardware, pending firmware, and bonded dates 2026-04-24 13:08:57 +02:00
darken a701fdfba7 feat(aap): Build a comprehensive AAP protocol catalog
Adopts the Wireshark AAP dissector (pabloaul/apple-wireshark) as a third reference source alongside LibrePods and MagicPodsCore. Catalogues every known message type and control/setting ID, corrects DeviceInfo field labels, and adds sealed AapPacket hierarchy with Connect Response parsing. Case Info probe (Pro 3), Sleep event, and Dynamic End of Charge decoders are in place for future use.
2026-04-24 08:39:46 +02:00
darken 118eaa8d08 feat(widget): Add 1x1 size support to battery widget
Introduce BatteryLayout enum with size-driven dispatch. Drops minWidth from 80dp to 40dp so the battery widget can be placed at one cell. At 1-cell-wide placements render a compact icon+percent stack; wider sizes keep the current NARROW/WIDE layouts.
2026-04-23 16:11:07 +02:00
darken 016914ec05 fix(overview): Replace red triangle with inline missing-pairing banner 2026-04-23 14:17:16 +02:00
darken 0aadec0b75 feat(device-settings): Link AAP-unavailable card to compatibility tracker 2026-04-23 12:53:24 +02:00
darken e6cf507937 chore(claude): Refresh .claude/ docs to match current architecture
Drops stale app-common/Wear OS references, renames migrated classes

(ReactionSettingsFragment/PopUpPodViewFactory/PodMonitor), documents

the BLE/AAP split in monitor/core, the layered AAP stack, Widget

(Glance) and Upgrade subsystems, Navigation3 + legacy helpers. Fixes

localization examples and screenshot pipeline counts.
2026-04-23 08:58:27 +02:00
207 changed files with 9749 additions and 2135 deletions
+5 -8
View File
@@ -4,10 +4,7 @@ Android app that detects and monitors AirPods via Bluetooth LE. Displays battery
## Project Structure ## Project Structure
| Module | Description | Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
|--------|-------------|
| `app/` | Main Android app (FOSS and Google Play flavors) |
| `app-common/` | Shared code between phone and Wear OS apps |
## Build Flavors ## Build Flavors
@@ -20,10 +17,10 @@ Quick build check: `./gradlew assembleFossDebug`
| Path | Contains | | Path | Contains |
|------|----------| |------|----------|
| `app/src/main/java/` | Main app source (activities, fragments, services) | | `app/src/main/java/` | Main app source (Compose screens, services, receivers, monitor, bluetooth, models) |
| `app-common/src/main/java/` | Shared logic (monitor, bluetooth, models) | | `app/src/foss/java/`, `app/src/gplay/java/` | Flavor-specific code (e.g. upgrade/billing) |
| `app/src/main/res/` | Layouts, drawables, strings | | `app/src/main/res/` | Layouts, drawables, strings |
| `app-common/src/test/` | Unit tests | | `app/src/test/`, `app/src/testFoss/`, `app/src/testGplay/` | Unit tests (shared + flavor-specific) |
| `app/build.gradle.kts` | App build config, dependencies, flavors | | `app/build.gradle.kts` | App build config, dependencies, flavors |
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables | | `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
| `fastlane/` | Screenshot generation scripts, Play Store metadata | | `fastlane/` | Screenshot generation scripts, Play Store metadata |
@@ -31,7 +28,6 @@ Quick build check: `./gradlew assembleFossDebug`
## Development Tips ## Development Tips
- Use `assembleFossDebug` as the fastest build variant for iteration - Use `assembleFossDebug` as the fastest build variant for iteration
- Shared code goes in `app-common/`, app-specific code in `app/`
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines - Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
- Always use string resources for user-facing text (see localization rules) - Always use string resources for user-facing text (see localization rules)
- Check `git log --oneline -20` for commit message style before committing - Check `git log --oneline -20` for commit message style before committing
@@ -47,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
- `pull-requests.md` — PR title and description conventions - `pull-requests.md` — PR title and description conventions
- `agent-instructions.md` — Sub-agent delegation and critical thinking - `agent-instructions.md` — Sub-agent delegation and critical thinking
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens - `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
+70 -31
View File
@@ -2,16 +2,22 @@
description: Architecture overview, module structure, key components, data flow, and dependencies description: Architecture overview, module structure, key components, data flow, and dependencies
globs: globs:
- "app/**/*.kt" - "app/**/*.kt"
- "app-common/**/*.kt"
- "**/*.gradle.kts" - "**/*.gradle.kts"
--- ---
# Architecture # Architecture
## Multi-Module Structure ## Single-Module Structure
- **app/**: Main Android application with FOSS and Google Play flavors One Gradle module: `app/`. Source sets:
- **app-common/**: Shared code between main app and Wear OS app
- `main` — shared code (Compose UI, services, monitor, bluetooth, AAP protocol, widgets)
- `foss` / `gplay` — flavor-specific code (e.g. upgrade/billing implementations)
- `debug` — debug-only code including screenshot content composables
- `test` / `testFoss` / `testGplay` — unit tests
- `screenshotTest` — Compose Preview Screenshot tests for Play Store assets
A previous `app-common/` module was merged into `app/` (commit `be8f4919`).
## Core Patterns ## Core Patterns
@@ -22,18 +28,62 @@ globs:
## Key Components ## Key Components
### PodMonitor System ### Device Monitoring
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE `monitor/core/` is split into two data-source siblings that `DeviceMonitor` merges:
- `MonitorControl`: Manages MonitorService lifecycle
- `MonitorService`: Foreground service that continuously scans for AirPods - `monitor/core/ble/BlePodMonitor` — passive BLE scanning; reads Apple advertisement beacons (battery, case state, in-ear, etc.). Works for any pod in range; no pairing required
- `BluetoothEventReceiver`: Handles system Bluetooth events - `monitor/core/aap/` — AAP connection lifecycle layer on top of `AapConnectionManager`:
- `AapLifecycleManager` — starts/stops the AAP subsystem
- `AapAutoConnect` — auto-opens AAP sessions for bonded/known devices
- `AapKeyPersister`, `AapLearnedSettingsPersister` — persist session keys and learned pod settings across app restarts
- `StemConfigSender`, `StemPressReaction`, `AncGestureResolver` — push config and react to stem/HID events
- `monitor/core/cache/DeviceStateCache` — persisted last-known state so profiles still show data when a device is out of range
- `DeviceMonitor` — singleton that `combine`s `BlePodMonitor.devices + AapConnectionManager.allStates + DeviceStateCache + profiles` into unified `PodDevice` objects. ViewModels observe `DeviceMonitor.devices`; they do **not** reach into `BlePodMonitor` or the AAP layer directly
- `MonitorControl` / `MonitorService` — foreground service lifecycle holding the scan awake
- `BluetoothEventReceiver`, `BootCompletedReceiver` — system triggers that wake the service
**BLE vs AAP — what each path gives you:**
| | BLE (advertisements) | AAP (L2CAP session) |
|---|---|---|
| Direction | Read-only, passive | Bidirectional commands + events |
| Prerequisite | Bluetooth on | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
| Data | Battery, case open, in-ear, pod model | Settings, ANC mode control, press controls, stem events, device info |
| Availability | Any pod in range | Only your own paired pods |
### Reaction System ### Reaction System
- `ReactionSettingsFragment`: Configuration for popup notifications - `ReactionsCard`: Compose UI for reaction settings, embedded in the device settings screen
- `PopUpWindow`: Displays AirPods status when case is opened - `PopUpWindow`: Displays AirPods status when case is opened
- `PopUpPodViewFactory`: Creates UI components for different pod models - `PopUpContent`: Compose pod rendering — model-specific UI branches inline, no factory class
### Widget System (Glance)
- `BatteryGlanceWidget`, `AncGlanceWidget`: Jetpack Glance-based home-screen widgets
- `WidgetConfigurationActivity`: Configuration UI launched on widget placement
- Lives under `app/src/main/java/eu/darken/capod/main/ui/widget/`
### Upgrade / Pro Features
- `UpgradeRepo` interface with two flavor implementations:
- `UpgradeRepoGplay` — billing-client backed, includes grace-period handling for interrupted purchases
- `UpgradeControlFoss` — cache/sponsor-backed; users are `isPro = false` until they call `upgrade()`, after which the pro flag is persisted via DataStore
- FOSS is **not** "always pro" — it's opt-in via a local sponsor flow
### AAP (Apple Accessory Protocol) Stack
Three-layer structure under `pods/core/apple/aap/`:
- **`protocol/`** — pure data: `AapMessage`, `AapCommand`, `AapSetting`, `AapDeviceProfile`, `AapDeviceInfo`, `StemPressEvent`, `KeyExchangeResult`. Plus `DefaultAapDeviceProfile` and `Model.Features` capturing per-model capability
- **`engine/`** — session state machine for one connection:
- `AapConnection` — the L2CAP socket wrapper
- `AapSessionEngine` — drives the session lifecycle; tested in `AapSessionEngineTest`
- `AapInboundInterpreter` / `AapOutboundController` — decode incoming messages, encode outgoing
- `AapSettingsCoordinator`, `AapAncController`, `HidTracker`, `AapDeviceInfoDiagnostics` — feature-specific coordinators that sit on top of the session
- **`AapConnectionManager`** (singleton) — owns all open AAP sessions keyed by `BluetoothAddress`, uses `L2capSocketFactory` to create sockets. Consumers don't touch `AapConnection` directly — they call `sendCommand(...)` and observe `allStates`
The monitor-layer glue (`monitor/core/aap/`) described above wires this stack into the foreground service and persists its learned state.
### Common Utilities ### Common Utilities
@@ -54,31 +104,20 @@ globs:
## Data Flow ## Data Flow
The app follows a unidirectional data flow: 1. `BluetoothEventReceiver` / `BootCompletedReceiver` wake `MonitorService` (foreground)
2. `MonitorService` keeps `BlePodMonitor` scanning (passive advertisements) and `AapLifecycleManager` running (active L2CAP sessions via `AapConnectionManager`)
1. `BluetoothEventReceiver` detects Bluetooth events 3. `DeviceMonitor` merges BLE + AAP + cached state + profiles into `PodDevice` objects
2. `MonitorService` scans for AirPods beacon data 4. ViewModels (`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel`, widget view models) observe `DeviceMonitor.devices`; settings/command changes are sent back through `AapConnectionManager.sendCommand(...)`
3. `PodMonitor` processes and stores device information 5. Reaction triggers (case-open popup, auto-play, notifications) and widget state updates react to the merged flow
4. ViewModels observe monitor data via repositories
5. UI components react to ViewModel state changes
6. `ReactionSystem` triggers popups and notifications
## Bluetooth LE Implementation
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in `MonitorService` which runs as a foreground service.
## Multi-Platform Considerations
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure compatibility across both platforms.
## Testing Strategy ## Testing Strategy
- **Unit Tests**: Located in `app-common/src/test/` for shared logic - **Unit Tests**: `app/src/test/` (shared), `app/src/testFoss/`, `app/src/testGplay/` (flavor-specific — e.g. `UpgradeRepoGplayTest`, `FossUpgradeSerializationTest`)
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants - **Screenshot Tests**: `app/src/screenshotTest/` — Compose Preview Screenshot Testing, powers the Play Store screenshot pipeline
## Key Dependencies ## Key Dependencies
- **Hilt**: Dependency injection framework - **Hilt**: Dependency injection framework
- **AndroidX Navigation**: Fragment navigation with SafeArgs - **Navigation**: Navigation3 (`addNavigation3()`) drives current Compose screen routing. Some legacy `androidx.navigation` helpers still exist (`NavDirectionsExtensions`, `ViewModel3`) — don't assume SafeArgs is fully gone
- **kotlinx.serialization**: JSON serialization for configuration and caching - **kotlinx.serialization**: JSON serialization for configuration and caching
- **Material Design**: UI components following Material Design guidelines - **Material Design 3**: Compose Material3 UI components
+1
View File
@@ -21,6 +21,7 @@ Use the existing commit history as reference. Common prefixes:
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`) - **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
- **feat**: New features - **feat**: New features
- **refactor**: Code restructuring without behavior change - **refactor**: Code restructuring without behavior change
- **ui**: Visual/layout-only tweaks that aren't a full feature or refactor (e.g., `ui(overview): ...`)
- **chore**: Maintenance, dependency updates, build config - **chore**: Maintenance, dependency updates, build config
- **docs**: Documentation changes - **docs**: Documentation changes
+5 -3
View File
@@ -9,7 +9,7 @@ globs:
When adding new user-facing strings: When adding new user-facing strings:
- **Always use string resources**: Never hardcode user-facing text in layouts or code - **Always use string resources**: Never hardcode user-facing text in layouts or code
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_bluetooth_enabled`) - **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_monitor_mode_label`)
- **Provide context**: String names should indicate usage and location - **Provide context**: String names should indicate usage and location
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary - **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
@@ -17,5 +17,7 @@ When adding new user-facing strings:
- `profiles_create_title` (screen title) - `profiles_create_title` (screen title)
- `profiles_name_label` (form field label) - `profiles_name_label` (form field label)
- `profiles_delete_confirmation` (dialog message) - `profiles_name_default` (default value)
- `error_network_unavailable` (error message) - `troubleshooter_ble_result_failure_title` (status/error title)
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
+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_ID` — numeric ID of the `d4rken-org-releaser` GitHub App
- `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`.
+13 -6
View File
@@ -19,16 +19,17 @@ ScreenshotContent.kt (mock data + composables)
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (8 screens) | | `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (7 screens) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions that wire content to locale annotations | | `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) | | `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
| `fastlane/generate_screenshots.sh` | Batched generation across 68 locales | | `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure | | `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
## Commands ## Commands
```bash ```bash
# Full run — all 68 locales, ~12 batches, ~7 minutes # 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 ./fastlane/generate_screenshots.sh
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch # Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
@@ -37,7 +38,7 @@ ScreenshotContent.kt (mock data + composables)
# Copy into fastlane directories (run after generate) # Copy into fastlane directories (run after generate)
./fastlane/copy_screenshots.sh ./fastlane/copy_screenshots.sh
# Clean copy (removes old screenshots first) # Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
./fastlane/copy_screenshots.sh --clean ./fastlane/copy_screenshots.sh --clean
``` ```
@@ -49,6 +50,12 @@ ScreenshotContent.kt (mock data + composables)
4. Update the expected count in `generate_screenshots.sh` (composables per locale) 4. Update the expected count in `generate_screenshots.sh` (composables per locale)
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh` 5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
## 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
## After UI Changes ## After UI Changes
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate: When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
@@ -60,7 +67,7 @@ When modifying a screen that appears in screenshots (check `ScreenshotContent.kt
## Technical Notes ## Technical Notes
- Batch size defaults to 2 locales (16 renders) to avoid layoutlib memory leak (~10MB/image) - Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
- Gradle daemon is stopped between batches to release memory - Gradle daemon is stopped between batches to release memory
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap - `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone) - Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
-9
View File
@@ -31,12 +31,3 @@ runs:
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }} key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gradle-caches- ${{ 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-
+23
View File
@@ -79,3 +79,26 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Validate metadata lengths - 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
+58
View File
@@ -0,0 +1,58 @@
name: Deploy GitHub Pages
on:
push:
branches: [ main ]
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:
app-id: ${{ secrets.RELEASE_APP_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: Re-validate after approval wait
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
- name: Re-check tag collision (state may have moved during approval)
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: permissions:
contents: read contents: read
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs: 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: release-github:
needs: validate-tag
name: Create GitHub release name: Create GitHub release
permissions: permissions:
contents: write contents: write
actions: write
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
environment: foss-production environment: foss-production
steps: steps:
@@ -61,7 +104,7 @@ jobs:
- name: Create pre-release - name: Create pre-release
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) 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: with:
prerelease: true prerelease: true
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
@@ -73,7 +116,7 @@ jobs:
- name: Create release - name: Create release
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)" 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: with:
prerelease: false prerelease: false
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
@@ -83,10 +126,19 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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: release-gplay:
needs: validate-tag
name: Create Google Play release name: Create Google Play release
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
environment: gplay-production environment: gplay-production
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
steps: steps:
- name: Decode Keystore - name: Decode Keystore
env: env:
@@ -122,6 +174,10 @@ jobs:
with: with:
ruby-version: 3.3.6 ruby-version: 3.3.6
bundler-cache: true bundler-cache: true
working-directory: fastlane
- name: Verify fastlane Bundler wiring
run: bundle exec fastlane --version
- name: Assemble beta and upload to Google Play - name: Assemble beta and upload to Google Play
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
+3
View File
@@ -19,3 +19,6 @@
app/src/screenshotTest*/reference/ app/src/screenshotTest*/reference/
.codex .codex
protocol-research/ protocol-research/
_site/
.jekyll-cache/
vendor/bundle/
+1 -1
View File
@@ -1 +1 @@
5.1.0-rc0 50100000 5.1.2-rc1 50102010
-3
View File
@@ -20,9 +20,6 @@ exclude:
- buildSrc - buildSrc
- gradle/wrapper - gradle/wrapper
- fastlane - fastlane
- Gemfile
- Gemfile.lock
- crowdin* - crowdin*
- app - app
- app-common
- CONTRIBUTING.md - CONTRIBUTING.md
@@ -118,6 +118,7 @@ internal fun DeviceSettingsReactionsContent() = PreviewWrapper {
now = MOCK_NOW, now = MOCK_NOW,
isPro = true, isPro = true,
isNudgeAvailable = true, isNudgeAvailable = true,
isClassicallyConnected = true,
), ),
onNavigateUp = {}, onNavigateUp = {},
) )
+14 -7
View File
@@ -10,22 +10,26 @@ import eu.darken.capod.common.debug.logging.LogCatLogger
import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR import eu.darken.capod.common.debug.logging.Logging.Priority.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.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.main.ui.widget.toWidgetKey
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.devicesWithProfiles import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -42,17 +46,20 @@ open class App : Application() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger()) if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
var foregroundExceptionHandled = false val foregroundExceptionHandled = AtomicBoolean(false)
val oldHandler = Thread.getDefaultUncaughtExceptionHandler() val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) { val isTimingExc = throwable.isForegroundServiceTimingException()
foregroundExceptionHandled = true val isMain = thread === Looper.getMainLooper().thread
if (isTimingExc && isMain && foregroundExceptionHandled.compareAndSet(false, true)) {
runCatching {
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" } log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable) Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
}
Looper.loop() Looper.loop()
return@setDefaultUncaughtExceptionHandler return@setDefaultUncaughtExceptionHandler
} }
log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } runCatching { log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1) if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
} }
@@ -63,10 +70,10 @@ open class App : Application() {
appScope.launch { widgetManager.refreshWidgets() } appScope.launch { widgetManager.refreshWidgets() }
deviceMonitor.devicesWithProfiles() deviceMonitor.devicesWithProfiles()
.distinctUntilChanged() .distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
.throttleLatest(1000) .throttleLatest(1000)
.onEach { .onEach {
log(TAG) { "Main device changed, refreshing widgets." } log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
widgetManager.refreshWidgets() widgetManager.refreshWidgets()
} }
.launchIn(appScope) .launchIn(appScope)
@@ -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")
}
}
@@ -32,14 +32,25 @@ class MediaControl @Inject constructor(
clearRecentCapPause() clearRecentCapPause()
} }
suspend fun sendPause() { /**
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
*
* Returns `true` when a key event was actually dispatched (and the 15-second
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
* cooldown on a real pause — should branch on the return value rather than checking
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
*/
suspend fun sendPause(): Boolean {
log(TAG, INFO) { "sendPause()" } log(TAG, INFO) { "sendPause()" }
if (!audioManager.isMusicActive) { if (!audioManager.isMusicActive) {
log(TAG, INFO) { "Music is not playing, not sending pause" } log(TAG, INFO) { "Music is not playing, not sending pause" }
return return false
} }
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE) sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause() markRecentCapPause()
return true
} }
suspend fun sendPlayPause() { suspend fun sendPlayPause() {
@@ -318,6 +318,74 @@ class BluetoothManager2 @Inject constructor(
emit(wrappedDevices) 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 private var _isNudgeAvailable: Boolean = true
val isNudgeAvailable: Boolean get() = _isNudgeAvailable val isNudgeAvailable: Boolean get() = _isNudgeAvailable
@@ -1,25 +1,11 @@
package eu.darken.capod.common.bluetooth package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class ScannerMode( enum class ScannerMode {
val identifier: String, @SerialName("scanner.mode.lowpower") LOW_POWER,
@StringRes val labelRes: Int @SerialName("scanner.mode.balanced") BALANCED,
) { @SerialName("scanner.mode.lowlatency") LOW_LATENCY,
@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
),
} }
@@ -255,11 +255,42 @@ object MockPodDataProvider {
aap = null, aap = null,
) )
/** Dual pods matched to a profile that has no paired Bluetooth device selected. */
fun dualPodMissingPairedDevice(): PodDevice = PodDevice(
profileId = "preview-dual-missing-paired",
ble = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.80f,
batteryRightPodPercent = 0.45f,
_batteryCasePercent = 0.60f,
_isLeftPodCharging = true,
leftPodIcon = R.drawable.device_airpods_pro2_left,
rightPodIcon = R.drawable.device_airpods_pro2_right,
_caseIcon = R.drawable.device_airpods_pro2_case,
),
aap = null,
)
/** Single pod matched to a profile that has no paired Bluetooth device selected. */
fun singlePodMissingPairedDevice(): PodDevice = PodDevice(
profileId = "preview-single-missing-paired",
ble = MockSingleBlePodSnapshot(
_model = PodModel.AIRPODS_MAX,
_label = "AirPods Max",
batteryHeadsetPercent = 0.85f,
_isBeingWorn = true,
),
aap = null,
)
/** Cached-only dual pod — device fully offline, showing last known state. */ /** Cached-only dual pod — device fully offline, showing last known state. */
fun dualPodCachedOnly(): PodDevice = PodDevice( fun dualPodCachedOnly(): PodDevice = PodDevice(
profileId = "preview-cached", profileId = "preview-cached",
ble = null, ble = null,
aap = null, aap = null,
profileAddress = "AA:BB:CC:DD:EE:FF",
profileModel = PodModel.AIRPODS_PRO2,
cached = CachedDeviceState( cached = CachedDeviceState(
profileId = "preview-cached", profileId = "preview-cached",
model = PodModel.AIRPODS_PRO2, model = PodModel.AIRPODS_PRO2,
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
@@ -54,7 +53,6 @@ fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhi
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } } .onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } } .onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
.catch { .catch {
if (it.hasCause(CancellationException::class)) { if (it.hasCause(CancellationException::class)) {
@@ -9,7 +9,6 @@ import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.bluetooth.BluetoothAddress 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.datastore.createValue
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.serialization.SerializationCapod import eu.darken.capod.common.serialization.SerializationCapod
@@ -44,8 +43,6 @@ class GeneralSettings @Inject constructor(
val keepConnectedNotificationAfterDisconnect = val keepConnectedNotificationAfterDisconnect =
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false) 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 oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>( val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
@@ -79,6 +76,8 @@ class GeneralSettings @Inject constructor(
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false) val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
val themeMode = dataStore.createValue( val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json, "core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -41,14 +42,17 @@ import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsInfoBox import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
import eu.darken.capod.main.ui.devicesettings.cards.rememberDeviceInfoDetailLabels
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
@@ -61,6 +65,10 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import java.time.Duration import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable @Composable
fun DeviceSettingsScreenHost( fun DeviceSettingsScreenHost(
@@ -78,6 +86,19 @@ fun DeviceSettingsScreenHost(
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) } var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
val hasPendingSettings = state?.device?.hasPendingSettings
var lastPendingState by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(hasPendingSettings) {
val current = hasPendingSettings ?: return@LaunchedEffect
val previous = lastPendingState
lastPendingState = current
if (previous == false && current) {
snackbarHostState.showSnackbar(pendingInfoMessage)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
vm.events.collect { event -> vm.events.collect { event ->
@@ -99,6 +120,10 @@ fun DeviceSettingsScreenHost(
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> { DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
snackbarHostState.showSnackbar(offRejectedMessage) snackbarHostState.showSnackbar(offRejectedMessage)
} }
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
}
} }
} }
} }
@@ -133,8 +158,10 @@ fun DeviceSettingsScreenHost(
onListeningModeCycleChange = { vm.setListeningModeCycle(it) }, onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
onAllowOffOptionChange = { vm.setAllowOffOption(it) }, onAllowOffOptionChange = { vm.setAllowOffOption(it) },
onSleepDetectionChange = { vm.setSleepDetection(it) }, onSleepDetectionChange = { vm.setSleepDetection(it) },
onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) },
onDeviceNameChange = { vm.setDeviceName(it) }, onDeviceNameChange = { vm.setDeviceName(it) },
onPressControlsClick = { vm.navToPressControls() }, onPressControlsClick = { vm.navToPressControls() },
onEditProfile = { vm.navToEditProfile() },
onForceConnect = { vm.forceConnect() }, onForceConnect = { vm.forceConnect() },
onUpgrade = { vm.launchUpgrade() }, onUpgrade = { vm.launchUpgrade() },
onOnePodModeChange = { vm.setOnePodMode(it) }, onOnePodModeChange = { vm.setOnePodMode(it) },
@@ -146,6 +173,7 @@ fun DeviceSettingsScreenHost(
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) }, onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onFixMonitorMode = { vm.setMonitorModeAutomatic() }, onFixMonitorMode = { vm.setMonitorModeAutomatic() },
onOpenIssueTracker = { vm.openIssueTracker() }, onOpenIssueTracker = { vm.openIssueTracker() },
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
) )
} }
@@ -168,8 +196,10 @@ fun DeviceSettingsScreen(
onListeningModeCycleChange: (Int) -> Unit = {}, onListeningModeCycleChange: (Int) -> Unit = {},
onAllowOffOptionChange: (Boolean) -> Unit = {}, onAllowOffOptionChange: (Boolean) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {}, onSleepDetectionChange: (Boolean) -> Unit = {},
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
onDeviceNameChange: (String) -> Unit = {}, onDeviceNameChange: (String) -> Unit = {},
onPressControlsClick: () -> Unit = {}, onPressControlsClick: () -> Unit = {},
onEditProfile: () -> Unit = {},
onForceConnect: () -> Unit = {}, onForceConnect: () -> Unit = {},
onUpgrade: () -> Unit = {}, onUpgrade: () -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {}, onOnePodModeChange: (Boolean) -> Unit = {},
@@ -181,6 +211,7 @@ fun DeviceSettingsScreen(
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {}, onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {}, onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {}, onOpenIssueTracker: () -> Unit = {},
onOpenAapTracker: () -> Unit = {},
) { ) {
val device = state.device val device = state.device
val features = device?.model?.features val features = device?.model?.features
@@ -237,86 +268,18 @@ fun DeviceSettingsScreen(
device.firstSeenFormatted(state.now) device.firstSeenFormatted(state.now)
} else null } else null
val info = device.deviceInfo val info = device.deviceInfo
val detailItems = buildList<DeviceDetailItem> { val locale = LocalConfiguration.current.locales[0]
if (info != null) { val zoneId = ZoneId.systemDefault()
if (info.manufacturer.isNotBlank()) { val dateFormatter = remember(locale, zoneId) {
add( DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
DeviceDetailItem.Single( .withLocale(locale)
stringResource(R.string.device_settings_info_manufacturer_label), .withZone(zoneId)
info.manufacturer
)
)
} }
if (info.serialNumber.isNotBlank()) { val detailItems = buildDeviceInfoDetailItems(
add( info = info,
DeviceDetailItem.Single( labels = rememberDeviceInfoDetailLabels(),
stringResource(R.string.device_settings_info_serial_label), formatDate = { instant -> dateFormatter.format(instant) },
info.serialNumber
) )
)
}
val hasFirmware = info.firmwareVersion.isNotBlank()
val hasBuild = !info.buildNumber.isNullOrBlank()
if (hasFirmware && hasBuild) {
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.buildNumber!!
),
)
)
} else if (hasFirmware) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_firmware_label),
info.firmwareVersion
)
)
} else if (hasBuild) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_build_label),
info.buildNumber!!
)
)
}
val hasLeft = !info.leftEarbudSerial.isNullOrBlank()
val hasRight = !info.rightEarbudSerial.isNullOrBlank()
if (hasLeft && hasRight) {
add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
),
end = DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
),
)
)
} else if (hasLeft) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_left_serial_label),
info.leftEarbudSerial!!
)
)
} else if (hasRight) {
add(
DeviceDetailItem.Single(
stringResource(R.string.device_settings_info_right_serial_label),
info.rightEarbudSerial!!
)
)
}
}
}
DeviceInfoCard( DeviceInfoCard(
deviceInfo = device.deviceInfo, deviceInfo = device.deviceInfo,
modelLabel = buildModelLabel(device), modelLabel = buildModelLabel(device),
@@ -331,15 +294,33 @@ fun DeviceSettingsScreen(
} }
} }
// Not connected info — BLE live but no AAP connection // Profile has no paired Bluetooth device — supersedes all other state cards
if (device != null && device.ble != null && !device.isAapConnected && device.address != null) { if (device != null && !device.hasSelectedPairedDevice) {
if (state.isClassicallyConnected) { item("missing_paired_device") {
// Device is connected for audio but AAP isn't available — show passive info MissingPairedDeviceBanner(
item("aap_unavailable_info") { onClick = onEditProfile,
AapUnavailableCard() modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} }
} else { }
// Device is nearby but not connected — prompt user to connect
// Not nearby — no live BLE; settings require the device to be present
if (device != null && device.hasSelectedPairedDevice &&
device.ble == null && !state.isClassicallyConnected
) {
item("not_nearby_info") {
SettingsInfoBox(
title = stringResource(R.string.device_settings_not_nearby_label),
text = stringResource(R.string.device_settings_not_nearby_description),
)
}
}
// Not connected info — device is nearby but not connected; prompt user to connect
if (device != null && device.hasSelectedPairedDevice &&
device.ble != null && !device.isAapConnected && device.address != null &&
!state.isClassicallyConnected
) {
item("not_connected_info") { item("not_connected_info") {
NotConnectedCard( NotConnectedCard(
isNudgeAvailable = state.isNudgeAvailable, isNudgeAvailable = state.isNudgeAvailable,
@@ -348,10 +329,11 @@ fun DeviceSettingsScreen(
) )
} }
} }
}
// ── Reactions (per-profile, not gated on AAP) ───────────────── // ── Reactions (gated on classic connection — needs phone to be the audio target) ──
if (device != null && features != null) { if (device != null && device.hasSelectedPairedDevice &&
features != null && state.isClassicallyConnected
) {
item("reactions_section") { item("reactions_section") {
ReactionsCard( ReactionsCard(
device = device, device = device,
@@ -374,15 +356,7 @@ fun DeviceSettingsScreen(
} }
// Settings — only show when AAP is connected // Settings — only show when AAP is connected
if (features != null && device.isAapConnected) { if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
if (device.hasPendingSettings == true) {
item("pending_info") {
SettingsInfoBox(
text = stringResource(R.string.device_settings_pending_info),
)
}
}
// ── Noise Control ──────────────────────────── // ── Noise Control ────────────────────────────
if (features.hasAncControl && device.ancMode != null) { if (features.hasAncControl && device.ancMode != null) {
@@ -449,6 +423,18 @@ fun DeviceSettingsScreen(
} }
} }
// ── Battery ──────────────────────────────────
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
item("battery_section") {
BatteryCard(
device = device,
features = features,
enabled = enabled,
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
)
}
}
// ── Connections ─────────────────────────────── // ── Connections ───────────────────────────────
val connectedDevices = device.connectedDevices val connectedDevices = device.connectedDevices
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) { if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
@@ -462,13 +448,22 @@ fun DeviceSettingsScreen(
} }
} }
// EQ visualization (debug only) // PME Config visualization (debug only, opcode 0x53).
val eqBands = device.eqBands // PME = Personal Medical Equipment (hearing-aid band gains for iOS
if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) { // 18.1+ AirPods Pro 2 hearing-aid mode). The bar chart reuses the
// EQ visualization because the data shape (per-ear × per-profile ×
// band gains) renders identically; hidden on all-zero payloads, which
// just mean the user hasn't configured a hearing-aid profile yet.
val pmeConfig = device.pmeConfig
if (eu.darken.capod.BuildConfig.DEBUG &&
pmeConfig != null &&
pmeConfig.sets.isNotEmpty() &&
!pmeConfig.isAllZero
) {
item("eq_section") { item("eq_section") {
SettingsSection(title = stringResource(R.string.device_settings_eq_label)) { SettingsSection(title = stringResource(R.string.device_settings_eq_label)) {
EqBarsChart( EqBarsChart(
sets = eqBands.sets, sets = pmeConfig.sets,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
} }
@@ -476,6 +471,16 @@ fun DeviceSettingsScreen(
} }
} }
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
if (device != null && device.hasSelectedPairedDevice &&
device.ble != null && !device.isAapConnected && device.address != null &&
state.isClassicallyConnected
) {
item("aap_unavailable_info") {
AapUnavailableCard(onOpenTracker = onOpenAapTracker)
}
}
item("bottom_spacer") { item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
@@ -496,6 +501,12 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
manufacturer = "Apple Inc.", manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04", serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305", firmwareVersion = "7A305",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454624",
leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
), ),
settings = mapOf( settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode( AapSetting.AncMode::class to AapSetting.AncMode(
@@ -520,11 +531,13 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS, muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
), ),
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = true),
), ),
), ),
), ),
now = MOCK_NOW, now = MOCK_NOW,
isPro = isPro, isPro = isPro,
isClassicallyConnected = true,
) )
@Preview2 @Preview2
@@ -590,3 +603,15 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
onNavigateUp = {}, onNavigateUp = {},
) )
} }
@Preview2
@Composable
private fun DeviceSettingsMissingPairedDevicePreview() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = MockPodDataProvider.dualPodMissingPairedDevice(),
now = MOCK_NOW,
),
onNavigateUp = {},
)
}
@@ -79,6 +79,7 @@ class DeviceSettingsViewModel @Inject constructor(
data class SendFailed(val command: AapCommand, val message: String?) : Event data class SendFailed(val command: AapCommand, val message: String?) : Event
data object SystemRenameUnavailable : Event data object SystemRenameUnavailable : Event
data object OffModeRejectedByDevice : Event data object OffModeRejectedByDevice : Event
data object DynamicEndOfChargeRejectedByDevice : Event
} }
val events = SingleEventFlow<Event>() val events = SingleEventFlow<Event>()
@@ -91,6 +92,16 @@ class DeviceSettingsViewModel @Inject constructor(
} }
} }
} }
launch {
aapManager.settingRejectedEvents.collect { (address, command) ->
if (address != currentAddress()) return@collect
when (command) {
is AapCommand.SetDynamicEndOfCharge ->
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
}
}
}
} }
val state = targetProfileId.flatMapLatest { profileId -> val state = targetProfileId.flatMapLatest { profileId ->
@@ -283,7 +294,16 @@ class DeviceSettingsViewModel @Inject constructor(
} }
} }
fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled)) fun setSleepDetection(enabled: Boolean) = launch {
log(TAG, INFO) { "setSleepDetection($enabled)" }
if (enabled && !upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
sendInternal(AapCommand.SetSleepDetection(enabled))
}
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
fun setDeviceName(name: String) = launch { fun setDeviceName(name: String) = launch {
val address = currentAddress() ?: return@launch val address = currentAddress() ?: return@launch
@@ -419,9 +439,20 @@ class DeviceSettingsViewModel @Inject constructor(
fun navToPressControls() = launch { fun navToPressControls() = launch {
log(TAG, INFO) { "navToPressControls()" } log(TAG, INFO) { "navToPressControls()" }
val profileId = targetProfileId.value ?: return@launch 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)) navTo(Nav.Main.PressControls(profileId = profileId))
} }
fun navToEditProfile() = launch {
log(TAG, INFO) { "navToEditProfile()" }
val profileId = targetProfileId.value ?: return@launch
navTo(Nav.Main.DeviceProfileCreation(profileId = profileId))
}
fun launchUpgrade() { fun launchUpgrade() {
log(TAG, INFO) { "launchUpgrade()" } log(TAG, INFO) { "launchUpgrade()" }
navTo(Nav.Main.Upgrade) navTo(Nav.Main.Upgrade)
@@ -431,6 +462,10 @@ class DeviceSettingsViewModel @Inject constructor(
webpageTool.open("https://github.com/d4rken-org/capod/issues") webpageTool.open("https://github.com/d4rken-org/capod/issues")
} }
fun openAapCompatibilityTracker() {
webpageTool.open("https://github.com/d4rken-org/capod/issues/538")
}
companion object { companion object {
private val TAG = logTag("DeviceSettings", "VM") private val TAG = logTag("DeviceSettings", "VM")
private const val OFF_BIT = 0x01 private const val OFF_BIT = 0x01
@@ -9,7 +9,9 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -18,7 +20,9 @@ import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
@Composable @Composable
internal fun AapUnavailableCard() { internal fun AapUnavailableCard(
onOpenTracker: () -> Unit = {},
) {
ElevatedCard( ElevatedCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -36,6 +40,13 @@ internal fun AapUnavailableCard() {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onOpenTracker,
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.device_settings_aap_unavailable_action))
}
} }
} }
} }
@@ -0,0 +1,66 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
/**
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
* used by every other boolean setting.
*/
@Composable
internal fun BatteryCard(
device: PodDevice,
features: PodModel.Features,
enabled: Boolean,
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
) {
if (!features.hasDynamicEndOfCharge) return
val cap = device.dynamicEndOfCharge ?: return
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.device_settings_charge_cap_label),
subtitle = stringResource(R.string.device_settings_charge_cap_description),
checked = cap.enabled,
onCheckedChange = onDynamicEndOfChargeChange,
enabled = enabled,
)
}
}
@Preview2
@Composable
private fun BatteryCardEnabledPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = true,
)
}
@Preview2
@Composable
private fun BatteryCardDisabledPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
BatteryCard(
device = device,
features = PodModel.Features(hasDynamicEndOfCharge = true),
enabled = false,
)
}
@@ -18,6 +18,11 @@ import androidx.compose.ui.unit.dp
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable @Composable
internal fun DeviceInfoBottomSheet( internal fun DeviceInfoBottomSheet(
@@ -28,6 +33,12 @@ internal fun DeviceInfoBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) { ) {
DeviceInfoDetailsContent(items)
}
}
@Composable
private fun DeviceInfoDetailsContent(items: List<DeviceDetailItem>) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -58,27 +69,74 @@ internal fun DeviceInfoBottomSheet(
} }
} }
} }
} }
private val previewDateFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneOffset.UTC)
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreviewSameDayPairing() = PreviewWrapper {
val pairedAt = Instant.parse("2023-10-16T12:00:00Z")
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "81.2675000075000000.6814",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454768",
leftEarbudFirstPaired = pairedAt,
rightEarbudFirstPaired = pairedAt,
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
} }
@Preview2 @Preview2
@Composable @Composable
private fun DeviceInfoBottomSheetPreview() = PreviewWrapper { private fun DeviceInfoBottomSheetPreviewMismatchedPairing() = PreviewWrapper {
Column(modifier = Modifier.padding(16.dp)) { val info = AapDeviceInfo(
Text( name = "AirPods Pro",
text = "Device Details", modelNumber = "A2699",
style = MaterialTheme.typography.titleMedium, manufacturer = "Apple Inc.",
modifier = Modifier.padding(bottom = 12.dp), serialNumber = "W5J7KV0N04",
firmwareVersion = "81.2675000075000000.6814",
firmwareVersionPending = "82.1000000075000000.7000",
hardwareVersion = "1.0.0",
leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0",
marketingVersion = "8454768",
leftEarbudFirstPaired = Instant.parse("2024-02-14T12:00:00Z"),
rightEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
)
}
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreviewOneSidedPairing() = PreviewWrapper {
val info = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
leftEarbudSerial = "H3KL7HR926JY",
leftEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
)
DeviceInfoDetailsContent(
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
previewDateFormatter.format(it)
},
) )
InfoRow(label = "Manufacturer", value = "Apple Inc.")
InfoRow(label = "Serial Number", value = "W5J7KV0N04")
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Firmware", value = "7A305", modifier = Modifier.weight(1f))
InfoRow(label = "Build", value = "8454624", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Left Pod Serial", value = "H3KL7HR926JY", modifier = Modifier.weight(1f))
InfoRow(label = "Right Pod Serial", value = "H3KL2AYL26K0", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
}
} }
@@ -252,7 +252,7 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
firmwareVersion = "7A305", firmwareVersion = "7A305",
leftEarbudSerial = "H3KL7HR926JY", leftEarbudSerial = "H3KL7HR926JY",
rightEarbudSerial = "H3KL2AYL26K0", rightEarbudSerial = "H3KL2AYL26K0",
buildNumber = "8454624", marketingVersion = "8454624",
), ),
modelLabel = "AirPods Pro 2 (A2699)", modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro", systemBluetoothName = "AirPods Pro",
@@ -0,0 +1,86 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import java.time.Instant
@Composable
internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
manufacturer = stringResource(R.string.device_settings_info_manufacturer_label),
hardware = stringResource(R.string.device_settings_info_hardware_label),
serial = stringResource(R.string.device_settings_info_serial_label),
firmware = stringResource(R.string.device_settings_info_firmware_label),
firmwarePending = stringResource(R.string.device_settings_info_firmware_pending_label),
build = stringResource(R.string.device_settings_info_build_label),
leftSerial = stringResource(R.string.device_settings_info_left_serial_label),
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
)
internal data class DeviceInfoDetailLabels(
val manufacturer: String,
val hardware: String,
val serial: String,
val firmware: String,
val firmwarePending: String,
val build: String,
val leftSerial: String,
val rightSerial: String,
val leftBonded: String,
val rightBonded: String,
)
internal fun buildDeviceInfoDetailItems(
info: AapDeviceInfo?,
labels: DeviceInfoDetailLabels,
formatDate: (Instant) -> String,
): List<DeviceDetailItem> {
if (info == null) return emptyList()
return buildList {
info.manufacturer.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.manufacturer, it))
}
info.hardwareVersion?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.hardware, it))
}
info.serialNumber.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.serial, it))
}
info.firmwareVersion.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.firmware, it))
}
info.firmwareVersionPending?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.firmwarePending, it))
}
info.marketingVersion?.takeIf { it.isNotBlank() }?.let {
add(DeviceDetailItem.Single(labels.build, it))
}
val leftSerial = info.leftEarbudSerial?.takeIf { it.isNotBlank() }
val rightSerial = info.rightEarbudSerial?.takeIf { it.isNotBlank() }
when {
leftSerial != null && rightSerial != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftSerial, leftSerial),
end = DeviceDetailItem.Single(labels.rightSerial, rightSerial),
)
)
leftSerial != null -> add(DeviceDetailItem.Single(labels.leftSerial, leftSerial))
rightSerial != null -> add(DeviceDetailItem.Single(labels.rightSerial, rightSerial))
}
val leftBonded = info.leftEarbudFirstPaired?.let(formatDate)
val rightBonded = info.rightEarbudFirstPaired?.let(formatDate)
when {
leftBonded != null && rightBonded != null -> add(
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(labels.leftBonded, leftBonded),
end = DeviceDetailItem.Single(labels.rightBonded, rightBonded),
)
)
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
}
}
}
@@ -133,7 +133,10 @@ internal fun NoiseControlCard(
text = stringResource(R.string.press_controls_long_press_anc_cycle_info), text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
type = InfoBoxType.INFO, type = InfoBoxType.INFO,
action = { action = {
TextButton(onClick = onPressControlsClick) { TextButton(
onClick = onPressControlsClick,
enabled = enabled,
) {
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action)) Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
} }
}, },
@@ -140,6 +140,7 @@ internal fun ReactionsCard(
checked = sleepDet.enabled, checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange, onCheckedChange = onSleepDetectionChange,
enabled = enabled, enabled = enabled,
requiresUpgrade = !isPro,
) )
if (sleepDet.enabled) { if (sleepDet.enabled) {
SettingsInfoBox( SettingsInfoBox(
@@ -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.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard 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.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.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard 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 eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant 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 @Composable
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
ErrorEventHandler(vm) ErrorEventHandler(vm)
@@ -158,7 +175,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onUpgrade = { vm.onUpgrade() }, onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() }, onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) }, 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) }, onEditProfile = { device -> vm.goToEditProfile(device) },
onToggleDeviceExpansion = { device -> onToggleDeviceExpansion = { device ->
device.profileId?.let { vm.toggleDeviceExpansion(it) } device.profileId?.let { vm.toggleDeviceExpansion(it) }
@@ -286,9 +306,21 @@ fun OverviewScreen(
// 4. Profiled device cards (limited to 1 for free users) // 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) { 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( itemsIndexed(
items = state.visibleProfiledDevices, items = state.visibleProfiledDevices,
key = { _, device -> requireNotNull(device.profileId) }, key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
) { index, device -> ) { index, device ->
val isCollapsed = !state.isExpanded(device, index) val isCollapsed = !state.isExpanded(device, index)
val isToggleable = state.isToggleable(device, index) val isToggleable = state.isToggleable(device, index)
@@ -337,10 +369,10 @@ fun OverviewScreen(
} }
if (state.showUnmatchedDevices) { if (state.showUnmatchedDevices) {
items( itemsIndexed(
items = state.unmatchedDevices, items = state.unmatchedDevices,
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" }, key = { index, device -> unmatchedDeviceKey(device, index) },
) { device -> ) { _, device ->
PodDeviceCard( PodDeviceCard(
device = device, device = device,
isPro = state.upgradeInfo.isPro, isPro = state.upgradeInfo.isPro,
@@ -414,6 +446,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
), ),
upgradeInfo = MockPodDataProvider.fossInfo(), upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false, showUnmatchedDevices = false,
showReactionsHint = true,
), ),
onRequestPermission = {}, onRequestPermission = {},
onBluetoothSettings = {}, onBluetoothSettings = {},
@@ -4,6 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider 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.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
@@ -130,7 +131,9 @@ class OverviewViewModel @Inject constructor(
upgradeRepo.upgradeInfo, upgradeRepo.upgradeInfo,
showUnmatchedDevices, showUnmatchedDevices,
userExpansionOverrides, 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) // Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet() val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet() val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -145,6 +148,7 @@ class OverviewViewModel @Inject constructor(
upgradeInfo = upgradeInfo, upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched, showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds, userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
) )
}.asLiveState() }.asLiveState()
@@ -160,6 +164,7 @@ class OverviewViewModel @Inject constructor(
val upgradeInfo: UpgradeRepo.Info, val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean, val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(), val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false,
) { ) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
@@ -252,6 +257,13 @@ class OverviewViewModel @Inject constructor(
} }
} }
fun dismissReactionsHint() {
log(TAG, INFO) { "dismissReactionsHint()" }
launch {
generalSettings.reactionsHintDismissed.value(true)
}
}
fun requestPermission(permission: Permission) { fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" } log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(permission) requestPermissionEvent.tryEmit(permission)
@@ -24,14 +24,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.GridView import androidx.compose.material.icons.twotone.GridView
import androidx.compose.material.icons.twotone.Tune import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -52,6 +51,7 @@ import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
import eu.darken.capod.main.ui.overview.cards.components.DebugSection import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.main.ui.overview.cards.components.StatusChip import eu.darken.capod.main.ui.overview.cards.components.StatusChip
import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow
@@ -61,6 +61,7 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
@@ -167,7 +168,7 @@ fun DualPodsCard(
} }
if (device.profileId != null && onDeviceSettings != null) { if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) { OutlinedIconButton(onClick = onDeviceSettings) {
Icon( Icon(
imageVector = Icons.TwoTone.Tune, imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd), contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -175,15 +176,15 @@ fun DualPodsCard(
) )
} }
} }
if (device.profileId != null && device.address == null && onEditProfile != null) {
IconButton(onClick = onEditProfile) {
Icon(
imageVector = Icons.TwoTone.Warning,
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
tint = MaterialTheme.colorScheme.error,
)
}
} }
if (!isCollapsed
&& device.profileId != null
&& !device.hasSelectedPairedDevice
&& onEditProfile != null
) {
Spacer(modifier = Modifier.height(12.dp))
MissingPairedDeviceBanner(onClick = onEditProfile)
} }
if (isCollapsed) { if (isCollapsed) {
@@ -227,7 +228,8 @@ private fun ColumnScope.DualPodsCardExpanded(
PodGauge( PodGauge(
iconRes = device.leftPodIcon, iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(), batteryPercent = device.batteryLeft.toBatteryFloat(),
isCharging = device.isLeftPodCharging ?: false, chargingState = device.leftPodChargingState
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isLeftInEar ?: false, isInEar = device.isLeftInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods, showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isLeftPodMicrophone ?: false, isMicrophone = device.isLeftPodMicrophone ?: false,
@@ -238,7 +240,8 @@ private fun ColumnScope.DualPodsCardExpanded(
PodGauge( PodGauge(
iconRes = device.rightPodIcon, iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(), batteryPercent = device.batteryRight.toBatteryFloat(),
isCharging = device.isRightPodCharging ?: false, chargingState = device.rightPodChargingState
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
isInEar = device.isRightInEar ?: false, isInEar = device.isRightInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods, showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isRightPodMicrophone ?: false, isMicrophone = device.isRightPodMicrophone ?: false,
@@ -292,7 +295,7 @@ private fun ColumnScope.DualPodsCardExpanded(
private fun PodGauge( private fun PodGauge(
iconRes: Int, iconRes: Int,
batteryPercent: Float, batteryPercent: Float,
isCharging: Boolean, chargingState: AapPodState.ChargingState?,
isInEar: Boolean, isInEar: Boolean,
showEarDetection: Boolean, showEarDetection: Boolean,
isMicrophone: Boolean, isMicrophone: Boolean,
@@ -370,12 +373,13 @@ private fun PodGauge(
// Status chips // Status chips
StatusChipRow( StatusChipRow(
isCharging = isCharging, chargingState = chargingState,
isInEar = isInEar, isInEar = isInEar,
showEarDetection = showEarDetection, showEarDetection = showEarDetection,
isMicrophone = isMicrophone, isMicrophone = isMicrophone,
showMicrophone = showMicrophone, showMicrophone = showMicrophone,
chargingLabel = stringResource(R.string.pods_charging_label), chargingLabel = stringResource(R.string.pods_charging_label),
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
inEarLabel = stringResource(R.string.pods_inear_label), inEarLabel = stringResource(R.string.pods_inear_label),
microphoneLabel = stringResource(R.string.pods_microphone_label), microphoneLabel = stringResource(R.string.pods_microphone_label),
) )
@@ -420,11 +424,17 @@ private fun CaseRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
if (device.isCaseCharging == true) { when (val caseState = device.caseChargingState
StatusChip( ?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull, icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label), label = stringResource(R.string.pods_charging_label),
) )
else -> Unit
} }
val lidState = device.caseLidState val lidState = device.caseLidState
@@ -490,7 +500,7 @@ private fun DualPodsCardCollapsedPreview() = PreviewWrapper {
@Composable @Composable
private fun DualPodsCardMissingAddressPreview() = PreviewWrapper { private fun DualPodsCardMissingAddressPreview() = PreviewWrapper {
DualPodsCard( DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredMixed(), device = MockPodDataProvider.dualPodMissingPairedDevice(),
showDebug = false, showDebug = false,
now = SystemTimeSource.now(), now = SystemTimeSource.now(),
onEditProfile = {}, onEditProfile = {},
@@ -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()
}
@@ -24,13 +24,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.Tune import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -50,6 +49,7 @@ import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
import eu.darken.capod.main.ui.overview.cards.components.DebugSection import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.main.ui.overview.cards.components.StatusChip import eu.darken.capod.main.ui.overview.cards.components.StatusChip
import eu.darken.capod.common.SystemTimeSource import eu.darken.capod.common.SystemTimeSource
@@ -58,6 +58,7 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Instant import java.time.Instant
@@ -149,7 +150,7 @@ fun SinglePodsCard(
} }
if (device.profileId != null && onDeviceSettings != null) { if (device.profileId != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) { OutlinedIconButton(onClick = onDeviceSettings) {
Icon( Icon(
imageVector = Icons.TwoTone.Tune, imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd), contentDescription = stringResource(R.string.device_settings_open_cd),
@@ -157,15 +158,15 @@ fun SinglePodsCard(
) )
} }
} }
if (device.profileId != null && device.address == null && onEditProfile != null) {
IconButton(onClick = onEditProfile) {
Icon(
imageVector = Icons.TwoTone.Warning,
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
tint = MaterialTheme.colorScheme.error,
)
}
} }
if (!isCollapsed
&& device.profileId != null
&& !device.hasSelectedPairedDevice
&& onEditProfile != null
) {
Spacer(modifier = Modifier.height(12.dp))
MissingPairedDeviceBanner(onClick = onEditProfile)
} }
if (isCollapsed) { if (isCollapsed) {
@@ -267,11 +268,17 @@ private fun ColumnScope.SinglePodsCardExpanded(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
if (device.isHeadsetBeingCharged == true) { when (val headsetState = device.headsetChargingState
StatusChip( ?: device.isHeadsetBeingCharged?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_optimized_label),
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull, icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label), label = stringResource(R.string.pods_charging_label),
) )
else -> Unit
} }
if (device.isBeingWorn == true) { if (device.isBeingWorn == true) {
StatusChip( StatusChip(
@@ -359,7 +366,7 @@ private fun SinglePodsCardCollapsedPreview() = PreviewWrapper {
@Composable @Composable
private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper { private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper {
SinglePodsCard( SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(), device = MockPodDataProvider.singlePodMissingPairedDevice(),
showDebug = false, showDebug = false,
now = SystemTimeSource.now(), now = SystemTimeSource.now(),
onEditProfile = {}, onEditProfile = {},
@@ -0,0 +1,63 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
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.semantics.semantics
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun MissingPairedDeviceBanner(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp),
modifier = modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {},
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.padding(end = 10.dp)
.size(20.dp),
)
Text(
text = stringResource(R.string.overview_card_missing_paired_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
modifier = Modifier.weight(1f),
)
}
}
}
@Preview2
@Composable
private fun MissingPairedDeviceBannerPreview() = PreviewWrapper {
MissingPairedDeviceBanner(onClick = {})
}
@@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.pods.core.apple.aap.AapPodState
@Composable @Composable
fun StatusChip( fun StatusChip(
@@ -60,12 +61,13 @@ fun StatusChip(
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun StatusChipRow( fun StatusChipRow(
isCharging: Boolean, chargingState: AapPodState.ChargingState?,
isInEar: Boolean, isInEar: Boolean,
showEarDetection: Boolean, showEarDetection: Boolean,
isMicrophone: Boolean, isMicrophone: Boolean,
showMicrophone: Boolean, showMicrophone: Boolean,
chargingLabel: String, chargingLabel: String,
chargingOptimizedLabel: String,
inEarLabel: String, inEarLabel: String,
microphoneLabel: String, microphoneLabel: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -75,11 +77,16 @@ fun StatusChipRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
if (isCharging) { when (chargingState) {
StatusChip( AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingOptimizedLabel,
)
AapPodState.ChargingState.CHARGING -> StatusChip(
icon = Icons.TwoTone.BatteryChargingFull, icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel, label = chargingLabel,
) )
else -> Unit
} }
if (showMicrophone && isMicrophone) { if (showMicrophone && isMicrophone) {
StatusChip( StatusChip(
@@ -106,12 +113,29 @@ private fun StatusChipChargingPreview() = PreviewWrapper {
@Composable @Composable
private fun StatusChipRowAllPreview() = PreviewWrapper { private fun StatusChipRowAllPreview() = PreviewWrapper {
StatusChipRow( StatusChipRow(
isCharging = true, chargingState = AapPodState.ChargingState.CHARGING,
isInEar = true, isInEar = true,
showEarDetection = true, showEarDetection = true,
isMicrophone = true, isMicrophone = true,
showMicrophone = true, showMicrophone = true,
chargingLabel = "Charging", chargingLabel = "Charging",
chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear",
microphoneLabel = "Mic",
)
}
@Preview2
@Composable
private fun StatusChipRowOptimizedPreview() = PreviewWrapper {
StatusChipRow(
chargingState = AapPodState.ChargingState.CHARGING_OPTIMIZED,
isInEar = false,
showEarDetection = true,
isMicrophone = true,
showMicrophone = true,
chargingLabel = "Charging",
chargingOptimizedLabel = "Optimized",
inEarLabel = "In Ear", inEarLabel = "In Ear",
microphoneLabel = "Mic", microphoneLabel = "Mic",
) )
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return 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( PressControlsScreen(
state = currentState, state = currentState,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
@@ -100,6 +100,13 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") }, onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") },
) )
} }
item {
SettingsBaseItem(
title = "apple-wireshark",
subtitle = "Thanks to Pablo Aul for the Wireshark dissector catalog of the AAP/AACP protocol.",
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
)
}
item { item {
SettingsBaseItem( SettingsBaseItem(
title = "crowdin.com", title = "crowdin.com",
@@ -110,13 +117,6 @@ fun AcknowledgementsScreen(
item { item {
SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label)) SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label))
} }
item {
SettingsBaseItem(
title = "Glide",
subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)",
onClick = { onOpenUrl("https://github.com/bumptech/glide") },
)
}
item { item {
SettingsBaseItem( SettingsBaseItem(
title = "Material Design Icons", title = "Material Design Icons",
@@ -145,6 +145,13 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") }, onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
) )
} }
item {
SettingsBaseItem(
title = "apple-wireshark",
subtitle = "Wireshark dissector plugins for Apple protocols. (GPL-3.0)",
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
)
}
item { item {
SettingsBaseItem( SettingsBaseItem(
title = "Kotlin", title = "Kotlin",
@@ -166,13 +173,6 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://source.android.com/source/licenses.html") }, onClick = { onOpenUrl("https://source.android.com/source/licenses.html") },
) )
} }
item {
SettingsBaseItem(
title = "Android",
subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.",
onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") },
)
}
} }
} }
} }
@@ -19,7 +19,6 @@ import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette 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.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -45,7 +44,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import eu.darken.capod.R import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.bluetooth.ScannerMode
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler
@@ -70,7 +68,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
state = it, state = it,
onNavigateUp = { vm.navUp() }, onNavigateUp = { vm.navUp() },
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) }, onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) }, onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) }, onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
onDebugSettings = { vm.goToDebugSettings() }, onDebugSettings = { vm.goToDebugSettings() },
@@ -90,7 +87,6 @@ fun GeneralSettingsScreen(
state: GeneralSettingsViewModel.State, state: GeneralSettingsViewModel.State,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onMonitorModeSelected: (MonitorMode) -> Unit, onMonitorModeSelected: (MonitorMode) -> Unit,
onScannerModeSelected: (ScannerMode) -> Unit,
onShowConnectedNotificationChanged: (Boolean) -> Unit, onShowConnectedNotificationChanged: (Boolean) -> Unit,
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit, onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
onDebugSettings: () -> Unit, onDebugSettings: () -> Unit,
@@ -104,7 +100,6 @@ fun GeneralSettingsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showMonitorModeDialog by remember { mutableStateOf(false) } var showMonitorModeDialog by remember { mutableStateOf(false) }
var showScannerModeDialog by remember { mutableStateOf(false) }
var showColorDialog by remember { mutableStateOf(false) } var showColorDialog by remember { mutableStateOf(false) }
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU && val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
@@ -138,14 +133,6 @@ fun GeneralSettingsScreen(
onClick = { showMonitorModeDialog = true }, 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 { item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label)) 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) { if (showColorDialog) {
ThemeColorSelectorDialog( ThemeColorSelectorDialog(
selectedColor = state.themeState.color, selectedColor = state.themeState.color,
@@ -342,7 +315,6 @@ fun GeneralSettingsScreen(
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State( private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
isPro = isPro, isPro = isPro,
monitorMode = MonitorMode.AUTOMATIC, monitorMode = MonitorMode.AUTOMATIC,
scannerMode = ScannerMode.BALANCED,
showConnectedNotification = true, showConnectedNotification = true,
keepNotificationAfterDisconnect = false, keepNotificationAfterDisconnect = false,
isOffloadedFilteringDisabled = false, isOffloadedFilteringDisabled = false,
@@ -358,7 +330,6 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
state = previewGeneralState(isPro = true), state = previewGeneralState(isPro = true),
onNavigateUp = {}, onNavigateUp = {},
onMonitorModeSelected = {}, onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {}, onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {}, onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {}, onDebugSettings = {},
@@ -375,7 +346,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
state = previewGeneralState(isPro = false), state = previewGeneralState(isPro = false),
onNavigateUp = {}, onNavigateUp = {},
onMonitorModeSelected = {}, onMonitorModeSelected = {},
onScannerModeSelected = {},
onShowConnectedNotificationChanged = {}, onShowConnectedNotificationChanged = {},
onKeepNotificationAfterDisconnectChanged = {}, onKeepNotificationAfterDisconnectChanged = {},
onDebugSettings = {}, onDebugSettings = {},
@@ -1,7 +1,6 @@
package eu.darken.capod.main.ui.settings.general package eu.darken.capod.main.ui.settings.general
import dagger.hilt.android.lifecycle.HiltViewModel 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.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
@@ -32,7 +31,6 @@ class GeneralSettingsViewModel @Inject constructor(
data class State( data class State(
val isPro: Boolean, val isPro: Boolean,
val monitorMode: MonitorMode, val monitorMode: MonitorMode,
val scannerMode: ScannerMode,
val showConnectedNotification: Boolean, val showConnectedNotification: Boolean,
val keepNotificationAfterDisconnect: Boolean, val keepNotificationAfterDisconnect: Boolean,
val isOffloadedFilteringDisabled: Boolean, val isOffloadedFilteringDisabled: Boolean,
@@ -46,12 +44,11 @@ class GeneralSettingsViewModel @Inject constructor(
val state = combine( val state = combine(
combine( combine(
generalSettings.monitorMode.flow, generalSettings.monitorMode.flow,
generalSettings.scannerMode.flow,
generalSettings.useExtraMonitorNotification.flow, generalSettings.useExtraMonitorNotification.flow,
generalSettings.keepConnectedNotificationAfterDisconnect.flow, generalSettings.keepConnectedNotificationAfterDisconnect.flow,
) { monitorMode, scannerMode, showNotif, keepNotif -> ) { monitorMode, showNotif, keepNotif ->
@Suppress("USELESS_CAST") @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( combine(
generalSettings.isOffloadedFilteringDisabled.flow, generalSettings.isOffloadedFilteringDisabled.flow,
@@ -67,9 +64,8 @@ class GeneralSettingsViewModel @Inject constructor(
State( State(
isPro = isPro, isPro = isPro,
monitorMode = general[0] as MonitorMode, monitorMode = general[0] as MonitorMode,
scannerMode = general[1] as ScannerMode, showConnectedNotification = general[1] as Boolean,
showConnectedNotification = general[2] as Boolean, keepNotificationAfterDisconnect = general[2] as Boolean,
keepNotificationAfterDisconnect = general[3] as Boolean,
isOffloadedFilteringDisabled = compat[0] as Boolean, isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean, isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean, useIndirectScanResultCallback = compat[2] as Boolean,
@@ -82,11 +78,6 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.monitorMode.valueBlocking = mode generalSettings.monitorMode.valueBlocking = mode
} }
fun setScannerMode(mode: ScannerMode) {
log(TAG, INFO) { "setScannerMode($mode)" }
generalSettings.scannerMode.valueBlocking = mode
}
fun setShowConnectedNotification(enabled: Boolean) { fun setShowConnectedNotification(enabled: Boolean) {
log(TAG, INFO) { "setShowConnectedNotification($enabled)" } log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
generalSettings.useExtraMonitorNotification.valueBlocking = enabled generalSettings.useExtraMonitorNotification.valueBlocking = enabled
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget 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.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class AncGlanceWidget : GlanceAppWidget() { class AncGlanceWidget : GlanceAppWidget() {
@@ -43,17 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: AncWidgetEntryPoint val ep: AncWidgetEntryPoint
val appWidgetId: Int val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try { try {
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java) ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" } log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro() ep.widgetSettings().migrateLegacyConfigIfNeeded(
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId) appWidgetId,
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) } AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent { provideContent {
@@ -73,26 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
} }
provideContent { 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 widthDp = LocalSize.current.width
val heightDp = LocalSize.current.height val heightDp = LocalSize.current.height
val state = try { // Glance keeps the content session alive and does not restart provideGlance()
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId) // for every update(). Observe a widget-key-deduped device flow so visible state
val theme = WidgetTheme.fromBundle( // changes update active sessions without recomposing on every BLE advertisement.
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId) val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
) .onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
val liveDevice = devices.firstOrNull { it.profileId == profileId } .onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId } .getOrDefault(false)
val initialDevice = remember(config.profileId) {
val profileLabel = profileId?.let { pid -> config.profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label 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 widthCells = getCellsForSize(widthDp.value.toInt())
val heightCells = getCellsForSize(heightDp.value.toInt()) val heightCells = getCellsForSize(heightDp.value.toInt())
@@ -108,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
AncWidgetRenderStateMapper.map( AncWidgetRenderStateMapper.map(
context = context, context = context,
device = device, device = device,
theme = theme, theme = config.theme,
isPro = isPro, isPro = isPro,
hasConfiguredProfile = profileId != null, hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel, profileLabel = profileLabel,
layout = layout, layout = layout,
) )
} catch (e: Exception) { } else {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
AncWidgetRenderState.Message( AncWidgetRenderState.Message(
theme = WidgetTheme.DEFAULT, theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
@@ -158,7 +171,7 @@ class AncGlanceWidget : GlanceAppWidget() {
private fun getCellsForSize(size: Int): Int { private fun getCellsForSize(size: Int): Int {
var n = 2 var n = 2
while (70 * n - 30 < size) { while (70 * n - 30 <= size) {
++n ++n
} }
return n - 1 return n - 1
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.updateAll
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
fun aapConnectionManager(): AapConnectionManager fun aapConnectionManager(): AapConnectionManager
fun widgetSettings(): WidgetSettings fun widgetSettings(): WidgetSettings
fun deviceMonitor(): DeviceMonitor fun deviceMonitor(): DeviceMonitor
fun widgetManager(): WidgetManager
} }
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
@@ -53,7 +53,7 @@ class AncModeActionCallback : ActionCallback {
val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java) val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
val profileId = ep.widgetSettings().getWidgetProfile(widgetId) val profileId = ep.widgetSettings().getWidgetConfig(widgetId).profileId
if (profileId == null) { if (profileId == null) {
log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" } log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
return return
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
val device = ep.deviceMonitor().getDeviceForProfile(profileId) val device = ep.deviceMonitor().getDeviceForProfile(profileId)
if (device == null) { if (device == null) {
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" } log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
AncGlanceWidget().updateAll(context) ep.widgetManager().refreshWidgets()
return return
} }
val address = device.address val address = device.address
if (address == null) { if (address == null) {
log(TAG, ERROR) { "onAction: device has no address" } log(TAG, ERROR) { "onAction: device has no address" }
AncGlanceWidget().updateAll(context) ep.widgetManager().refreshWidgets()
return return
} }
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" } log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
} }
AncGlanceWidget().updateAll(context) ep.widgetManager().refreshWidgets()
} }
companion object { companion object {
@@ -5,6 +5,7 @@ import android.content.Context
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget 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.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class BatteryGlanceWidget : GlanceAppWidget() { class BatteryGlanceWidget : GlanceAppWidget() {
@@ -43,26 +45,26 @@ class BatteryGlanceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val ep: WidgetEntryPoint val ep: WidgetEntryPoint
val appWidgetId: Int val appWidgetId: Int
val initialIsPro: Boolean
val initialProfileId: String?
val cachedDevice: PodDevice?
try { try {
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" } log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro() ep.widgetSettings().migrateLegacyConfigIfNeeded(
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId) appWidgetId,
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) } AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent { provideContent {
val layout = BatteryLayout.forCells(getCellsForSize(LocalSize.current.width.value.toInt()))
GlanceWidgetContent( GlanceWidgetContent(
state = WidgetRenderState.Message( state = WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT, theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT), resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT), resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
layout = layout,
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label), primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
), ),
context = context, context = context,
@@ -72,45 +74,57 @@ class BatteryGlanceWidget : GlanceAppWidget() {
} }
provideContent { 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 widthDp = LocalSize.current.width
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
val state = try { // Glance keeps the content session alive and does not restart provideGlance()
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId) // for every update(). Observe a widget-key-deduped device flow so visible state
val theme = WidgetTheme.fromBundle( // changes update active sessions without recomposing on every BLE advertisement.
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId) val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
) .onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
.getOrNull()
val isPro = upgradeInfo?.isPro ?: initialIsPro val state = if (config != null) {
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
val liveDevice = devices.firstOrNull { it.profileId == profileId } .onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId } .getOrDefault(false)
val initialDevice = remember(config.profileId) {
val profileLabel = profileId?.let { pid -> config.profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label 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()
} }
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5 log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
WidgetRenderStateMapper.map( WidgetRenderStateMapper.map(
context = context, context = context,
device = device, device = device,
theme = theme, theme = config.theme,
isPro = isPro, isPro = isPro,
hasConfiguredProfile = profileId != null, hasConfiguredProfile = config.profileId != null,
profileLabel = profileLabel, profileLabel = profileLabel,
isWide = isWide, layout = layout,
) )
} catch (e: Exception) { } else {
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
WidgetRenderState.Message( WidgetRenderState.Message(
theme = WidgetTheme.DEFAULT, theme = WidgetTheme.DEFAULT,
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT), resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT), resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
layout = layout,
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label), primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
) )
} }
@@ -137,7 +151,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
*/ */
private fun getCellsForSize(size: Int): Int { private fun getCellsForSize(size: Int): Int {
var n = 2 var n = 2
while (70 * n - 30 < size) { while (70 * n - 30 <= size) {
++n ++n
} }
return n - 1 return n - 1
@@ -25,6 +25,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.darken.capod.R import eu.darken.capod.R
@@ -87,46 +88,17 @@ private fun DualPodPreview(
val iconColor = Color(state.resolvedIconColor) val iconColor = Color(state.resolvedIconColor)
val iconTint = ColorFilter.tint(iconColor) val iconTint = ColorFilter.tint(iconColor)
if (state.isWide) { when (state.layout) {
// Wide layout: left | case | right in a horizontal row BatteryLayout.WIDE -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row( Row(
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Left pod PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
PodItemRow( PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
icon = state.leftIcon, PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
percent = state.leftPercent,
charging = state.leftCharging,
inEar = state.leftInEar,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
modifier = Modifier.padding(end = 12.dp),
)
// Case
PodItemRow(
icon = state.caseIcon,
percent = state.casePercent,
charging = state.caseCharging,
inEar = false,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
modifier = Modifier.padding(end = 12.dp),
)
// Right pod
PodItemRow(
icon = state.rightIcon,
percent = state.rightPercent,
charging = state.rightCharging,
inEar = state.rightInEar,
textColor = textColor,
iconTint = iconTint,
iconSize = 40,
)
} }
DeviceLabel( DeviceLabel(
label = state.deviceLabel, label = state.deviceLabel,
@@ -135,33 +107,13 @@ private fun DualPodPreview(
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
) )
} }
} else { }
// Compact layout: vertical stack
BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(bgColor = bgColor, modifier = modifier) {
PodItemRow( PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
icon = state.leftIcon, PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
percent = state.leftPercent, PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
charging = state.leftCharging,
inEar = state.leftInEar,
textColor = textColor,
iconTint = iconTint,
)
PodItemRow(
icon = state.rightIcon,
percent = state.rightPercent,
charging = state.rightCharging,
inEar = state.rightInEar,
textColor = textColor,
iconTint = iconTint,
)
PodItemRow(
icon = state.caseIcon,
percent = state.casePercent,
charging = state.caseCharging,
inEar = false,
textColor = textColor,
iconTint = iconTint,
)
DeviceLabel( DeviceLabel(
label = state.deviceLabel, label = state.deviceLabel,
visible = state.theme.showDeviceLabel, visible = state.theme.showDeviceLabel,
@@ -169,6 +121,20 @@ private fun DualPodPreview(
) )
} }
} }
BatteryLayout.TINY_COLUMN -> {
WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
TinyPodItem(state.leftIcon, state.leftPercent, textColor, iconTint)
TinyPodItem(state.rightIcon, state.rightPercent, textColor, iconTint)
TinyPodItem(state.caseIcon, state.casePercent, textColor, iconTint)
}
}
}
} }
@Composable @Composable
@@ -180,6 +146,8 @@ private fun SinglePodPreview(
val textColor = Color(state.resolvedTextColor) val textColor = Color(state.resolvedTextColor)
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor)) val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
when (state.layout) {
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(bgColor = bgColor, modifier = modifier) {
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
@@ -220,6 +188,19 @@ private fun SinglePodPreview(
textColor = textColor, textColor = textColor,
) )
} }
}
BatteryLayout.TINY_COLUMN -> {
WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
TinyPodItem(state.batteryIcon, state.percent, textColor, iconTint)
}
}
}
} }
@Composable @Composable
@@ -229,17 +210,25 @@ private fun MessagePreview(
) { ) {
val bgColor = Color(state.resolvedBgColor) val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor) val textColor = Color(state.resolvedTextColor)
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text( Text(
text = state.primaryText, text = state.primaryText,
fontSize = 12.sp, fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor, color = textColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (state.secondaryText != null) { if (!isCompact && state.secondaryText != null) {
Text( Text(
text = state.secondaryText, text = state.secondaryText,
fontSize = 12.sp, fontSize = 12.sp,
@@ -260,11 +249,17 @@ private fun LoadingPreview(
) { ) {
val bgColor = Color(state.resolvedBgColor) val bgColor = Color(state.resolvedBgColor)
val textColor = Color(state.resolvedTextColor) val textColor = Color(state.resolvedTextColor)
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
WidgetContainer(bgColor = bgColor, modifier = modifier) { WidgetContainer(
bgColor = bgColor,
modifier = modifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text( Text(
text = "", text = "",
fontSize = 12.sp, fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor, color = textColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -277,13 +272,15 @@ private fun LoadingPreview(
private fun WidgetContainer( private fun WidgetContainer(
bgColor: Color, bgColor: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
horizontalPadding: Dp = 16.dp,
verticalPadding: Dp = 4.dp,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(bgColor) .background(bgColor)
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
@@ -338,6 +335,34 @@ private fun PodItemRow(
} }
} }
@Composable
private fun TinyPodItem(
icon: Int,
percent: Float,
textColor: Color,
iconTint: ColorFilter,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(14.dp),
colorFilter = iconTint,
)
Text(
text = formatPercent(percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
maxLines = 1,
modifier = Modifier.padding(start = 2.dp),
)
}
}
@Composable @Composable
private fun DeviceLabel( private fun DeviceLabel(
label: String?, label: String?,
@@ -363,12 +388,30 @@ private fun formatPercent(percent: Float?): String {
@Preview2 @Preview2
@Composable @Composable
private fun PreviewDualCompact() = PreviewWrapper { private fun PreviewDualTiny11() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod()) ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
modifier = Modifier.size(40.dp),
)
}
@Preview2
@Composable
private fun PreviewDualTinyTall() = PreviewWrapper {
ComposeWidgetPreview(
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
modifier = Modifier.size(width = 40.dp, height = 110.dp),
)
}
@Preview2
@Composable
private fun PreviewDualNarrow() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.NARROW))
} }
@Preview2 @Preview2
@Composable @Composable
private fun PreviewDualWide() = PreviewWrapper { private fun PreviewDualWide() = PreviewWrapper {
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true)) ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.WIDE))
} }
@@ -3,6 +3,7 @@ package eu.darken.capod.main.ui.widget
import android.content.Intent import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
@@ -53,13 +54,11 @@ private fun GlanceDualPod(
state: WidgetRenderState.DualPod, state: WidgetRenderState.DualPod,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor)) val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
if (state.isWide) { when (state.layout) {
BatteryLayout.WIDE -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row( Row(
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp), modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
@@ -72,7 +71,10 @@ private fun GlanceDualPod(
} }
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor) GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
} else { }
BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint) GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint) GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
@@ -80,6 +82,21 @@ private fun GlanceDualPod(
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor) GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
} }
BatteryLayout.TINY_COLUMN -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
GlanceTinyPodItem(state.leftIcon, state.leftPercent, textStyle, iconTint)
GlanceTinyPodItem(state.rightIcon, state.rightPercent, textStyle, iconTint)
GlanceTinyPodItem(state.caseIcon, state.casePercent, textStyle, iconTint)
}
}
}
} }
@Composable @Composable
@@ -87,12 +104,11 @@ private fun GlanceSinglePod(
state: WidgetRenderState.SinglePod, state: WidgetRenderState.SinglePod,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
val textStyle = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp,
)
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor)) val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
when (state.layout) {
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
Row( Row(
modifier = GlanceModifier.fillMaxWidth(), modifier = GlanceModifier.fillMaxWidth(),
@@ -129,6 +145,20 @@ private fun GlanceSinglePod(
} }
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor) GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
} }
}
BatteryLayout.TINY_COLUMN -> {
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = 4.dp,
verticalPadding = 0.dp,
) {
GlanceTinyPodItem(state.batteryIcon, state.percent, textStyle, iconTint)
}
}
}
} }
@Composable @Composable
@@ -136,20 +166,29 @@ private fun GlanceMessage(
state: WidgetRenderState.Message, state: WidgetRenderState.Message,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
val textStyle = TextStyle( val isCompact = state.layout == BatteryLayout.TINY_COLUMN
color = fixedColor(state.resolvedTextColor), val fontSize = if (isCompact) 10.sp else 12.sp
fontSize = 12.sp, val hPad = if (isCompact) 4.dp else 16.dp
fontWeight = FontWeight.Bold, val vPad = if (isCompact) 0.dp else 4.dp
textAlign = TextAlign.Center,
)
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = hPad,
verticalPadding = vPad,
) {
Text( Text(
text = state.primaryText, text = state.primaryText,
style = textStyle, style = TextStyle(
color = fixedColor(state.resolvedTextColor),
fontSize = fontSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.fillMaxWidth(), modifier = GlanceModifier.fillMaxWidth(),
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
) )
if (state.secondaryText != null) { if (!isCompact && state.secondaryText != null) {
Text( Text(
text = state.secondaryText, text = state.secondaryText,
style = TextStyle( style = TextStyle(
@@ -169,12 +208,18 @@ private fun GlanceLoading(
state: WidgetRenderState.Loading, state: WidgetRenderState.Loading,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
) { ) {
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) { val isCompact = state.layout == BatteryLayout.TINY_COLUMN
GlanceWidgetRoot(
bgColor = state.resolvedBgColor,
clickModifier = clickModifier,
horizontalPadding = if (isCompact) 4.dp else 16.dp,
verticalPadding = if (isCompact) 0.dp else 4.dp,
) {
Text( Text(
text = "", text = "",
style = TextStyle( style = TextStyle(
color = fixedColor(state.resolvedTextColor), color = fixedColor(state.resolvedTextColor),
fontSize = 12.sp, fontSize = if (isCompact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
), ),
@@ -187,13 +232,15 @@ private fun GlanceLoading(
private fun GlanceWidgetRoot( private fun GlanceWidgetRoot(
bgColor: Int, bgColor: Int,
clickModifier: GlanceModifier, clickModifier: GlanceModifier,
horizontalPadding: Dp = 16.dp,
verticalPadding: Dp = 4.dp,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Column( Column(
modifier = clickModifier modifier = clickModifier
.fillMaxSize() .fillMaxSize()
.background(fixedColor(bgColor)) .background(fixedColor(bgColor))
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -246,6 +293,33 @@ private fun GlancePodItem(
} }
} }
@Composable
private fun GlanceTinyPodItem(
icon: Int,
percent: Float,
textStyle: TextStyle,
iconTint: ColorFilter,
modifier: GlanceModifier = GlanceModifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(icon),
contentDescription = null,
modifier = GlanceModifier.size(14.dp),
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent.toBatteryOrNull()),
style = textStyle,
maxLines = 1,
modifier = GlanceModifier.padding(start = 4.dp),
)
}
}
@Composable @Composable
private fun GlanceDeviceLabel( private fun GlanceDeviceLabel(
label: String?, label: String?,
@@ -0,0 +1,10 @@
package eu.darken.capod.main.ui.widget
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.serialization.Serializable
@Serializable
data class WidgetConfig(
val profileId: ProfileId? = null,
val theme: WidgetTheme = WidgetTheme(),
)
@@ -47,19 +47,21 @@ class WidgetConfigurationViewModel @Inject constructor(
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID" } log(TAG) { "Invalid widget ID" }
} else {
widgetSettings.migrateLegacyConfigIfNeeded(widgetId, appWidgetManager.getAppWidgetOptions(widgetId))
} }
} }
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId)) private val initialConfig: WidgetConfig = run {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetConfig()
private val initialTheme: WidgetTheme = run { widgetSettings.getWidgetConfig(widgetId)
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
} }
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null) private val selectedProfile = MutableStateFlow(initialConfig.profileId)
private val currentTheme = MutableStateFlow(initialTheme) private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialConfig.theme) == null)
private val currentTheme = MutableStateFlow(initialConfig.theme)
private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles -> private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
@@ -152,18 +154,16 @@ class WidgetConfigurationViewModel @Inject constructor(
} }
fun confirmSelection() { fun confirmSelection() {
val selectedProfile = selectedProfile.value if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
if (selectedProfile != null) { log(TAG, INFO) { "confirmSelection: invalid widget ID, skipping save" }
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" } return
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
} }
val config = WidgetConfig(
// Save theme to AppWidgetOptions bundle profileId = selectedProfile.value,
val theme = currentTheme.value theme = currentTheme.value,
log(TAG, INFO) { "confirmSelection: saving theme=$theme" } )
val options = appWidgetManager.getAppWidgetOptions(widgetId) log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, config=$config)" }
theme.toBundle(options) widgetSettings.saveWidgetConfig(widgetId, config)
appWidgetManager.updateAppWidgetOptions(widgetId, options)
} }
companion object { companion object {
@@ -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() }
@@ -0,0 +1,55 @@
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 / 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 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 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,
batteryCase = batteryCase,
batteryHeadset = batteryHeadset,
isLeftPodCharging = isLeftPodCharging,
isRightPodCharging = isRightPodCharging,
isCaseCharging = isCaseCharging,
isHeadsetBeingCharged = isHeadsetBeingCharged,
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isBeingWorn = isBeingWorn,
isAapConnected = isAapConnected,
isAapReady = isAapReady,
ancMode = ancMode?.current,
pendingAncMode = pendingAncMode,
visibleAncModes = visibleAncModes,
)
@@ -1,9 +1,16 @@
package eu.darken.capod.main.ui.widget package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context 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 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.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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject import javax.inject.Inject
@@ -16,9 +23,63 @@ class WidgetManager @Inject constructor(
) { ) {
suspend fun refreshWidgets() { suspend fun refreshWidgets() {
log(TAG, VERBOSE) { "refreshWidgets()" } val manager = GlanceAppWidgetManager(context)
BatteryGlanceWidget().updateAll(context) refreshWidget(
AncGlanceWidget().updateAll(context) 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 { companion object {
@@ -4,18 +4,33 @@ import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import eu.darken.capod.R import eu.darken.capod.R
enum class BatteryLayout {
TINY_COLUMN,
NARROW,
WIDE;
companion object {
fun forCells(widthCells: Int): BatteryLayout = when {
widthCells <= 1 -> TINY_COLUMN
widthCells >= 5 -> WIDE
else -> NARROW
}
}
}
sealed class WidgetRenderState { sealed class WidgetRenderState {
abstract val theme: WidgetTheme abstract val theme: WidgetTheme
@get:ColorInt abstract val resolvedBgColor: Int @get:ColorInt abstract val resolvedBgColor: Int
@get:ColorInt abstract val resolvedTextColor: Int @get:ColorInt abstract val resolvedTextColor: Int
@get:ColorInt abstract val resolvedIconColor: Int @get:ColorInt abstract val resolvedIconColor: Int
abstract val layout: BatteryLayout
data class DualPod( data class DualPod(
override val theme: WidgetTheme, override val theme: WidgetTheme,
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
val isWide: Boolean, override val layout: BatteryLayout,
val deviceLabel: String?, val deviceLabel: String?,
@DrawableRes val leftIcon: Int, @DrawableRes val leftIcon: Int,
val leftPercent: Float, val leftPercent: Float,
@@ -35,6 +50,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
val deviceLabel: String?, val deviceLabel: String?,
@DrawableRes val headsetIcon: Int, @DrawableRes val headsetIcon: Int,
val percent: Float, val percent: Float,
@@ -48,6 +64,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
val primaryText: String, val primaryText: String,
val secondaryText: String? = null, val secondaryText: String? = null,
) : WidgetRenderState() ) : WidgetRenderState()
@@ -57,6 +74,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedBgColor: Int, @ColorInt override val resolvedBgColor: Int,
@ColorInt override val resolvedTextColor: Int, @ColorInt override val resolvedTextColor: Int,
@ColorInt override val resolvedIconColor: Int, @ColorInt override val resolvedIconColor: Int,
override val layout: BatteryLayout,
) : WidgetRenderState() ) : WidgetRenderState()
companion object { companion object {
@@ -65,13 +83,13 @@ sealed class WidgetRenderState {
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(), @ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(), @ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(), @ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
isWide: Boolean = false, layout: BatteryLayout = BatteryLayout.NARROW,
): DualPod = DualPod( ): DualPod = DualPod(
theme = theme, theme = theme,
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
isWide = isWide, layout = layout,
deviceLabel = "My AirPods Pro", deviceLabel = "My AirPods Pro",
leftIcon = R.drawable.device_airpods_pro2_left, leftIcon = R.drawable.device_airpods_pro2_left,
leftPercent = 0.85f, leftPercent = 0.85f,
@@ -85,5 +103,25 @@ sealed class WidgetRenderState {
casePercent = 1.0f, casePercent = 1.0f,
caseCharging = false, caseCharging = false,
) )
fun previewSinglePod(
theme: WidgetTheme = WidgetTheme.DEFAULT,
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
layout: BatteryLayout = BatteryLayout.NARROW,
): SinglePod = SinglePod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
layout = layout,
deviceLabel = "My AirPods Max",
headsetIcon = R.drawable.device_airpods_max,
percent = 0.72f,
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
charging = false,
worn = true,
)
} }
} }
@@ -18,7 +18,7 @@ object WidgetRenderStateMapper {
isPro: Boolean, isPro: Boolean,
hasConfiguredProfile: Boolean, hasConfiguredProfile: Boolean,
profileLabel: String?, profileLabel: String?,
isWide: Boolean = false, layout: BatteryLayout = BatteryLayout.NARROW,
): WidgetRenderState { ): WidgetRenderState {
val bgColor = resolvedBgColor(context, theme) val bgColor = resolvedBgColor(context, theme)
val textColor = resolvedTextColor(context, theme) val textColor = resolvedTextColor(context, theme)
@@ -30,6 +30,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
primaryText = context.getString(R.string.upgrade_capod_label), primaryText = context.getString(R.string.upgrade_capod_label),
secondaryText = context.getString(R.string.upgrade_capod_description), secondaryText = context.getString(R.string.upgrade_capod_description),
) )
@@ -39,7 +40,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
isWide = isWide, layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon, leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeft.toBatteryFloat(), leftPercent = device.batteryLeft.toBatteryFloat(),
@@ -59,6 +60,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
deviceLabel = profileLabel ?: device.getLabel(context), deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes, headsetIcon = device.iconRes,
percent = device.batteryHeadset.toBatteryFloat(), percent = device.batteryHeadset.toBatteryFloat(),
@@ -72,6 +74,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
primaryText = context.getString(R.string.pods_unknown_label), primaryText = context.getString(R.string.pods_unknown_label),
) )
@@ -86,6 +89,7 @@ object WidgetRenderStateMapper {
resolvedBgColor = bgColor, resolvedBgColor = bgColor,
resolvedTextColor = textColor, resolvedTextColor = textColor,
resolvedIconColor = iconColor, resolvedIconColor = iconColor,
layout = layout,
primaryText = context.getString(messageRes), primaryText = context.getString(messageRes),
) )
} }
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.widget package eu.darken.capod.main.ui.widget
import android.content.Context import android.content.Context
import android.os.Bundle
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
@@ -11,15 +12,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.profiles.core.ProfileId import eu.darken.capod.common.serialization.SerializationCapod
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class WidgetSettings @Inject constructor( class WidgetSettings @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context,
@SerializationCapod private val json: Json,
) { ) {
private val Context.dataStore by preferencesDataStore( private val Context.dataStore by preferencesDataStore(
@@ -29,32 +34,76 @@ class WidgetSettings @Inject constructor(
private val dataStore: DataStore<Preferences> get() = context.dataStore private val dataStore: DataStore<Preferences> get() = context.dataStore
fun saveWidgetProfile(widgetId: Int, profileId: ProfileId) { fun getWidgetConfig(widgetId: Int): WidgetConfig {
log(TAG, VERBOSE) { "saveWidgetProfile(widgetId=$widgetId, profileId=$profileId)" } val raw = runBlocking { dataStore.data.first()[configKey(widgetId)] }
runBlocking { if (raw == null) {
dataStore.edit { it[stringPreferencesKey(getWidgetProfileKey(widgetId))] = profileId } log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = absent → default" }
return WidgetConfig()
}
return try {
json.decodeFromString<WidgetConfig>(raw).also {
log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = $it" }
}
} catch (e: SerializationException) {
log(TAG) { "getWidgetConfig(widgetId=$widgetId): malformed JSON, returning default ($e)" }
WidgetConfig()
} }
} }
fun getWidgetProfile(widgetId: Int): ProfileId? { fun saveWidgetConfig(widgetId: Int, config: WidgetConfig) {
val profileId = runBlocking { log(TAG, VERBOSE) { "saveWidgetConfig(widgetId=$widgetId, config=$config)" }
dataStore.data.first()[stringPreferencesKey(getWidgetProfileKey(widgetId))] val encoded = json.encodeToString(config)
runBlocking {
dataStore.edit { prefs ->
prefs[configKey(widgetId)] = encoded
prefs.remove(legacyProfileKey(widgetId))
}
}
}
/**
* One-shot migration from the legacy storage layout (separate `widget_profile_<id>` DataStore key
* + per-widget theme in `AppWidgetManager.getAppWidgetOptions()`) to the unified
* `widget_config_<id>` JSON. Marker-guarded inside the same `dataStore.edit {}` block so
* concurrent saves can't be overwritten.
*
* Pre-reboot the legacy options Bundle still carries the user's theme; post-reboot it does not
* (Android only persists the standard `OPTION_APPWIDGET_*` keys).
*/
fun migrateLegacyConfigIfNeeded(widgetId: Int, legacyOptions: Bundle) {
val legacyTheme = WidgetTheme.fromLegacyBundleOrNull(legacyOptions)
runBlocking {
dataStore.edit { prefs ->
if (prefs[configKey(widgetId)] != null) return@edit
val legacyProfileId = prefs[legacyProfileKey(widgetId)]
if (legacyProfileId == null && legacyTheme == null) return@edit
val migrated = WidgetConfig(
profileId = legacyProfileId,
theme = legacyTheme ?: WidgetTheme(),
)
log(TAG) { "migrateLegacyConfigIfNeeded(widgetId=$widgetId): writing $migrated" }
prefs[configKey(widgetId)] = json.encodeToString(migrated)
prefs.remove(legacyProfileKey(widgetId))
}
} }
log(TAG, VERBOSE) { "getWidgetProfile(widgetId=$widgetId) = $profileId" }
return profileId
} }
fun removeWidget(widgetId: Int) { fun removeWidget(widgetId: Int) {
log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" } log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" }
runBlocking { runBlocking {
dataStore.edit { it.remove(stringPreferencesKey(getWidgetProfileKey(widgetId))) } dataStore.edit { prefs ->
prefs.remove(configKey(widgetId))
prefs.remove(legacyProfileKey(widgetId))
}
} }
} }
private fun getWidgetProfileKey(widgetId: Int): String = "$WIDGET_PROFILE_PREFIX$widgetId" private fun configKey(widgetId: Int) = stringPreferencesKey("$WIDGET_CONFIG_PREFIX$widgetId")
private fun legacyProfileKey(widgetId: Int) = stringPreferencesKey("$LEGACY_WIDGET_PROFILE_PREFIX$widgetId")
companion object { companion object {
private const val WIDGET_PROFILE_PREFIX = "widget_profile_" private const val WIDGET_CONFIG_PREFIX = "widget_config_"
private const val LEGACY_WIDGET_PROFILE_PREFIX = "widget_profile_"
private val TAG = logTag("Widget", "Settings") private val TAG = logTag("Widget", "Settings")
} }
} }
@@ -4,7 +4,9 @@ import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import kotlinx.serialization.Serializable
@Serializable
data class WidgetTheme( data class WidgetTheme(
@ColorInt val backgroundColor: Int? = null, @ColorInt val backgroundColor: Int? = null,
@ColorInt val foregroundColor: Int? = null, @ColorInt val foregroundColor: Int? = null,
@@ -36,7 +38,18 @@ data class WidgetTheme(
val DEFAULT = WidgetTheme() val DEFAULT = WidgetTheme()
fun fromBundle(bundle: Bundle): WidgetTheme { /**
* Reads a [WidgetTheme] from the legacy `AppWidgetManager.getAppWidgetOptions()` Bundle.
* Returns `null` if the Bundle holds none of the legacy keys — distinguishing "system reset
* the Bundle to system-only keys" from "user picked Material You + 255".
*/
fun fromLegacyBundleOrNull(bundle: Bundle): WidgetTheme? {
val hasLegacyKey = bundle.containsKey(KEY_THEME_MODE)
|| bundle.containsKey(KEY_BG_ALPHA)
|| bundle.containsKey(KEY_SHOW_LABEL)
|| bundle.containsKey(KEY_CUSTOM_BG)
|| bundle.containsKey(KEY_CUSTOM_FG)
if (!hasLegacyKey) return null
val mode = bundle.getString(KEY_THEME_MODE, MODE_MATERIAL_YOU) val mode = bundle.getString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
val showLabel = bundle.getBoolean(KEY_SHOW_LABEL, true) val showLabel = bundle.getBoolean(KEY_SHOW_LABEL, true)
val alpha = bundle.getInt(KEY_BG_ALPHA, 255).coerceIn(0, 255) val alpha = bundle.getInt(KEY_BG_ALPHA, 255).coerceIn(0, 255)
@@ -71,25 +84,4 @@ data class WidgetTheme(
} }
} }
fun toBundle(bundle: Bundle) {
if (backgroundColor == null && foregroundColor == null) {
bundle.putString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
bundle.remove(KEY_CUSTOM_BG)
bundle.remove(KEY_CUSTOM_FG)
} else {
bundle.putString(KEY_THEME_MODE, MODE_CUSTOM)
if (backgroundColor != null) {
bundle.putInt(KEY_CUSTOM_BG, backgroundColor)
} else {
bundle.remove(KEY_CUSTOM_BG)
}
if (foregroundColor != null) {
bundle.putInt(KEY_CUSTOM_FG, foregroundColor)
} else {
bundle.remove(KEY_CUSTOM_FG)
}
}
bundle.putInt(KEY_BG_ALPHA, backgroundAlpha.coerceIn(0, 255))
bundle.putBoolean(KEY_SHOW_LABEL, showDeviceLabel)
}
} }
@@ -65,6 +65,13 @@ data class PodDevice(
val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN
/** Bonded BR/EDR address (from profile). Used for AAP commands. */ /** Bonded BR/EDR address (from profile). Used for AAP commands. */
val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: profileAddress ?: cached?.address val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: profileAddress ?: cached?.address
/**
* True when the matched profile has a selected paired Bluetooth device. Unlike [address],
* this never falls back to the cache, so a profile whose paired device was removed still
* reports `false` even while a stale cached address lingers.
*/
val hasSelectedPairedDevice: Boolean
get() = (ble?.meta?.profile?.address ?: profileAddress) != null
/** BLE scan address (RPA, rotates). */ /** BLE scan address (RPA, rotates). */
val bleAddress: BluetoothAddress? get() = ble?.address val bleAddress: BluetoothAddress? get() = ble?.address
val identifier: BlePodSnapshot.Id? get() = ble?.identifier val identifier: BlePodSnapshot.Id? get() = ble?.identifier
@@ -78,6 +85,7 @@ data class PodDevice(
val hasEarDetection: Boolean get() = model.features.hasEarDetection val hasEarDetection: Boolean get() = model.features.hasEarDetection
val hasAncControl: Boolean get() = model.features.hasAncControl val hasAncControl: Boolean get() = model.features.hasAncControl
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
val hasDynamicEndOfCharge: Boolean get() = model.features.hasDynamicEndOfCharge
// Signal / timing // Signal / timing
val seenLastAt: Instant? val seenLastAt: Instant?
@@ -179,6 +187,15 @@ data class PodDevice(
val isHeadsetBeingCharged: Boolean? val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
// after the device went out of range. Existing isLeftCharging/etc. remain the Boolean
// collapse of CHARGING + CHARGING_OPTIMIZED for everyone who just cares "is it charging".
val leftPodChargingState: AapPodState.ChargingState? get() = aap?.leftChargingState
val rightPodChargingState: AapPodState.ChargingState? get() = aap?.rightChargingState
val caseChargingState: AapPodState.ChargingState? get() = aap?.caseChargingState
val headsetChargingState: AapPodState.ChargingState? get() = aap?.headsetChargingState
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback. // Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod? private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
get() = aap?.aapPrimaryPod?.pod?.let { aapPod -> get() = aap?.aapPrimaryPod?.pod?.let { aapPod ->
@@ -188,19 +205,28 @@ data class PodDevice(
} }
} ?: (ble as? DualApplePods)?.primaryPod } ?: (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. // Ear detection — AAP preferred (lower latency), BLE fallback.
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary. // AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
val isLeftInEar: Boolean? val isLeftInEar: Boolean?
get() { get() {
val earDetection = aap?.aapEarDetection val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) { if (earDetection != null) {
if (primary != null) {
return if (primary == DualBlePodSnapshot.Pod.LEFT) { return if (primary == DualBlePodSnapshot.Pod.LEFT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR earDetection.primaryPod.isInEar()
} else { } else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR earDetection.secondaryPod.isInEar()
} }
} }
earDetection.samePlacementInEarOrNull()?.let { return it }
}
return (ble as? HasEarDetectionDual)?.isLeftPodInEar return (ble as? HasEarDetectionDual)?.isLeftPodInEar
} }
@@ -208,13 +234,16 @@ data class PodDevice(
get() { get() {
val earDetection = aap?.aapEarDetection val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) { if (earDetection != null) {
if (primary != null) {
return if (primary == DualBlePodSnapshot.Pod.RIGHT) { return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR earDetection.primaryPod.isInEar()
} else { } else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR earDetection.secondaryPod.isInEar()
} }
} }
earDetection.samePlacementInEarOrNull()?.let { return it }
}
return (ble as? HasEarDetectionDual)?.isRightPodInEar return (ble as? HasEarDetectionDual)?.isRightPodInEar
} }
@@ -330,13 +359,16 @@ data class PodDevice(
val sleepDetection: AapSetting.SleepDetection? val sleepDetection: AapSetting.SleepDetection?
get() = aap?.setting() get() = aap?.setting()
val dynamicEndOfCharge: AapSetting.DynamicEndOfCharge?
get() = aap?.setting()
val connectedDevices: AapSetting.ConnectedDevices? val connectedDevices: AapSetting.ConnectedDevices?
get() = aap?.setting() get() = aap?.setting()
val audioSource: AapSetting.AudioSource? val audioSource: AapSetting.AudioSource?
get() = aap?.setting() get() = aap?.setting()
val eqBands: AapSetting.EqBands? val pmeConfig: AapSetting.PmeConfig?
get() = aap?.setting() get() = aap?.setting()
val deviceInfo: AapDeviceInfo? val deviceInfo: AapDeviceInfo?
@@ -45,6 +45,7 @@ import javax.inject.Singleton
class BlePodMonitor @Inject constructor( class BlePodMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope, @AppScope private val appScope: CoroutineScope,
private val bleScanner: BleScanner, private val bleScanner: BleScanner,
private val bleScanModeController: BleScanModeController,
private val podFactory: PodFactory, private val podFactory: PodFactory,
private val timeSource: TimeSource, private val timeSource: TimeSource,
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
@@ -128,7 +129,7 @@ class BlePodMonitor @Inject constructor(
) )
private fun createBleScanner() = combine( private fun createBleScanner() = combine(
generalSettings.scannerMode.flow, bleScanModeController.scannerMode,
debugSettings.showUnfiltered.flow, debugSettings.showUnfiltered.flow,
generalSettings.isOffloadedBatchingDisabled.flow, generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow, generalSettings.isOffloadedFilteringDisabled.flow,
@@ -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()
@@ -25,7 +25,12 @@ data class CachedDeviceState(
@SerialName("firmwareVersion") val firmwareVersion: String? = null, @SerialName("firmwareVersion") val firmwareVersion: String? = null,
@SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null, @SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null,
@SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null, @SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null,
@SerialName("buildNumber") val buildNumber: String? = null, /**
* Formerly known as `buildNumber` — the Wireshark AAP dissector calls this
* "Marketing Version". The `@SerialName("buildNumber")` preserves the wire
* key so cache entries from older CAPod versions keep deserializing.
*/
@SerialName("buildNumber") val marketingVersion: String? = null,
@Serializable(with = InstantEpochMillisSerializer::class) @Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("lastSeenAt") val lastSeenAt: Instant, @SerialName("lastSeenAt") val lastSeenAt: Instant,
) { ) {
@@ -40,7 +45,7 @@ data class CachedDeviceState(
firmwareVersion = firmwareVersion ?: "", firmwareVersion = firmwareVersion ?: "",
leftEarbudSerial = leftEarbudSerial, leftEarbudSerial = leftEarbudSerial,
rightEarbudSerial = rightEarbudSerial, rightEarbudSerial = rightEarbudSerial,
buildNumber = buildNumber, marketingVersion = marketingVersion,
) )
} }
@@ -50,7 +50,7 @@ fun PodDevice.toCachedState(
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion, firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial, leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial,
rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial, rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial,
buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber, marketingVersion = liveDeviceInfo?.marketingVersion ?: existing?.marketingVersion,
lastSeenAt = seenLastAt ?: now, lastSeenAt = seenLastAt ?: now,
) )
@@ -11,6 +11,7 @@ import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -35,11 +36,13 @@ import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.monitor.ui.MonitorNotifications import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.autoconnect.AutoConnect import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.core.sleep.SleepReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -48,6 +51,7 @@ import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -73,6 +77,7 @@ class MonitorService : Service() {
@Inject lateinit var playPause: PlayPause @Inject lateinit var playPause: PlayPause
@Inject lateinit var autoConnect: AutoConnect @Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction @Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var sleepReaction: SleepReaction
@Inject lateinit var popUpWindow: PopUpWindow @Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo @Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager @Inject lateinit var aapConnectionManager: AapConnectionManager
@@ -223,31 +228,19 @@ class MonitorService : Service() {
profilesRepo.profiles, profilesRepo.profiles,
bluetoothManager.connectedDevices, bluetoothManager.connectedDevices,
aapConnectionManager.allStates, aapConnectionManager.allStates,
) { monitorMode, profiles, connectedDevices, aapStates -> ) { mode, profiles, devices, aapStates ->
listOf(monitorMode, profiles, connectedDevices, aapStates) buildMonitorModeState(mode, profiles, devices, aapStates)
} }
} }
} }
.distinctUntilChanged()
.setupCommonEventHandlers(TAG) { "MonitorMode" } .setupCommonEventHandlers(TAG) { "MonitorMode" }
.flatMapLatest { arguments -> .flatMapLatest { state ->
val monitorMode = arguments[0] as MonitorMode log(TAG) { "Monitor mode: ${state.mode}" }
log(TAG) { "connectedAddresses: ${state.connectedAddresses}" }
log(TAG) { "knownAddresses: ${state.knownAddresses}" }
@Suppress("UNCHECKED_CAST") when (state.mode) {
val profiles = arguments[1] as List<DeviceProfile>
@Suppress("UNCHECKED_CAST")
val devices = arguments[2] as Collection<BluetoothDevice2>
@Suppress("UNCHECKED_CAST")
val aapStates = arguments[3] as Map<*, *>
val connectedAddresses = devices.map { it.address }.toSet()
val knownAddresses = profiles.mapNotNull { it.address }.toSet()
log(TAG) { "Monitor mode: $monitorMode" }
log(TAG) { "connectedAddresses: $connectedAddresses" }
log(TAG) { "knownAddresses: $knownAddresses" }
when (monitorMode) {
MonitorMode.MANUAL -> flow<Unit> { MonitorMode.MANUAL -> flow<Unit> {
monitorScope.coroutineContext.cancelChildren() monitorScope.coroutineContext.cancelChildren()
} }
@@ -255,11 +248,11 @@ class MonitorService : Service() {
MonitorMode.ALWAYS -> emptyFlow() MonitorMode.ALWAYS -> emptyFlow()
MonitorMode.AUTOMATIC -> flow { MonitorMode.AUTOMATIC -> flow {
when { when {
profiles.isEmpty() && devices.isNotEmpty() -> { !state.hasProfiles && state.connectedAddresses.isNotEmpty() -> {
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" } log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
} }
knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> { state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> {
log(TAG) { "A device is connected, aborting any timeout." } log(TAG) { "A device is connected, aborting any timeout." }
} }
@@ -302,6 +295,11 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } } .catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
.launchIn(monitorScope) .launchIn(monitorScope)
sleepReaction.monitor()
.setupCommonEventHandlers(TAG) { "sleepReaction" }
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" } log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join() monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" } log(TAG, VERBOSE) { "Monitor job quit" }
@@ -340,6 +338,27 @@ class MonitorService : Service() {
} }
} }
internal data class MonitorModeState(
val mode: MonitorMode,
val hasProfiles: Boolean,
val knownAddresses: Set<BluetoothAddress>,
val connectedAddresses: Set<BluetoothAddress>,
val hasAapSession: Boolean,
)
internal fun buildMonitorModeState(
mode: MonitorMode,
profiles: List<DeviceProfile>,
devices: Collection<BluetoothDevice2>,
aapStates: Map<BluetoothAddress, AapPodState>,
): MonitorModeState = MonitorModeState(
mode = mode,
hasProfiles = profiles.isNotEmpty(),
knownAddresses = profiles.mapNotNull { it.address }.toSet(),
connectedAddresses = devices.map { it.address }.toSet(),
hasAapSession = aapStates.isNotEmpty(),
)
private data class NotificationDeviceKey( private data class NotificationDeviceKey(
val profileId: String?, val profileId: String?,
val label: String?, val label: String?,
@@ -232,6 +232,7 @@ enum class PodModel(
hasAllowOffOption = true, hasAllowOffOption = true,
hasStemConfig = true, hasStemConfig = true,
hasSleepDetection = true, hasSleepDetection = true,
hasDynamicEndOfCharge = true,
), ),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left, leftPodIconRes = R.drawable.device_airpods_pro2_left,
@@ -558,5 +559,12 @@ enum class PodModel(
val hasAllowOffOption: Boolean = false, val hasAllowOffOption: Boolean = false,
val hasStemConfig: Boolean = false, val hasStemConfig: Boolean = false,
val hasSleepDetection: Boolean = false, val hasSleepDetection: Boolean = false,
/**
* Apple's "Optimized Charge Limit" (AAP setting 0x3B). Distinct from the older
* "Optimized Battery Charging" — that earlier feature isn't exposed as a toggleable
* AAP setting. Enable on any model that's been confirmed (via capture) to push 0x3B
* on connect and accept writes to it.
*/
val hasDynamicEndOfCharge: Boolean = false,
) )
} }
@@ -64,10 +64,30 @@ class AapConnectionManager @Inject constructor(
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32) private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow() val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
/**
* Emits when a connected device fires a Sleep Detection update (AAP opcode 0x57).
* Address-only — the opaque payload stays logged at engine level per the "never suppress
* protocol logging" convention; downstream consumers (SleepReaction) only need the origin
* address to decide whether to act.
*/
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */ /** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16) private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow() val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
/**
* Emits when any setting command failed verification on the device side. Unlike
* [offRejectedEvents] this covers every rejected write (including the ANC-OFF case); UI
* consumers filter by the command type they care about — e.g. the charge-cap toggle shows
* a snackbar only for [AapCommand.SetDynamicEndOfCharge].
*/
private val _settingRejectedEvents =
MutableSharedFlow<Pair<BluetoothAddress, AapCommand>>(extraBufferCapacity = 16)
val settingRejectedEvents: SharedFlow<Pair<BluetoothAddress, AapCommand>> =
_settingRejectedEvents.asSharedFlow()
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] } fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
suspend fun connect( suspend fun connect(
@@ -112,6 +132,14 @@ class AapConnectionManager @Inject constructor(
} }
} }
// Forward sleep events from this connection (child coroutine). Address-only —
// the opaque payload stays logged at the engine layer.
launch {
connection.sleepEvents.collect {
_sleepEvents.tryEmit(address)
}
}
// Forward OFF-rejection events from this connection (child coroutine) // Forward OFF-rejection events from this connection (child coroutine)
launch { launch {
connection.offRejected.collect { connection.offRejected.collect {
@@ -119,6 +147,14 @@ class AapConnectionManager @Inject constructor(
} }
} }
// Forward generic setting-rejection events from this connection (child coroutine).
// Covers every rejected write — consumers filter by the command they care about.
launch {
connection.settingRejected.collect { command ->
_settingRejectedEvents.tryEmit(address to command)
}
}
connection.state.collect { podState -> connection.state.collect { podState ->
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) { if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "Connection to $address disconnected" } log(TAG) { "Connection to $address disconnected" }
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap
import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant import java.time.Instant
@@ -16,6 +17,16 @@ data class AapPodState(
val lastMessageAt: Instant? = null, val lastMessageAt: Instant? = null,
val pendingAncMode: AapSetting.AncMode.Value? = null, val pendingAncMode: AapSetting.AncMode.Value? = null,
val pendingSettingsCount: Int = 0, val pendingSettingsCount: Int = 0,
/**
* 64-bit features bitmask from the Connect Response (packet type 0x0001).
* Opaque — we don't yet know the bit-to-feature mapping. Logged for future
* correlation work. Null until a Connect Response is seen.
*/
val negotiatedFeatures: ULong? = null,
/** Status field from the Connect Response. 0 = success. Null before Connect Response. */
val connectResponseStatus: Int? = null,
/** Parsed Case Info (message type 0x23), if the device responded to a 0x22 probe. */
val caseInfo: AapCaseInfo? = null,
) { ) {
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
@@ -42,6 +53,13 @@ data class AapPodState(
val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent
val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent
// Raw charging state per slot — the full enum, not collapsed to a Boolean. Lets the UI
// distinguish CHARGING_OPTIMIZED ("Optimized Charge Limit in effect") from plain CHARGING.
val leftChargingState: ChargingState? get() = batteries[BatteryType.LEFT]?.charging
val rightChargingState: ChargingState? get() = batteries[BatteryType.RIGHT]?.charging
val caseChargingState: ChargingState? get() = batteries[BatteryType.CASE]?.charging
val headsetChargingState: ChargingState? get() = batteries[BatteryType.SINGLE]?.charging
// Charging state from AAP battery // Charging state from AAP battery
val isLeftCharging: Boolean? val isLeftCharging: Boolean?
get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED } get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
@@ -12,7 +12,8 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage 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.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -45,7 +46,9 @@ internal class AapConnection(
val state: StateFlow<AapPodState> get() = engine.state val state: StateFlow<AapPodState> get() = engine.state
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected val offRejected: SharedFlow<Unit> get() = engine.offRejected
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
private var socket: BluetoothSocket? = null private var socket: BluetoothSocket? = null
private var readerJob: Job? = null private var readerJob: Job? = null
@@ -143,11 +146,36 @@ internal class AapConnection(
break break
} }
// L2CAP SEQPACKET: each read() returns exactly one complete message // L2CAP SEQPACKET: each read() returns exactly one complete frame
val raw = buf.copyOfRange(0, len) val raw = buf.copyOfRange(0, len)
val message = AapMessage.parse(raw) val packet = AapPacket.parse(raw) ?: continue
if (message != null) {
engine.processMessage(message) when (packet) {
is AapPacket.Message -> engine.processMessage(packet)
is AapPacket.ConnectResponse -> {
engine.processConnectResponse(packet)
if (packet.status == 0) dispatchCaseInfoProbe()
}
is AapPacket.Disconnect -> {
log(TAG, Logging.Priority.INFO) {
"Disconnect received: service=0x${"%04X".format(packet.service)} status=0x${"%04X".format(packet.status)}"
}
}
is AapPacket.DisconnectResponse -> {
log(TAG) { "DisconnectResponse received: service=0x${"%04X".format(packet.service)}" }
}
is AapPacket.Connect -> {
// Unexpected — we're the source, not the peer. Log and ignore.
log(TAG, Logging.Priority.WARN) {
"Unexpected Connect packet from peer: service=0x${"%04X".format(packet.service)}"
}
}
is AapPacket.Unknown -> {
val hex = packet.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, Logging.Priority.INFO) {
"Unknown AAP packet type=0x${"%04X".format(packet.packetType)} len=${packet.raw.size} raw=$hex"
}
}
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
@@ -158,6 +186,31 @@ internal class AapConnection(
} }
} }
/**
* Fire-and-forget Case Info request (message type 0x22). Sent after a
* successful Connect Response. Not an AapCommand — bypasses the outbound
* queue, ear-gating, and pending-settings counter. If the device doesn't
* reply, nothing happens; the next connect attempt re-probes.
*/
private suspend fun dispatchCaseInfoProbe() {
val bytes = profile.encodeCaseInfoRequest() ?: return
try {
writeMutex.withLock {
withContext(Dispatchers.IO) {
val sock = socket ?: return@withContext
sock.outputStream.write(bytes)
sock.outputStream.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) }
log(TAG, Logging.Priority.VERBOSE) { "SEND CaseInfoProbe len=${bytes.size} raw=$hex" }
}
}
} catch (e: Exception) {
// Non-critical: the probe is fire-and-forget; a send failure just means
// we don't get Case Info this session. No retry, no state change.
log(TAG, Logging.Priority.WARN) { "CaseInfoProbe send failed: $e" }
}
}
private fun cleanupSocket() { private fun cleanupSocket() {
try { try {
socket?.close() socket?.close()
@@ -4,46 +4,87 @@ import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction import java.nio.charset.CodingErrorAction
/** /**
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for * Diagnostic-only segmentation of a 0x1D INFORMATION payload, used for issue #173
* issue #173 engraving discovery logging. * engraving discovery logging.
*
* Schema matches the Wireshark AAP dissector's INFORMATION message:
* - Segments 0-10 are NUL-delimited UTF-8 strings.
* - Segments 11 and 12 are fixed 17-byte UUIDs (may contain 0x00 internally —
* splitting on NUL here would corrupt the offsets for every later segment).
* - Segments 13 and 14 are NUL-delimited ASCII-decimal timestamps.
*
* Payloads that truncate early just produce fewer segments — the helper stays
* best-effort and never throws.
*/ */
internal object AapDeviceInfoDiagnostics { internal object AapDeviceInfoDiagnostics {
private const val UUID_LEN = 17
private const val LAST_STRING_SEGMENT = 10 // after this the UUID blob starts
fun describeSegments(payload: ByteArray): List<DeviceInfoSegment> { fun describeSegments(payload: ByteArray): List<DeviceInfoSegment> {
var start = 0 var offset = skipBinaryHeader(payload)
while (start < payload.size) { if (offset >= payload.size) return emptyList()
val b = payload[start].toInt() and 0xFF
if (b in 0x20..0x7E) break
start++
}
if (start >= payload.size) return emptyList()
val segments = mutableListOf<DeviceInfoSegment>() val segments = mutableListOf<DeviceInfoSegment>()
var segIndex = 0 var segIndex = 0
var i = start
// Segments 0..10 — NUL-delimited UTF-8 strings
while (segIndex <= LAST_STRING_SEGMENT && offset < payload.size) {
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
if (offset >= payload.size) break
val segStart = offset
while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++
segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset))
segIndex++
}
// Skip the NUL terminator of segment 10 before the UUID blob.
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
// Segments 11 and 12 — fixed 17-byte UUIDs, read verbatim.
for (targetIdx in 11..12) {
if (offset + UUID_LEN > payload.size) break
val segBytes = payload.copyOfRange(offset, offset + UUID_LEN)
segments += buildSegment(targetIdx, offset, segBytes)
offset += UUID_LEN
segIndex = targetIdx + 1
}
// Segments 13+ — NUL-delimited timestamps / any trailing strings.
while (offset < payload.size) {
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
if (offset >= payload.size) break
val segStart = offset
while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++
segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset))
segIndex++
}
return segments
}
private fun skipBinaryHeader(payload: ByteArray): Int {
var i = 0
while (i < payload.size) { while (i < payload.size) {
while (i < payload.size && payload[i] == 0x00.toByte()) i++ val b = payload[i].toInt() and 0xFF
if (i >= payload.size) break if (b in 0x20..0x7E) return i
i++
val segStart = i }
while (i < payload.size && payload[i] != 0x00.toByte()) i++ return payload.size
val segBytes = payload.copyOfRange(segStart, i) }
private fun buildSegment(index: Int, offset: Int, bytes: ByteArray): DeviceInfoSegment {
val utf8: String? = try { val utf8: String? = try {
Charsets.UTF_8.newDecoder() Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(segBytes)) .decode(ByteBuffer.wrap(bytes))
.toString() .toString()
} catch (_: CharacterCodingException) { } catch (_: CharacterCodingException) {
null null
} }
val hex = segBytes.joinToString("") { "%02X".format(it) } val hex = bytes.joinToString("") { "%02X".format(it) }
return DeviceInfoSegment(index, offset, bytes.size, utf8, hex)
segments += DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex)
segIndex++
}
return segments
} }
} }
@@ -1,10 +1,13 @@
package eu.darken.capod.pods.core.apple.aap.engine package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapDynamicEndOfChargeEvent
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlin.reflect.KClass import kotlin.reflect.KClass
@@ -14,6 +17,9 @@ internal sealed interface AapInboundUpdate {
data class Battery(val batteries: Map<AapPodState.BatteryType, AapPodState.Battery>) : AapInboundUpdate data class Battery(val batteries: Map<AapPodState.BatteryType, AapPodState.Battery>) : AapInboundUpdate
data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate
data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate
data class CaseInfo(val info: AapCaseInfo) : AapInboundUpdate
data class SleepEvent(val event: AapSleepEvent) : AapInboundUpdate
data class DynamicEndOfChargeEvent(val event: AapDynamicEndOfChargeEvent) : AapInboundUpdate
data class Setting(val key: KClass<out AapSetting>, val value: AapSetting) : AapInboundUpdate data class Setting(val key: KClass<out AapSetting>, val value: AapSetting) : AapInboundUpdate
} }
@@ -25,6 +31,11 @@ internal class AapInboundInterpreter(
profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) } profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) }
profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) } profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) }
profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) } profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) }
profile.decodeCaseInfo(message)?.let { return AapInboundUpdate.CaseInfo(it) }
profile.decodeSleepEvent(message)?.let { return AapInboundUpdate.SleepEvent(it) }
profile.decodeDynamicEndOfChargeEvent(message)?.let {
return AapInboundUpdate.DynamicEndOfChargeEvent(it)
}
profile.decodeSetting(message)?.let { (key, value) -> profile.decodeSetting(message)?.let { (key, value) ->
return AapInboundUpdate.Setting(key, value) return AapInboundUpdate.Setting(key, value)
} }
@@ -35,7 +35,11 @@ internal class AapOutboundController(
runtimeState: OutboundRuntimeState, runtimeState: OutboundRuntimeState,
command: AapCommand, command: AapCommand,
): OutboundDecision { ): OutboundDecision {
if (command !is AapCommand.SetDeviceName) { // SetDeviceName and SetDynamicEndOfCharge bypass ear-gating:
// - Rename is a user-initiated metadata change, independent of wear state.
// - Charge cap (setting 0x3B) is toggled while pods sit in the closed case; queueing
// it until worn would make the toggle look broken for its main use case.
if (command !is AapCommand.SetDeviceName && command !is AapCommand.SetDynamicEndOfCharge) {
val earDetection = podState.setting<AapSetting.EarDetection>() val earDetection = podState.setting<AapSetting.EarDetection>()
if (earDetection != null && !earDetection.isEitherPodInEar) { if (earDetection != null && !earDetection.isEitherPodInEar) {
val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState) val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState)
@@ -10,7 +10,10 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -47,10 +50,24 @@ internal class AapSessionEngine(
MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow() val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
private val _sleepEvents =
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
private val _offRejected = private val _offRejected =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow() val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
/**
* Fires whenever a write command fails verification after the coordinator's single retry —
* i.e. the device neither echoed the expected state nor retried into a matching one.
* Separate from [offRejected] which is specialised for the ANC-OFF UX path. Consumers filter
* by the command type they care about (e.g. the charge-cap toggle shows a snackbar).
*/
private val _settingRejected =
MutableSharedFlow<AapCommand>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val settingRejected: SharedFlow<AapCommand> = _settingRejected.asSharedFlow()
private val hidTracker = HidTracker { msg -> log(TAG) { msg } } private val hidTracker = HidTracker { msg -> log(TAG) { msg } }
private val inboundInterpreter = AapInboundInterpreter(profile) private val inboundInterpreter = AapInboundInterpreter(profile)
private val ancController = AapAncController() private val ancController = AapAncController()
@@ -88,6 +105,21 @@ internal class AapSessionEngine(
dispatch(AapEngineEvent.MessageReceived(message)) dispatch(AapEngineEvent.MessageReceived(message))
} }
/**
* Handle a Connect Response (packet type 0x0001) from the peer. Stores
* the 64-bit features bitmask in [AapPodState.negotiatedFeatures] for
* future correlation work, but does NOT drive state transitions itself —
* the first subsequent Message packet triggers HANDSHAKING → READY via
* the existing logic.
*
* On non-zero status we log an error and skip the features update; the
* engine stays in HANDSHAKING (no probes should fire until the session
* succeeds).
*/
fun processConnectResponse(packet: AapPacket.ConnectResponse) {
dispatch(AapEngineEvent.ConnectResponseReceived(packet))
}
private fun dispatch(event: AapEngineEvent) { private fun dispatch(event: AapEngineEvent) {
when (event) { when (event) {
is AapEngineEvent.SessionStarted -> { is AapEngineEvent.SessionStarted -> {
@@ -112,13 +144,36 @@ internal class AapSessionEngine(
} }
is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message) is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message)
is AapEngineEvent.ConnectResponseReceived -> handleConnectResponse(event.packet)
is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update) is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update)
is AapEngineEvent.TimerFired -> handleTimerFired(event.key) is AapEngineEvent.TimerFired -> handleTimerFired(event.key)
} }
} }
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
if (packet.status != 0) {
log(TAG, ERROR) {
"ConnectResponse failed: status=0x${"%04X".format(packet.status)} " +
"major=${packet.major} minor=${packet.minor} " +
"features=0x${"%016X".format(packet.features.toLong())}"
}
_state.value = _state.value.copy(connectResponseStatus = packet.status)
return
}
log(TAG, INFO) {
"ConnectResponse OK: major=${packet.major} minor=${packet.minor} " +
"features=0x${"%016X".format(packet.features.toLong())}"
}
_state.value = _state.value.copy(
negotiatedFeatures = packet.features,
connectResponseStatus = packet.status,
lastMessageAt = timeSource.now(),
)
}
private fun handleMessageReceived(message: AapMessage) { private fun handleMessageReceived(message: AapMessage) {
if (message.commandType != CMD_HID_DESCRIPTOR) { if (message.commandType != AapMessageType.BUDDY_COMMAND.value) {
val hex = message.raw.joinToString(" ") { "%02X".format(it) } val hex = message.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { log(TAG, VERBOSE) {
"MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex"
@@ -127,7 +182,7 @@ internal class AapSessionEngine(
} }
if (!runtimeState.handshakeResponseReceived && if (!runtimeState.handshakeResponseReceived &&
message.commandType != CMD_SETTING && message.commandType != AapMessageType.CONTROL.value &&
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
) { ) {
runtimeState = runtimeState.copy(handshakeResponseReceived = true) runtimeState = runtimeState.copy(handshakeResponseReceived = true)
@@ -135,13 +190,13 @@ internal class AapSessionEngine(
log(TAG) { "Connection READY" } log(TAG) { "Connection READY" }
} }
if (message.commandType == CMD_HID_DESCRIPTOR) { if (message.commandType == AapMessageType.BUDDY_COMMAND.value) {
hidTracker.consume(message.payload) hidTracker.consume(message.payload)
_state.value = _state.value.copy(lastMessageAt = timeSource.now()) _state.value = _state.value.copy(lastMessageAt = timeSource.now())
return return
} }
if (message.commandType == CMD_DEVICE_INFO) { if (message.commandType == AapMessageType.INFORMATION.value) {
logDeviceInfoDiagnostics(message.payload) logDeviceInfoDiagnostics(message.payload)
} }
@@ -186,6 +241,25 @@ internal class AapSessionEngine(
log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" } log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" }
} }
is AapInboundUpdate.CaseInfo -> {
_state.value = _state.value.copy(caseInfo = update.info, lastMessageAt = timeSource.now())
val hex = update.info.rawPayload.joinToString(" ") { "%02X".format(it) }
log(TAG, INFO) { "Case info: ${update.info.rawPayload.size}B payload=[$hex]" }
}
is AapInboundUpdate.SleepEvent -> {
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) }
log(TAG, INFO) { "Sleep event: ${update.event.rawPayload.size}B payload=[$hex]" }
_sleepEvents.tryEmit(update.event)
}
is AapInboundUpdate.DynamicEndOfChargeEvent -> {
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) }
log(TAG, INFO) { "Dynamic EoC event: ${update.event.rawPayload.size}B payload=[$hex]" }
}
is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value) is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value)
} }
} }
@@ -361,10 +435,12 @@ internal class AapSessionEngine(
} }
private fun handleRejectedCommand(command: AapCommand?) { private fun handleRejectedCommand(command: AapCommand?) {
if (command == null) return
if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) {
applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc)) applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc))
_offRejected.tryEmit(Unit) _offRejected.tryEmit(Unit)
} }
_settingRejected.tryEmit(command)
} }
private fun scheduleTimer(key: EngineTimerKey, delayMs: Long) { private fun scheduleTimer(key: EngineTimerKey, delayMs: Long) {
@@ -411,20 +487,24 @@ internal class AapSessionEngine(
"DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}" "DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}"
} }
segments.forEach { seg -> segments.forEach { seg ->
// Labels per the Wireshark AAP dissector. The segmenter knows the schema —
// segments 11 and 12 are fixed 17-byte UUIDs.
val label = when (seg.index) { val label = when (seg.index) {
0 -> "name" 0 -> "name"
1 -> "modelNumber" 1 -> "modelNumber"
2 -> "manufacturer" 2 -> "manufacturer"
3 -> "serialNumber" 3 -> "serialNumber"
4 -> "firmwareVersion" 4 -> "firmwareVersion"
5 -> "firmwareVersionDup" 5 -> "firmwareVersionPending"
6 -> "protocolVersion" 6 -> "hardwareVersion"
7 -> "updaterAppId" 7 -> "eaProtocolName"
8 -> "leftEarbudSerial" 8 -> "leftEarbudSerial"
9 -> "rightEarbudSerial" 9 -> "rightEarbudSerial"
10 -> "buildNumber" 10 -> "marketingVersion"
11 -> "encryptedBlob" 11 -> "leftEarbudUuid (17 bytes fixed)"
12 -> "timestamp" 12 -> "rightEarbudUuid (17 bytes fixed)"
13 -> "leftEarbudFirstPaired"
14 -> "rightEarbudFirstPaired"
else -> "unknown" else -> "unknown"
} }
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>" val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
@@ -438,7 +518,7 @@ internal class AapSessionEngine(
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
val sendInfo = currentSendDebugInfo() val sendInfo = currentSendDebugInfo()
if (message.commandType == CMD_SETTING && message.payload.size >= 2) { if (message.commandType == AapMessageType.CONTROL.value && message.payload.size >= 2) {
val settingId = message.payload[0].toInt() and 0xFF val settingId = message.payload[0].toInt() and 0xFF
val value = message.payload[1].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF
val boolHint = value.appleBoolHint() val boolHint = value.appleBoolHint()
@@ -461,7 +541,7 @@ internal class AapSessionEngine(
return return
} }
if (message.commandType == CMD_CONNECTED_DEVICE && message.payload.size >= 6) { if (message.commandType == AapMessageType.MAC_ADDRESS.value && message.payload.size >= 6) {
val macRaw = message.payload.formatMac(reverse = false) val macRaw = message.payload.formatMac(reverse = false)
val macReversed = message.payload.formatMac(reverse = true) val macReversed = message.payload.formatMac(reverse = true)
val tailHex = if (message.payload.size > 6) { val tailHex = if (message.payload.size > 6) {
@@ -472,7 +552,7 @@ internal class AapSessionEngine(
_state.value = _state.value.copy(lastMessageAt = timeSource.now()) _state.value = _state.value.copy(lastMessageAt = timeSource.now())
log(TAG, VERBOSE) { log(TAG, VERBOSE) {
buildString { buildString {
append("Known cmd=0x000C") append("Known cmd=0x000C (${AapMessageType.MAC_ADDRESS.wiresharkName})")
append(" payload=${message.payload.size}B") append(" payload=${message.payload.size}B")
append(" macRaw=$macRaw macReversed=$macReversed") append(" macRaw=$macRaw macReversed=$macReversed")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]") if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
@@ -484,26 +564,56 @@ internal class AapSessionEngine(
if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) { if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) {
_state.value = _state.value.copy(lastMessageAt = timeSource.now()) _state.value = _state.value.copy(lastMessageAt = timeSource.now())
val namedType = AapMessageType.byValue(message.commandType)
val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: ""
log(TAG, VERBOSE) { log(TAG, VERBOSE) {
"Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" "Known cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
} }
return return
} }
val namedType = AapMessageType.byValue(message.commandType)
val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: " (unknown)"
log(TAG, INFO) { log(TAG, INFO) {
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" "Unhandled cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
} }
} }
companion object { companion object {
private val TAG = logTag("AAP", "Engine") private val TAG = logTag("AAP", "Engine")
private const val CMD_SETTING = 0x0009
private const val CMD_CONNECTED_DEVICE = 0x000C
private const val CMD_DEVICE_INFO = 0x001D
private const val CMD_HID_DESCRIPTOR = 0x0017
private val KNOWN_NON_SETTINGS_COMMANDS = setOf( /**
0x0000, 0x0002, 0x002B, 0x004E, 0x0052, 0x0055, 0x0057, * Opcodes we see on-wire but don't model as a domain update. Decoded only
* enough to refresh `lastMessageAt` (freshness feeds the AAP quality boost
* in PodDevice.computeAapBoost). Add newly-catalogued push-only opcodes here
* when they appear in captures so logs get a named label AND freshness stays
* intact.
*/
private val KNOWN_NON_SETTINGS_COMMANDS: Set<Int> = setOf(
0x0000, // Connect (shouldn't reach here but historically observed)
AapMessageType.CAPABILITIES.value, // 0x0002
AapMessageType.DEVICE_LIST.value, // 0x000B
AapMessageType.TRIANGLE_STATUS_REQUEST.value, // 0x0015
AapMessageType.MAGNET_LINK.value, // 0x0016
AapMessageType.TIMESTAMP.value, // 0x001B
AapMessageType.UNKNOWN_0X21.value, // 0x0021
AapMessageType.CASE_INFO.value, // 0x0023 (handled via decoder later; still refresh)
AapMessageType.GYRO_INFO.value, // 0x0028
AapMessageType.STREAM_STATE_INFO.value, // 0x002B
AapMessageType.GAPA_CHALLENGE.value, // 0x002C
AapMessageType.UNKNOWN_0X40.value, // 0x0040
AapMessageType.ADAPTIVE_VOLUME_MESSAGE.value, // 0x004C
AapMessageType.SOURCE_FEATURE_CAPABILITIES.value, // 0x004D
AapMessageType.FEATURE_PROX_CARD_STATUS_UPDATE.value, // 0x004E
AapMessageType.UARP_DATA.value, // 0x004F
AapMessageType.UNKNOWN_0X50.value, // 0x0050
AapMessageType.SOURCE_CONTEXT.value, // 0x0052
AapMessageType.SET_BAND_EDGES.value, // 0x0054 RF band gating (push-only; see AapMessageType kdoc)
AapMessageType.UNKNOWN_0X55.value, // 0x0055
AapMessageType.SLEEP_DETECTION_UPDATE.value, // 0x0057
AapMessageType.UNKNOWN_0X58.value, // 0x0058
AapMessageType.DYNAMIC_END_OF_CHARGE.value, // 0x0059
AapMessageType.PERSONAL_TRANSLATION.value, // 0x0060
) )
} }
} }
@@ -517,6 +627,7 @@ internal sealed interface AapEngineEvent {
data object HandshakeSent : AapEngineEvent data object HandshakeSent : AapEngineEvent
data object ResetRequested : AapEngineEvent data object ResetRequested : AapEngineEvent
data class MessageReceived(val message: AapMessage) : AapEngineEvent data class MessageReceived(val message: AapMessage) : AapEngineEvent
data class ConnectResponseReceived(val packet: AapPacket.ConnectResponse) : AapEngineEvent
data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent
data class TimerFired(val key: EngineTimerKey) : AapEngineEvent data class TimerFired(val key: EngineTimerKey) : AapEngineEvent
} }
@@ -172,6 +172,10 @@ internal class AapSettingsCoordinator(
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled) AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
} }
is AapCommand.SetDynamicEndOfCharge -> {
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled)
}
is AapCommand.SetDeviceName -> { is AapCommand.SetDeviceName -> {
val currentInfo = baseState.deviceInfo ?: return null val currentInfo = baseState.deviceInfo ?: return null
return baseState.copy( return baseState.copy(
@@ -205,6 +209,7 @@ internal class AapSettingsCoordinator(
is AapCommand.SetAllowOffOption -> { s -> s.setting<AapSetting.AllowOffOption>()?.enabled == command.enabled } is AapCommand.SetAllowOffOption -> { s -> s.setting<AapSetting.AllowOffOption>()?.enabled == command.enabled }
is AapCommand.SetStemConfig -> { s -> s.setting<AapSetting.StemConfig>()?.claimedPressMask == command.claimedPressMask } is AapCommand.SetStemConfig -> { s -> s.setting<AapSetting.StemConfig>()?.claimedPressMask == command.claimedPressMask }
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled } is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
is AapCommand.SetDeviceName -> null is AapCommand.SetDeviceName -> null
} }
} }
@@ -42,6 +42,12 @@ internal class HidTracker(private val log: (String) -> Unit) {
log("HID: terminator (${type.payloadSize}B)") log("HID: terminator (${type.payloadSize}B)")
} }
is HidFrameType.ServiceInfo -> {
flush()
val tokens = type.asciiTokens.joinToString(", ")
log("HID: service info tokens=[$tokens] (${type.payloadSize}B)")
}
is HidFrameType.Other -> { is HidFrameType.Other -> {
flush() flush()
val hex = payload.joinToString(" ") { "%02X".format(it) } val hex = payload.joinToString(" ") { "%02X".format(it) }
@@ -70,10 +76,25 @@ internal class HidTracker(private val log: (String) -> Unit) {
data class ServiceDirectory(val services: List<String>) : HidFrameType() data class ServiceDirectory(val services: List<String>) : HidFrameType()
data class Descriptor(val phase: Int, val fill: Int) : HidFrameType() data class Descriptor(val phase: Int, val fill: Int) : HidFrameType()
data class Terminator(val payloadSize: Int) : HidFrameType() data class Terminator(val payloadSize: Int) : HidFrameType()
/**
* 0x0017 "service info" frames — a TLV-ish metadata dump that carries
* ASCII key/value pairs like `VendorID`, `SerialNumber`, `CFG`,
* `ReportDescriptor`, etc.
*
* We don't decode the TLV structure yet (the framing is not fully
* documented). Instead we extract all printable ASCII runs of length
* ≥ 3 so the log tells you which keys/values the frame contains.
*/
data class ServiceInfo(val asciiTokens: List<String>, val payloadSize: Int) : HidFrameType()
data object Other : HidFrameType() data object Other : HidFrameType()
} }
companion object { companion object {
/** Minimum length for an ASCII run to count as a token in a ServiceInfo frame. */
private const val MIN_ASCII_TOKEN_LEN = 3
internal fun classify(payload: ByteArray): HidFrameType { internal fun classify(payload: ByteArray): HidFrameType {
// Service directory frame — starts with FE 00 00 and contains repeated // Service directory frame — starts with FE 00 00 and contains repeated
// [len=4? ascii service name + 4B flags] blocks. For logging, extract names. // [len=4? ascii service name + 4B flags] blocks. For logging, extract names.
@@ -111,7 +132,40 @@ internal class HidTracker(private val log: (String) -> Unit) {
return HidFrameType.Terminator(payload.size) return HidFrameType.Terminator(payload.size)
} }
// "Service info" frame — 4-byte magic 00 00 10 00 followed by a TLV-ish
// payload with ASCII keys and mixed binary values. Observed on AirPods Pro 2
// USB-C after the descriptor batch.
if (payload.size >= 8 &&
payload[0] == 0x00.toByte() &&
payload[1] == 0x00.toByte() &&
payload[2] == 0x10.toByte() &&
payload[3] == 0x00.toByte()
) {
return HidFrameType.ServiceInfo(
asciiTokens = extractAsciiTokens(payload),
payloadSize = payload.size,
)
}
return HidFrameType.Other return HidFrameType.Other
} }
/** Extract printable ASCII runs ≥ 3 chars. Skips all non-printable bytes. */
private fun extractAsciiTokens(payload: ByteArray): List<String> {
val tokens = mutableListOf<String>()
var i = 0
while (i < payload.size) {
val startByte = payload[i].toInt() and 0xFF
if (startByte in 0x20..0x7E) {
val start = i
while (i < payload.size && (payload[i].toInt() and 0xFF) in 0x20..0x7E) i++
val run = String(payload, start, i - start, Charsets.US_ASCII)
if (run.length >= MIN_ASCII_TOKEN_LEN) tokens += run
} else {
i++
}
}
return tokens
}
} }
} }
@@ -0,0 +1,54 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Information about the case itself (message type 0x23, requested via 0x22).
*
* Fields defined by the Wireshark AAP dissector:
* - CaseInfoMessageVersion
* - CaseInfoVID / CaseInfoPID / CaseInfoVIDSource
* - CaseInfoColor
* - CaseInfoVersion (case firmware)
* - CaseInfoName
*
* The Wireshark dissector does not yet decode the per-field byte offsets
* reliably — the payload length and presence of each field varies across
* models. This class holds the raw bytes for future iteration once more
* captures are available, and a best-effort named-field view.
*/
data class AapCaseInfo(
/** The complete payload (after the 6-byte header) for future analysis. */
val rawPayload: ByteArray,
val messageVersion: Int? = null,
val vid: Int? = null,
val pid: Int? = null,
val vidSource: Int? = null,
val color: Int? = null,
val version: String? = null,
val name: String? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AapCaseInfo) return false
if (!rawPayload.contentEquals(other.rawPayload)) return false
if (messageVersion != other.messageVersion) return false
if (vid != other.vid) return false
if (pid != other.pid) return false
if (vidSource != other.vidSource) return false
if (color != other.color) return false
if (version != other.version) return false
if (name != other.name) return false
return true
}
override fun hashCode(): Int {
var r = rawPayload.contentHashCode()
r = 31 * r + (messageVersion ?: 0)
r = 31 * r + (vid ?: 0)
r = 31 * r + (pid ?: 0)
r = 31 * r + (vidSource ?: 0)
r = 31 * r + (color ?: 0)
r = 31 * r + (version?.hashCode() ?: 0)
r = 31 * r + (name?.hashCode() ?: 0)
return r
}
}
@@ -34,5 +34,6 @@ sealed class AapCommand {
data class SetAllowOffOption(val enabled: Boolean) : AapCommand() data class SetAllowOffOption(val enabled: Boolean) : AapCommand()
data class SetStemConfig(val claimedPressMask: Int) : AapCommand() data class SetStemConfig(val claimedPressMask: Int) : AapCommand()
data class SetSleepDetection(val enabled: Boolean) : AapCommand() data class SetSleepDetection(val enabled: Boolean) : AapCommand()
data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand()
data class SetDeviceName(val name: String) : AapCommand() data class SetDeviceName(val name: String) : AapCommand()
} }
@@ -0,0 +1,134 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Catalog of AAP control/setting IDs — the 8-bit ID at payload byte 0 of a
* Control message (see [AapMessageType.CONTROL], opcode 0x0009).
*
* Sources:
* - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua
* - LibrePods / MagicPodsCore
* - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3)
*
* Where CAPod ships a setting under a different user-facing name than the
* Wireshark catalog uses, both names are preserved — the enum uses the
* Wireshark name, UI-facing strings use the CAPod name.
*/
enum class AapControlId(val value: Int, val wiresharkName: String) {
MIC_MODE(0x01, "Mic Mode"),
SCAN(0x02, "Scan"),
RESET(0x03, "Reset"),
BASIC_DOUBLE_TAP_MODE(0x04, "Basic Double Tap Mode"),
BUTTON_SEND_MODE(0x05, "Button Send Mode"),
OWNERSHIP_STATE(0x06, "Ownership state"),
TAP_INTERVAL(0x07, "Tap Interval"),
/** Request the connected bud to go secondary. */
BUD_ROLE(0x08, "Bud Role"),
DEBUG_GET_DATA(0x09, "Debug Get Data"),
IN_EAR_DETECTION(0x0A, "In Ear Detection"),
/** Aka "Dynamic Latency". */
JITTER_BUFFER(0x0B, "Jitter Buffer"),
DOUBLE_TAP_MODE(0x0C, "Double Tap Mode"),
LISTEN_MODE(0x0D, "Listen Mode"),
HEART_RATE_MONITOR_1(0x0E, "Heart Rate Monitor"),
HEART_RATE_MONITOR_2(0x0F, "Heart Rate Monitor"),
UNKNOWN_0X10(0x10, "Unknown/Unassigned"),
SWITCH_CONTROL(0x11, "Switch Control"),
VOICE_TRIGGER(0x12, "Voice Trigger"),
/** "Dictation over AirPods" for Siri. */
DOAP_MODE(0x13, "DoAP mode"),
SINGLE_CLICK(0x14, "Single Click"),
DOUBLE_CLICK(0x15, "Double Click"),
CLICK_AND_HOLD(0x16, "Click and Hold"),
/** CAPod ships this as "Press Speed". */
DOUBLE_CLICK_INTERVAL(0x17, "Double Click Interval"),
/** CAPod ships this as "Press Hold Duration". */
CLICK_AND_HOLD_INTERVAL(0x18, "Click and Hold Interval"),
UNKNOWN_0X19(0x19, "Unknown/Unassigned"),
LISTENING_MODE_CONFIGS(0x1A, "Listening Mode Configs"),
ONE_BUD_ANC_MODE(0x1B, "One Bud ANC Mode"),
CROWN_ROTATION_DIRECTION(0x1C, "Crown Rotation Direction"),
UNKNOWN_0X1D(0x1D, "Unknown/Unassigned"),
AUTO_ANSWER_MODE(0x1E, "Auto Answer Mode"),
/** CAPod ships this as "Tone Volume". */
CHIME_VOLUME(0x1F, "Chime Volume"),
SMART_ROUTING_MODE(0x20, "Smart Routing Mode"),
UNKNOWN_0X21(0x21, "Unknown/Unassigned"),
HFP_UPLINK_MODE(0x22, "HFP Uplink Mode"),
/** CAPod ships this as "Volume Swipe Length". */
VOLUME_SWIPE_INTERVAL(0x23, "Volume Swipe Interval"),
/** CAPod ships this as "End Call / Mute Mic". */
CALL_MANAGEMENT_CONFIG(0x24, "Call Management Config"),
/** CAPod ships this as "Volume Swipe". */
VOLUME_SWIPE_MODE(0x25, "Volume Swipe Mode"),
/**
* "Adaptive Volume" per Wireshark. CAPod ships it as "Personalized Volume"
* because that matches the iOS Settings.app label — unclear if this is
* the same feature or the two sources disagree on naming.
*/
ADAPTIVE_VOLUME(0x26, "Adaptive Volume"),
SOFTWARE_MUTE(0x27, "Software Mute"),
/** CAPod ships this as "Conversational Awareness". */
CONVERSATION_DETECT(0x28, "Conversation Detect"),
SELECTIVE_SPEECH_LISTENING(0x29, "Selective Speech Listening"),
UNKNOWN_0X2A(0x2A, "Unknown/Unassigned"),
UNKNOWN_0X2B(0x2B, "Unknown/Unassigned"),
HEARING_AID(0x2C, "Hearing Aid"),
UNKNOWN_0X2D(0x2D, "Unknown/Unassigned"),
/** CAPod ships this as "Adaptive Audio Noise". */
AUTO_ANC_STRENGTH(0x2E, "Auto ANC Strength"),
HEARING_AID_GAIN_SWIPE(0x2F, "Hearing Aid Gain Swipe"),
HEART_RATE_MONITOR_3(0x30, "Heart Rate Monitor"),
IN_CASE_TONE(0x31, "In-Case Tone"),
SIRI_MULTITONE(0x32, "Siri Multitone"),
HEARING_ASSIST(0x33, "Hearing Assist"),
ALLOW_OFF_OPTION(0x34, "Allow Off Option"),
SLEEP_DETECTION(0x35, "Sleep Detection"),
ALLOW_AUTO_CONNECT_FROM_AUDIO_ACCESSORY(0x36, "Allow Auto Connect from Audio Accessory"),
HEARING_PROTECTION_PPE(0x37, "Hearing Protection PPE"),
PPE_CAP_LEVEL_CONFIG(0x38, "PPE Cap Level Config"),
/** CAPod ships this as "Stem Config". */
RAW_GESTURES_CONFIG(0x39, "Raw Gestures Config"),
ALLOW_TEMPORARY_MANAGED_PAIRING(0x3A, "Allow Temporary Managed Pairing"),
DYNAMIC_END_OF_CHARGE(0x3B, "Dynamic End of Charge"),
SYSTEM_SIRI_MODE(0x3C, "System Siri Mode"),
/** "hearingAidV2SourceRegionSupport" per Wireshark. */
HEARING_AID_GENERIC(0x3D, "Hearing Aid Generic"),
UPLINK_EQ_BUD(0x3E, "Uplink EQ Bud"),
UPLINK_EQ_SOURCE(0x3F, "Uplink EQ Source"),
/** Separate from [IN_CASE_TONE] (0x31) — this is a volume level, not an on/off. */
IN_CASE_TONE_VOLUME(0x40, "In Case Tone Volume"),
DISABLE_BUTTON_INPUT(0x41, "Disable Button Input"),
EXTENDED_HOLD_AND_RELEASE(0x42, "Extended Hold and Release"),
;
companion object {
private val byValue: Map<Int, AapControlId> = entries.associateBy { it.value }
fun byValue(value: Int): AapControlId? = byValue[value]
}
}
@@ -1,15 +1,96 @@
package eu.darken.capod.pods.core.apple.aap.protocol package eu.darken.capod.pods.core.apple.aap.protocol
import java.time.Instant
/** /**
* Device identity parsed from the AAP handshake response (message type 0x1D). * Device identity parsed from the AAP Information message (type 0x1D).
*
* Field ordering and semantics are sourced from the Wireshark AAP dissector:
* <https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua>
*
* Segments 0-10 are NUL-delimited UTF-8. Segments 11-12 are fixed 17-byte
* UUIDs (may contain 0x00 — never treat as strings). Segments 13-14 are
* NUL-delimited timestamps (Unix epoch seconds, ASCII).
*
* Any field past the system fields may be absent on older devices or
* truncated payloads — all optional fields are nullable.
*/ */
data class AapDeviceInfo( data class AapDeviceInfo(
/** Segment 0 — user-visible device name ("AirPods Pro"). */
val name: String, val name: String,
/** Segment 1 — Apple model identifier ("A2084"). */
val modelNumber: String, val modelNumber: String,
/** Segment 2 — manufacturer string ("Apple Inc."). */
val manufacturer: String, val manufacturer: String,
/** Segment 3 — system (case) serial number. */
val serialNumber: String, val serialNumber: String,
/** Segment 4 — currently-running firmware version. */
val firmwareVersion: String, val firmwareVersion: String,
/** Segment 5 — pending firmware version after next reboot. Null if equal to active. */
val firmwareVersionPending: String? = null,
/** Segment 6 — hardware revision ("1.0.0"). */
val hardwareVersion: String? = null,
/** Segment 7 — External Accessory protocol name ("com.apple.accessory.updater.app.71"). */
val eaProtocolName: String? = null,
/** Segment 8 — left earbud serial. */
val leftEarbudSerial: String? = null, val leftEarbudSerial: String? = null,
/** Segment 9 — right earbud serial. */
val rightEarbudSerial: String? = null, val rightEarbudSerial: String? = null,
val buildNumber: String? = null, /**
) * Segment 10 — marketing/build version (e.g. "8454624"). Originally
* mis-labeled `buildNumber` in CAPod; the Wireshark dissector calls it
* "Marketing Version".
*/
val marketingVersion: String? = null,
/** Segment 11 — opaque 17-byte left-bud UUID. May contain arbitrary bytes. */
val leftEarbudUuid: ByteArray? = null,
/** Segment 12 — opaque 17-byte right-bud UUID. May contain arbitrary bytes. */
val rightEarbudUuid: ByteArray? = null,
/** Segment 13 — first-time-pairing timestamp for the left bud. */
val leftEarbudFirstPaired: Instant? = null,
/** Segment 14 — first-time-pairing timestamp for the right bud. */
val rightEarbudFirstPaired: Instant? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AapDeviceInfo) return false
if (name != other.name) return false
if (modelNumber != other.modelNumber) return false
if (manufacturer != other.manufacturer) return false
if (serialNumber != other.serialNumber) return false
if (firmwareVersion != other.firmwareVersion) return false
if (firmwareVersionPending != other.firmwareVersionPending) return false
if (hardwareVersion != other.hardwareVersion) return false
if (eaProtocolName != other.eaProtocolName) return false
if (leftEarbudSerial != other.leftEarbudSerial) return false
if (rightEarbudSerial != other.rightEarbudSerial) return false
if (marketingVersion != other.marketingVersion) return false
if (!leftEarbudUuid.contentOptionalEquals(other.leftEarbudUuid)) return false
if (!rightEarbudUuid.contentOptionalEquals(other.rightEarbudUuid)) return false
if (leftEarbudFirstPaired != other.leftEarbudFirstPaired) return false
if (rightEarbudFirstPaired != other.rightEarbudFirstPaired) return false
return true
}
override fun hashCode(): Int {
var r = name.hashCode()
r = 31 * r + modelNumber.hashCode()
r = 31 * r + manufacturer.hashCode()
r = 31 * r + serialNumber.hashCode()
r = 31 * r + firmwareVersion.hashCode()
r = 31 * r + (firmwareVersionPending?.hashCode() ?: 0)
r = 31 * r + (hardwareVersion?.hashCode() ?: 0)
r = 31 * r + (eaProtocolName?.hashCode() ?: 0)
r = 31 * r + (leftEarbudSerial?.hashCode() ?: 0)
r = 31 * r + (rightEarbudSerial?.hashCode() ?: 0)
r = 31 * r + (marketingVersion?.hashCode() ?: 0)
r = 31 * r + (leftEarbudUuid?.contentHashCode() ?: 0)
r = 31 * r + (rightEarbudUuid?.contentHashCode() ?: 0)
r = 31 * r + (leftEarbudFirstPaired?.hashCode() ?: 0)
r = 31 * r + (rightEarbudFirstPaired?.hashCode() ?: 0)
return r
}
}
private fun ByteArray?.contentOptionalEquals(other: ByteArray?): Boolean =
if (this == null || other == null) this === other else contentEquals(other)
@@ -74,6 +74,33 @@ interface AapDeviceProfile {
*/ */
fun decodeStemPress(message: AapMessage): StemPressEvent? fun decodeStemPress(message: AapMessage): StemPressEvent?
/**
* Encode a Case Info request (command 0x22). Returns null when this profile
* shouldn't probe the case — older models may not respond, or the payload
* schema is unconfirmed for that model. Fire-and-forget: if the device
* doesn't reply, nothing happens.
*/
fun encodeCaseInfoRequest(): ByteArray? = null
/**
* Decode a Case Info response (command 0x23). Returns null if the message
* is not a Case Info response or the payload cannot be recognised.
*/
fun decodeCaseInfo(message: AapMessage): AapCaseInfo? = null
/**
* Decode a Sleep Detection Update event (command 0x57). Returns null for
* any other message type. Payload schema is not fully documented — the
* default implementation returns the raw bytes verbatim.
*/
fun decodeSleepEvent(message: AapMessage): AapSleepEvent? = null
/**
* Decode a Dynamic End-of-Charge event (command 0x59). Returns null for
* any other message type.
*/
fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? = null
companion object { companion object {
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model) fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model)
} }
@@ -0,0 +1,20 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Push-only event paired with the Dynamic End-of-Charge setting (control ID 0x3B).
* Message type 0x59 — reports charge-cap status transitions (e.g. "80% cap reached").
*
* Payload schema is not yet documented publicly — this type preserves the raw
* bytes so future capture-based analysis can fill in structured fields.
*/
data class AapDynamicEndOfChargeEvent(
val rawPayload: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AapDynamicEndOfChargeEvent) return false
return rawPayload.contentEquals(other.rawPayload)
}
override fun hashCode(): Int = rawPayload.contentHashCode()
}
@@ -1,36 +1,5 @@
package eu.darken.capod.pods.core.apple.aap.protocol package eu.darken.capod.pods.core.apple.aap.protocol
/**
* A parsed AAP protocol message.
*/
data class AapMessage(
val raw: ByteArray,
val commandType: Int,
val payload: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AapMessage) return false
return raw.contentEquals(other.raw)
}
override fun hashCode(): Int = raw.contentHashCode()
companion object {
/**
* Parse a complete AAP message from raw bytes.
* AAP messages have the format: [4-byte header] [2-byte command type] [payload]
* Minimum message size is 6 bytes (header + command type with no payload).
*/
fun parse(raw: ByteArray): AapMessage? {
if (raw.size < 6) return null
val commandType = (raw[4].toInt() and 0xFF) or ((raw[5].toInt() and 0xFF) shl 8)
val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0)
return AapMessage(raw = raw.copyOf(), commandType = commandType, payload = payload)
}
}
}
/** /**
* Accumulates bytes from a stream and emits complete [AapMessage] objects. * Accumulates bytes from a stream and emits complete [AapMessage] objects.
* *
@@ -39,6 +8,12 @@ data class AapMessage(
* *
* AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian) * AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian)
* indicate total message length (excluding the first 4 header bytes). * indicate total message length (excluding the first 4 header bytes).
*
* Note: production code bypasses this framer [AapConnection.readLoop]
* parses whole reads directly because L2CAP SEQPACKET already delivers
* per-frame boundaries. The framer is retained for future stream-mode
* paths but its splitting rule doesn't match every real capture
* (see `AapFramerTest` for the shape it expects).
*/ */
class AapFramer { class AapFramer {
@@ -0,0 +1,124 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Catalog of AAP (Apple Accessory Protocol, also "AACP") message types —
* the 16-bit opcode at bytes 4-5 of a Message-type packet (packet type 0x04).
*
* Sources:
* - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua
* - LibrePods / MagicPodsCore
* - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3)
*
* Entries whose names start with `UNKNOWN_` exist in Wireshark's dissector
* but have no known meaning. They're catalogued here so unhandled-message
* logging still reports a named opcode instead of a bare int.
*/
enum class AapMessageType(val value: Int, val wiresharkName: String) {
CAPABILITIES_REQUEST(0x0001, "Capabilities Request"),
CAPABILITIES(0x0002, "Capabilities"),
BATTERY_INFO_REQUEST(0x0003, "Battery Info Request"),
BATTERY_INFO(0x0004, "Battery Info"),
EAR_DETECTION_REQUEST(0x0005, "Ear Detection Request"),
EAR_DETECTION(0x0006, "Ear Detection"),
BUD_ROLE_REQUEST(0x0007, "Bud Role Request"),
BUD_ROLE(0x0008, "Bud Role"),
/** Controls / settings. The control ID is payload byte 0 — see [AapControlId]. */
CONTROL(0x0009, "Control"),
DEVICE_LIST(0x000B, "Device List"),
MAC_ADDRESS(0x000C, "MAC Address"),
STREAM_STATE_INFO_REQUEST(0x000D, "Stream State Info Request"),
AUDIO_SOURCE(0x000E, "Audio Source"),
SET_NOTIFICATION_FILTER(0x000F, "Set Notification Filter"),
SMART_ROUTING_1(0x0010, "Smart Routing"),
SMART_ROUTING_2(0x0011, "Smart Routing"),
EASY_PAIR_REQUEST(0x0012, "Easy Pair Request?"),
CONNECT_PRIORITY_LIST(0x0014, "Connect Priority List"),
TRIANGLE_STATUS_REQUEST(0x0015, "Triangle Status Request"),
MAGNET_LINK(0x0016, "Magnet Link"),
/**
* Buddy Command per Wireshark. In CAPod we treat payloads of this opcode as
* HID descriptor frames (Service Directory / descriptor / terminator — see [HidTracker]).
* Both interpretations may be correct for different sub-payloads.
*/
BUDDY_COMMAND(0x0017, "BuddyCommand"),
STEM_PRESS(0x0019, "Stem Press"),
RENAME(0x001A, "Rename"),
TIMESTAMP(0x001B, "Timestamp"),
INFORMATION(0x001D, "Information"),
SEND_EXTERNAL_ACCESSORY_SESSION_PACKET(0x001E, "Send External Accessory Session Packet"),
NOTIFY_SESSION_STATE(0x001F, "Notify Session State?"),
SEND_REMOTE_FIRMWARE_AUTH_DATA(0x0020, "Send Remote Firmware Auth Data"),
UNKNOWN_0X21(0x0021, "Unknown"),
CASE_INFO_REQUEST(0x0022, "Case Info Request"),
CASE_INFO(0x0023, "Case Info"),
SEND_DEVICE_INFO(0x0024, "Send Device Info?"),
CERTIFICATES_REQUEST(0x0026, "Certificates Request"),
CERTIFICATES(0x0027, "Certificates"),
GYRO_INFO(0x0028, "Gyro Info"),
SET_COUNTRY_CODE(0x0029, "Set Country Code"),
STREAM_STATE_INFO(0x002B, "Stream State Info"),
GAPA_CHALLENGE(0x002C, "GAPA Challenge"),
CONNECTED_DEVICES_REQUEST(0x002D, "Connected Devices Request"),
CONNECTED_DEVICES(0x002E, "Connected Devices"),
MAGIC_KEYS_REQUEST(0x0030, "Magic Keys Request"),
MAGIC_KEYS(0x0031, "Magic Keys"),
MAGIC_KEYS_2(0x0032, "Magic Keys"),
UNKNOWN_0X40(0x0040, "Unknown"),
SEND_SMART_ROUTING_2_INFO(0x0044, "Send Smart Routing 2.0 Info"),
FAST_CONNECT_COMPLETE(0x0045, "Fast Connect Complete?"),
BUD_SWAP_2_PROCEDURE(0x0047, "Bud Swap 2.0 Procedure?"),
SWAP_IMMINENT_CONFIRM(0x0048, "Swap Imminent Confirm?"),
BUD_SWAP_2_COMPLETION(0x0049, "Bud Swap 2.0 Completion?"),
SWAP_COMPLETE_CONFIRM(0x004A, "Swap Complete Confirm?"),
CONVERSATIONAL_AWARENESS(0x004B, "Conversational Awareness"),
ADAPTIVE_VOLUME_MESSAGE(0x004C, "Adaptive Volume Message"),
/**
* Source Feature Capabilities. Sent by the source after Connect Response to
* advertise what it supports. In CAPod this is "InitExt" — we only emit a
* fixed template, we don't read/decode the reply.
*/
SOURCE_FEATURE_CAPABILITIES(0x004D, "Source Feature Capabilities"),
FEATURE_PROX_CARD_STATUS_UPDATE(0x004E, "Feature ProxCard Status Update"),
UARP_DATA(0x004F, "UARP Data"),
UNKNOWN_0X50(0x0050, "Unknown"),
SOURCE_CONTEXT(0x0052, "Source Context"),
/**
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment) —
* hearing-aid configuration for the iOS 18.1+ hearing-aid feature on AirPods
* Pro 2. Decoded as 4 × 8 Float32 values (see [AapSetting.PmeConfig]); see
* that data class for the layout rationale. "PME Config" is the label the
* Wireshark AAP dissector uses for this opcode.
*/
PME_CONFIG(0x0053, "PME Config"),
/**
* Configures the AirPods radio's allowed RF bands. AirPods Pro 2 USB-C
* and newer can transmit on 5 GHz / 6 GHz U-NII bands in addition to
* 2.4 GHz ISM — Apple uses this for the proprietary lossless audio mode
* with Apple Vision Pro. The "type" field is a band code from Apple's
* `BSM_BAND_CODE_*` enum (0x0 = ISM24, 0x1/0x2/0x3 = U-NII-1/3/4,
* 0x40x7 = U-NII-5A/B/C/D, 0x8 = INVALID; identified via FCC firmware
* analysis). "Set Band Edges" is the label the Wireshark AAP dissector
* uses for this opcode. CAPod observes but does not decode the payload.
*/
SET_BAND_EDGES(0x0054, "Set Band Edges"),
UNKNOWN_0X55(0x0055, "Unknown"),
USB_SPATIAL_SENSOR_DATA_REQUEST(0x0056, "USB Spatial Sensor Data Request"),
SLEEP_DETECTION_UPDATE(0x0057, "Sleep Detection Update"),
UNKNOWN_0X58(0x0058, "Unknown"),
DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"),
PERSONAL_TRANSLATION(0x0060, "Personal Translation"),
;
companion object {
private val byValue: Map<Int, AapMessageType> = entries.associateBy { it.value }
fun byValue(value: Int): AapMessageType? = byValue[value]
}
}
@@ -0,0 +1,172 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* A parsed AAP (AACP) frame. Packet type lives at bytes 0-1 (little-endian)
* and selects which variant we're looking at.
*
* Per the Wireshark dissector, packet types are:
* - 0x0000 — Connect (session open request; source → pods)
* - 0x0001 — Connect Response (pods → source)
* - 0x0002 — Disconnect
* - 0x0003 — Disconnect Response
* - 0x0004 — Message (the large Message type with a 16-bit message/command ID)
*
* Only `Message` packets carry the `(commandType, payload)` pair that the
* existing decoder pipeline consumes; the other variants have their own
* field schema and bypass the decoder.
*/
sealed class AapPacket(val raw: ByteArray) {
/** Source → pods session open. We emit this as our handshake. */
class Connect(
raw: ByteArray,
val service: Int,
val major: Int,
val minor: Int,
val features: ULong,
) : AapPacket(raw)
/**
* Pods → source response to a Connect. The `features` bitmask is opaque
* for now (no public bit-to-feature mapping); CAPod logs it and stores
* it in `AapPodState` for future correlation work.
*/
class ConnectResponse(
raw: ByteArray,
val service: Int,
val status: Int,
val major: Int,
val minor: Int,
val features: ULong,
) : AapPacket(raw)
class Disconnect(
raw: ByteArray,
val service: Int,
val status: Int,
) : AapPacket(raw)
class DisconnectResponse(
raw: ByteArray,
val service: Int,
) : AapPacket(raw)
/**
* Message packet — bytes 4-5 are the message/command ID. This is what
* [AapDeviceProfile.decodeSetting] / `decodeBattery` / etc. consume.
*
* Typealiased to `AapMessage` for backward compatibility.
*/
class Message(
raw: ByteArray,
val commandType: Int,
val payload: ByteArray,
) : AapPacket(raw) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Message) return false
return raw.contentEquals(other.raw)
}
override fun hashCode(): Int = raw.contentHashCode()
override fun toString(): String =
"AapPacket.Message(cmd=0x${"%04X".format(commandType)}, payload=${payload.size}B)"
companion object {
/**
* Parse a Message-type AAP frame. Returns null if the bytes aren't
* a complete Message packet (packet type != 0x0004, or payload too short).
* Non-Message packets (Connect Response etc.) return null here —
* use [AapPacket.parse] if you need to handle them.
*/
fun parse(raw: ByteArray): Message? {
val packet = AapPacket.parse(raw) ?: return null
return packet as? Message
}
}
}
/** Unknown / unrecognised packet type. Preserved for logging. */
class Unknown(raw: ByteArray, val packetType: Int) : AapPacket(raw)
companion object {
private const val PACKET_TYPE_CONNECT = 0x0000
private const val PACKET_TYPE_CONNECT_RESPONSE = 0x0001
private const val PACKET_TYPE_DISCONNECT = 0x0002
private const val PACKET_TYPE_DISCONNECT_RESPONSE = 0x0003
private const val PACKET_TYPE_MESSAGE = 0x0004
/**
* Parse a raw AAP frame. Returns:
* - `null` if the buffer is too short to even read the packet type.
* - An [Unknown] if the packet type isn't one we recognise.
* - The appropriate subclass otherwise.
*
* Note: this assumes one frame per call — L2CAP SEQPACKET delivers
* complete frames per read, matching this expectation. The
* (unused-in-prod) [AapFramer] uses a different splitting approach.
*/
fun parse(raw: ByteArray): AapPacket? {
if (raw.size < 4) return null
val packetType = readLe16(raw, 0)
val service = readLe16(raw, 2)
return when (packetType) {
PACKET_TYPE_CONNECT -> parseConnect(raw, service)
PACKET_TYPE_CONNECT_RESPONSE -> parseConnectResponse(raw, service)
PACKET_TYPE_DISCONNECT -> parseDisconnect(raw, service)
PACKET_TYPE_DISCONNECT_RESPONSE -> DisconnectResponse(raw.copyOf(), service)
PACKET_TYPE_MESSAGE -> parseMessage(raw)
else -> Unknown(raw.copyOf(), packetType)
}
}
private fun parseConnect(raw: ByteArray, service: Int): Connect? {
if (raw.size < 16) return null
val major = readLe16(raw, 4)
val minor = readLe16(raw, 6)
val features = readLe64(raw, 8)
return Connect(raw.copyOf(), service, major, minor, features)
}
private fun parseConnectResponse(raw: ByteArray, service: Int): ConnectResponse? {
if (raw.size < 18) return null
val status = readLe16(raw, 4)
val major = readLe16(raw, 6)
val minor = readLe16(raw, 8)
val features = readLe64(raw, 10)
return ConnectResponse(raw.copyOf(), service, status, major, minor, features)
}
private fun parseDisconnect(raw: ByteArray, service: Int): Disconnect? {
if (raw.size < 6) return null
val status = readLe16(raw, 4)
return Disconnect(raw.copyOf(), service, status)
}
private fun parseMessage(raw: ByteArray): Message? {
if (raw.size < 6) return null
val commandType = readLe16(raw, 4)
val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0)
return Message(raw = raw.copyOf(), commandType = commandType, payload = payload)
}
private fun readLe16(data: ByteArray, offset: Int): Int =
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
private fun readLe64(data: ByteArray, offset: Int): ULong {
var result = 0UL
for (i in 0 until 8) {
result = result or ((data[offset + i].toLong() and 0xFFL).toULong() shl (i * 8))
}
return result
}
}
}
/**
* Backward-compat alias — CAPod historically named the Message packet just
* `AapMessage`. Kept as a type alias so decoder signatures don't churn.
*/
typealias AapMessage = AapPacket.Message
@@ -146,6 +146,15 @@ sealed class AapSetting {
val enabled: Boolean, val enabled: Boolean,
) : AapSetting() ) : AapSetting()
/**
* Apple's "Optimized Charge Limit" — AAP setting 0x3B, Apple-bool encoding. Distinct
* from the older "Optimized Battery Charging" which Apple doesn't expose as a
* user-controllable AAP setting. Supported models push this value on connect.
*/
data class DynamicEndOfCharge(
val enabled: Boolean,
) : AapSetting()
data class InCaseTone( data class InCaseTone(
val enabled: Boolean, val enabled: Boolean,
) : AapSetting() ) : AapSetting()
@@ -163,9 +172,26 @@ sealed class AapSetting {
enum class AudioSourceType { NONE, CALL, MEDIA } enum class AudioSourceType { NONE, CALL, MEDIA }
} }
data class EqBands( /**
* Payload of message type 0x0053 — "PME Config" in the Wireshark AAP dissector.
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment):
* the hearing-aid configuration for Apple's iOS 18.1+ hearing-aid feature on
* AirPods Pro 2.
*
* Decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
* audiogram band gains (e.g. L/R × two environment profiles, 8 frequency
* bands). CAPod previously called this "EQ bands".
*
* Callers should treat all-zero [sets] as "no hearing-aid profile configured"
* — stock firmware reports zeros until the user runs Apple's Hearing Test /
* hearing-aid setup.
*/
data class PmeConfig(
val sets: List<List<Float>>, val sets: List<List<Float>>,
) : AapSetting() ) : AapSetting() {
val isAllZero: Boolean
get() = sets.all { set -> set.all { it == 0f } }
}
/** Per-pod placement reported by the device (command 0x06). */ /** Per-pod placement reported by the device (command 0x06). */
data class EarDetection( data class EarDetection(
@@ -0,0 +1,20 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Push-only event emitted by newer AirPods firmware when the Sleep Detection
* feature (setting 0x35) is enabled (message type 0x57 "Sleep Detection Update").
*
* Payload schema is not yet documented publicly — this type preserves the raw
* bytes so future capture-based analysis can fill in structured fields.
*/
data class AapSleepEvent(
val rawPayload: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AapSleepEvent) return false
return rawPayload.contentEquals(other.rawPayload)
}
override fun hashCode(): Int = rawPayload.contentHashCode()
}
@@ -16,58 +16,42 @@ class DefaultAapDeviceProfile(
) : AapDeviceProfile { ) : AapDeviceProfile {
companion object { companion object {
// AAP command types (bytes 4-5 of the message, little-endian) /**
const val CMD_SETTINGS = 0x0009 * Catalogued control IDs we see on-wire but don't yet model as a named
const val CMD_BATTERY = 0x0004 * [AapSetting] subclass. Decoded as [AapSetting.UnknownSetting] so
const val CMD_DEVICE_INFO = 0x001D * `lastMessageAt` still refreshes (freshness feeds AAP quality boost).
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031 * Promote an entry to a dedicated subclass when its semantics are
const val CMD_EAR_DETECTION = 0x0006 * confirmed from captures.
const val CMD_PRIMARY_POD = 0x0008 *
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B * Observed on Pro 2 USB-C and/or Pro 3.
*/
// Setting IDs (first byte of settings command payload) val UNMAPPED_SETTING_IDS = setOf(
const val SETTING_ANC_MODE = 0x0D AapControlId.SELECTIVE_SPEECH_LISTENING.value, // 0x29
const val SETTING_PRESS_SPEED = 0x17 AapControlId.HEARING_AID.value, // 0x2C
const val SETTING_PRESS_HOLD_DURATION = 0x18 AapControlId.HEARING_AID_GAIN_SWIPE.value, // 0x2F
const val SETTING_NC_ONE_AIRPOD = 0x1B AapControlId.HEART_RATE_MONITOR_3.value, // 0x30
const val SETTING_TONE_VOLUME = 0x1F AapControlId.HEARING_ASSIST.value, // 0x33
const val SETTING_VOLUME_SWIPE_LENGTH = 0x23 AapControlId.HEARING_PROTECTION_PPE.value, // 0x37
const val SETTING_END_CALL_MUTE_MIC = 0x24 AapControlId.PPE_CAP_LEVEL_CONFIG.value, // 0x38
const val SETTING_VOLUME_SWIPE = 0x25 AapControlId.UPLINK_EQ_BUD.value, // 0x3E
const val SETTING_PERSONALIZED_VOLUME = 0x26
const val SETTING_CONVERSATIONAL_AWARENESS = 0x28
const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E
const val SETTING_MICROPHONE_MODE = 0x01
const val SETTING_EAR_DETECTION_ENABLED = 0x0A
const val SETTING_LISTENING_MODE_CYCLE = 0x1A
// Kept decoded internally (never exposed in UI): the device pushes 0x31 frames and dropping the
// decode would fall through to "Unhandled message" logging and prevent lastMessageAt refresh,
// which AAP freshness / boost logic in PodDevice.computeAapBoost depends on.
// Originally labeled "Charging Sounds" but the real case tones are controlled over ATT, not here;
// the actual effect of this setting is unknown, so we don't expose or write it.
const val SETTING_IN_CASE_TONE = 0x31
const val SETTING_ALLOW_OFF_OPTION = 0x34
const val SETTING_SLEEP_DETECTION = 0x35
const val SETTING_STEM_CONFIG = 0x39
// Known-but-unconfirmed setting IDs (H2+ exclusive). Decoded as UnknownSetting
// to keep lastMessageAt fresh. Observed on Pro 2 USB-C and/or Pro 3.
val UNCONFIRMED_SETTING_IDS = setOf(
0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E,
) )
// Command types for non-settings messages
const val CMD_RENAME = 0x001E
const val CMD_STEM_PRESS = 0x0019
const val CMD_CONNECTED_DEVICES = 0x002E
const val CMD_AUDIO_SOURCE = 0x000E
const val CMD_EQ_DATA = 0x0053
// ANC mode wire values // ANC mode wire values
const val ANC_WIRE_OFF = 0x01 const val ANC_WIRE_OFF = 0x01
const val ANC_WIRE_ON = 0x02 const val ANC_WIRE_ON = 0x02
const val ANC_WIRE_TRANSPARENCY = 0x03 const val ANC_WIRE_TRANSPARENCY = 0x03
const val ANC_WIRE_ADAPTIVE = 0x04 const val ANC_WIRE_ADAPTIVE = 0x04
/** Fixed length of each earbud UUID segment in the Information payload (segments 11 & 12). */
private const val UUID_LEN = 17
/**
* Models where sending a 0x22 CASE_INFO_REQUEST yields a 0x23 response.
* Grow this list as captures confirm other models respond correctly.
*/
private val CASE_INFO_ALLOWLIST = setOf(
PodModel.AIRPODS_PRO3,
)
} }
private val supportedAncModes: List<AapSetting.AncMode.Value> by lazy { private val supportedAncModes: List<AapSetting.AncMode.Value> by lazy {
@@ -95,31 +79,32 @@ class DefaultAapDeviceProfile(
) )
override fun encodeCommand(command: AapCommand): ByteArray = when (command) { override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode)) is AapCommand.SetAncMode -> buildSettingsMessage(AapControlId.LISTEN_MODE.value, encodeAncMode(command.mode))
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled)) is AapCommand.SetConversationalAwareness -> buildSettingsMessage(AapControlId.CONVERSATION_DETECT.value, encodeAppleBool(command.enabled))
is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue) is AapCommand.SetPressSpeed -> buildSettingsMessage(AapControlId.DOUBLE_CLICK_INTERVAL.value, command.value.wireValue)
is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue) is AapCommand.SetPressHoldDuration -> buildSettingsMessage(AapControlId.CLICK_AND_HOLD_INTERVAL.value, command.value.wireValue)
is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled)) is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(AapControlId.ONE_BUD_ANC_MODE.value, encodeAppleBool(command.enabled))
is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64)) is AapCommand.SetToneVolume -> buildSettingsMessage(AapControlId.CHIME_VOLUME.value, command.level.coerceIn(0x0F, 0x64))
is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue) is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_INTERVAL.value, command.value.wireValue)
is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled)) is AapCommand.SetVolumeSwipe -> buildSettingsMessage(AapControlId.VOLUME_SWIPE_MODE.value, encodeAppleBool(command.enabled))
is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled)) is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(AapControlId.ADAPTIVE_VOLUME.value, encodeAppleBool(command.enabled))
// Wire semantics are inverted: wire 0 = max noise reduction, wire 100 = min (transparency-like). // Wire semantics are inverted: wire 0 = max noise reduction, wire 100 = min (transparency-like).
// UI value 0..100 follows user intuition (100 = max NC), so flip on write/read. // UI value 0..100 follows user intuition (100 = max NC), so flip on write/read.
is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, 100 - command.level.coerceIn(0, 100)) is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(AapControlId.AUTO_ANC_STRENGTH.value, 100 - command.level.coerceIn(0, 100))
is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall) is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall)
is AapCommand.SetMicrophoneMode -> buildSettingsMessage(SETTING_MICROPHONE_MODE, command.mode.wireValue) is AapCommand.SetMicrophoneMode -> buildSettingsMessage(AapControlId.MIC_MODE.value, command.mode.wireValue)
is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(SETTING_EAR_DETECTION_ENABLED, encodeAppleBool(command.enabled)) is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(AapControlId.IN_EAR_DETECTION.value, encodeAppleBool(command.enabled))
is AapCommand.SetListeningModeCycle -> buildSettingsMessage(SETTING_LISTENING_MODE_CYCLE, command.modeMask and 0x0F) is AapCommand.SetListeningModeCycle -> buildSettingsMessage(AapControlId.LISTENING_MODE_CONFIGS.value, command.modeMask and 0x0F)
is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled)) is AapCommand.SetAllowOffOption -> buildSettingsMessage(AapControlId.ALLOW_OFF_OPTION.value, encodeAppleBool(command.enabled))
is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F) is AapCommand.SetStemConfig -> buildSettingsMessage(AapControlId.RAW_GESTURES_CONFIG.value, command.claimedPressMask and 0x0F)
is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled)) is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled))
is AapCommand.SetDynamicEndOfCharge -> buildSettingsMessage(AapControlId.DYNAMIC_END_OF_CHARGE.value, encodeAppleBool(command.enabled))
is AapCommand.SetDeviceName -> buildRenameMessage(command.name) is AapCommand.SetDeviceName -> buildRenameMessage(command.name)
} }
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? { override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
// Primary pod identity (push-only, fires on mic/primary swap) // Primary pod identity (push-only, fires on mic/primary swap)
if (message.commandType == CMD_PRIMARY_POD) { if (message.commandType == AapMessageType.BUD_ROLE.value) {
if (message.payload.size < 4) return null if (message.payload.size < 4) return null
val podId = message.payload[0].toInt() and 0xFF val podId = message.payload[0].toInt() and 0xFF
// Validate known fixed bytes: [podId] 00 [00|01] [00|01] // Validate known fixed bytes: [podId] 00 [00|01] [00|01]
@@ -136,7 +121,7 @@ class DefaultAapDeviceProfile(
} }
// Ear detection is a separate command type (push-only from device) // Ear detection is a separate command type (push-only from device)
if (message.commandType == CMD_EAR_DETECTION) { if (message.commandType == AapMessageType.EAR_DETECTION.value) {
if (message.payload.size < 2) return null if (message.payload.size < 2) return null
return AapSetting.EarDetection::class to AapSetting.EarDetection( return AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF), primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF),
@@ -145,7 +130,7 @@ class DefaultAapDeviceProfile(
} }
// Connected devices list (push-only from device) // Connected devices list (push-only from device)
if (message.commandType == CMD_CONNECTED_DEVICES) { if (message.commandType == AapMessageType.CONNECTED_DEVICES.value) {
if (message.payload.size < 3) return null if (message.payload.size < 3) return null
val count = message.payload[2].toInt() and 0xFF val count = message.payload[2].toInt() and 0xFF
val devices = mutableListOf<AapSetting.ConnectedDevices.ConnectedDevice>() val devices = mutableListOf<AapSetting.ConnectedDevices.ConnectedDevice>()
@@ -161,7 +146,7 @@ class DefaultAapDeviceProfile(
} }
// Audio source tracking (push-only from device) // Audio source tracking (push-only from device)
if (message.commandType == CMD_AUDIO_SOURCE) { if (message.commandType == AapMessageType.AUDIO_SOURCE.value) {
if (message.payload.size < 7) return null if (message.payload.size < 7) return null
val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":") val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":")
val typeValue = message.payload[6].toInt() and 0xFF val typeValue = message.payload[6].toInt() and 0xFF
@@ -173,8 +158,13 @@ class DefaultAapDeviceProfile(
return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type) return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type)
} }
// EQ data (push-only from device) // 0x53 is "PME Config" per the Wireshark AAP dissector — Personal Medical
if (message.commandType == CMD_EQ_DATA) { // Equipment (cf. PPE), i.e. the iOS 18.1+ hearing-aid profile on AirPods
// Pro 2. Decoded verbatim as 4 × 8 Float32 (per-ear × per-profile band
// gains); stock firmware reports all-zero until the user runs Apple's
// Hearing Test. 0x54 "Set Band Edges" is a neighbouring opcode with a
// different payload — not decoded here.
if (message.commandType == AapMessageType.PME_CONFIG.value) {
if (message.payload.size < 6 + 128) return null if (message.payload.size < 6 + 128) return null
val sets = mutableListOf<List<Float>>() val sets = mutableListOf<List<Float>>()
var offset = 6 // skip header var offset = 6 // skip header
@@ -190,92 +180,105 @@ class DefaultAapDeviceProfile(
} }
sets.add(bands) sets.add(bands)
} }
return AapSetting.EqBands::class to AapSetting.EqBands(sets) return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
} }
// Conversation Awareness State is a separate command type (push-only) // Conversation Awareness State is a separate command type (push-only)
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) { if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
if (message.payload.isEmpty()) return null if (message.payload.isEmpty()) return null
val value = message.payload[0].toInt() and 0xFF val value = message.payload[0].toInt() and 0xFF
val speaking = value == 0x01 val speaking = value == 0x01
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking) return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
} }
if (message.commandType != CMD_SETTINGS) return null if (message.commandType != AapMessageType.CONTROL.value) return null
if (message.payload.size < 2) return null if (message.payload.size < 2) return null
val settingId = message.payload[0].toInt() and 0xFF val settingId = message.payload[0].toInt() and 0xFF
val value = message.payload[1].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF
return when (settingId) { return when (settingId) {
SETTING_ANC_MODE -> { AapControlId.LISTEN_MODE.value -> {
val mode = decodeAncMode(value) ?: return null val mode = decodeAncMode(value) ?: return null
AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes) AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes)
} }
SETTING_CONVERSATIONAL_AWARENESS -> { AapControlId.CONVERSATION_DETECT.value -> {
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled) AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled)
} }
SETTING_PRESS_SPEED -> { AapControlId.DOUBLE_CLICK_INTERVAL.value -> {
val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null
AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed) AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed)
} }
SETTING_PRESS_HOLD_DURATION -> { AapControlId.CLICK_AND_HOLD_INTERVAL.value -> {
val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration) AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration)
} }
SETTING_NC_ONE_AIRPOD -> { AapControlId.ONE_BUD_ANC_MODE.value -> {
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled) AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled)
} }
SETTING_TONE_VOLUME -> { AapControlId.CHIME_VOLUME.value -> {
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value) AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value)
} }
SETTING_VOLUME_SWIPE_LENGTH -> { AapControlId.VOLUME_SWIPE_INTERVAL.value -> {
val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length) AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length)
} }
SETTING_END_CALL_MUTE_MIC -> { AapControlId.CALL_MANAGEMENT_CONFIG.value -> {
decodeEndCallMuteMic(message.payload) decodeEndCallMuteMic(message.payload)
} }
SETTING_VOLUME_SWIPE -> { AapControlId.VOLUME_SWIPE_MODE.value -> {
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled) AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled)
} }
SETTING_PERSONALIZED_VOLUME -> { AapControlId.ADAPTIVE_VOLUME.value -> {
// Wireshark calls 0x26 "Adaptive Volume"; CAPod ships the user-facing name
// "Personalized Volume" because that matches the iOS Settings.app label.
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled) AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled)
} }
SETTING_ADAPTIVE_AUDIO_NOISE -> { AapControlId.AUTO_ANC_STRENGTH.value -> {
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 100 - value.coerceIn(0, 100)) AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 100 - value.coerceIn(0, 100))
} }
SETTING_MICROPHONE_MODE -> { AapControlId.MIC_MODE.value -> {
val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode) AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode)
} }
SETTING_EAR_DETECTION_ENABLED -> { AapControlId.IN_EAR_DETECTION.value -> {
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled) AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled)
} }
SETTING_LISTENING_MODE_CYCLE -> { AapControlId.LISTENING_MODE_CONFIGS.value -> {
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value) AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value)
} }
SETTING_ALLOW_OFF_OPTION -> { AapControlId.ALLOW_OFF_OPTION.value -> {
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled) AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled)
} }
SETTING_STEM_CONFIG -> { AapControlId.RAW_GESTURES_CONFIG.value -> {
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value) AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value)
} }
SETTING_SLEEP_DETECTION -> { AapControlId.SLEEP_DETECTION.value -> {
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled) AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled)
} }
SETTING_IN_CASE_TONE -> { AapControlId.DYNAMIC_END_OF_CHARGE.value -> {
// Apple's "Optimized Charge Limit" — Pro 3 pushes this on connect as value 0x01
// (enabled). decodeAppleBool rejects anything that isn't a confirmed bool so
// unknown encodings fall through to UnknownSetting logging rather than being
// coerced to false.
val enabled = decodeAppleBool(value) ?: return null
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled)
}
AapControlId.IN_CASE_TONE.value -> {
// Decoded internally (never exposed in UI) to keep lastMessageAt fresh.
// Originally labeled "Charging Sounds" — the real case tones are controlled
// over ATT, not here; the actual effect of this setting is unknown.
val enabled = decodeAppleBool(value) ?: return null val enabled = decodeAppleBool(value) ?: return null
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled) AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
} }
in UNCONFIRMED_SETTING_IDS -> { in UNMAPPED_SETTING_IDS -> {
AapSetting.UnknownSetting::class to AapSetting.UnknownSetting( AapSetting.UnknownSetting::class to AapSetting.UnknownSetting(
settingId = settingId, settingId = settingId,
rawValue = value, rawValue = value,
@@ -286,7 +289,7 @@ class DefaultAapDeviceProfile(
} }
override fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>? { override fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>? {
if (message.commandType != CMD_BATTERY) return null if (message.commandType != AapMessageType.BATTERY_INFO.value) return null
val payload = message.payload val payload = message.payload
if (payload.isEmpty()) return null if (payload.isEmpty()) return null
@@ -328,7 +331,7 @@ class DefaultAapDeviceProfile(
) )
override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? { override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? {
if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null if (message.commandType != AapMessageType.MAGIC_KEYS.value) return null
val payload = message.payload val payload = message.payload
if (payload.isEmpty()) return null if (payload.isEmpty()) return null
@@ -360,26 +363,31 @@ class DefaultAapDeviceProfile(
} }
override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? { override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? {
if (message.commandType != CMD_DEVICE_INFO) return null if (message.commandType != AapMessageType.INFORMATION.value) return null
if (message.payload.size < 10) return null if (message.payload.size < 10) return null
// Device info payload contains null-terminated UTF-8 strings val parsed = parseDeviceInfoPayload(message.payload) ?: return null
// Format: [binary header] [NUL-delimited strings...] if (parsed.strings.size < 4) return null
val strings = parseNullTerminatedStrings(message.payload)
if (strings.size < 4) return null val activeFirmware = parsed.strings.getOrElse(4) { "" }
val pendingFirmware = parsed.strings.getOrNull(5)?.takeIf { it.isNotBlank() && it != activeFirmware }
return AapDeviceInfo( return AapDeviceInfo(
name = strings.getOrElse(0) { "" }, name = parsed.strings.getOrElse(0) { "" },
modelNumber = strings.getOrElse(1) { "" }, modelNumber = parsed.strings.getOrElse(1) { "" },
manufacturer = strings.getOrElse(2) { "" }, manufacturer = parsed.strings.getOrElse(2) { "" },
serialNumber = strings.getOrElse(3) { "" }, serialNumber = parsed.strings.getOrElse(3) { "" },
firmwareVersion = strings.getOrElse(4) { "" }, firmwareVersion = activeFirmware,
// Segments [5]=firmware dup, [6]=protocol version, [7]=updater app ID — skipped firmwareVersionPending = pendingFirmware,
// Segments [8] and [9] are individual earbud serials (observed on Pro 1, Pro 2, Pro 3) hardwareVersion = parsed.strings.getOrNull(6)?.takeIf { it.isNotBlank() },
leftEarbudSerial = strings.getOrNull(8)?.takeIf { it.isNotBlank() }, eaProtocolName = parsed.strings.getOrNull(7)?.takeIf { it.isNotBlank() },
rightEarbudSerial = strings.getOrNull(9)?.takeIf { it.isNotBlank() }, leftEarbudSerial = parsed.strings.getOrNull(8)?.takeIf { it.isNotBlank() },
// Segment [10] is the build number (e.g. "8454624") rightEarbudSerial = parsed.strings.getOrNull(9)?.takeIf { it.isNotBlank() },
buildNumber = strings.getOrNull(10)?.takeIf { it.isNotBlank() }, marketingVersion = parsed.strings.getOrNull(10)?.takeIf { it.isNotBlank() },
leftEarbudUuid = parsed.leftEarbudUuid,
rightEarbudUuid = parsed.rightEarbudUuid,
leftEarbudFirstPaired = parsed.leftEarbudFirstPaired,
rightEarbudFirstPaired = parsed.rightEarbudFirstPaired,
) )
} }
@@ -445,7 +453,7 @@ class DefaultAapDeviceProfile(
return byteArrayOf( return byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x04, 0x00, 0x04, 0x00,
0x09, 0x00, 0x09, 0x00,
SETTING_END_CALL_MUTE_MIC.toByte(), 0x20, AapControlId.CALL_MANAGEMENT_CONFIG.value.toByte(), 0x20,
combined.toByte(), combined.toByte(),
0x00, 0x00, 0x00, 0x00,
) )
@@ -480,13 +488,49 @@ class DefaultAapDeviceProfile(
} }
override fun decodeStemPress(message: AapMessage): StemPressEvent? { override fun decodeStemPress(message: AapMessage): StemPressEvent? {
if (message.commandType != CMD_STEM_PRESS) return null if (message.commandType != AapMessageType.STEM_PRESS.value) return null
if (message.payload.size < 2) return null if (message.payload.size < 2) return null
val pressType = StemPressEvent.PressType.fromWire(message.payload[0].toInt() and 0xFF) ?: return null val pressType = StemPressEvent.PressType.fromWire(message.payload[0].toInt() and 0xFF) ?: return null
val bud = StemPressEvent.Bud.fromWire(message.payload[1].toInt() and 0xFF) ?: return null val bud = StemPressEvent.Bud.fromWire(message.payload[1].toInt() and 0xFF) ?: return null
return StemPressEvent(pressType, bud) return StemPressEvent(pressType, bud)
} }
/**
* Case Info probing is allowlisted to models observed to respond. Pro 3 is the
* confirmed first entry; other models can be added once captures verify they
* reply to 0x22 with a 0x23 payload.
*/
override fun encodeCaseInfoRequest(): ByteArray? {
if (model !in CASE_INFO_ALLOWLIST) return null
// Fire-and-forget 6-byte template, matching the private key request shape
// (header + command type with no payload).
return byteArrayOf(
0x04, 0x00, 0x04, 0x00,
AapMessageType.CASE_INFO_REQUEST.value.toByte(), 0x00,
)
}
/**
* Best-effort decoder. Per the dissector, the payload contains a mix of
* binary VID/PID/color bytes plus a NUL-delimited name string. Without
* more captures we preserve the raw payload and leave individual fields
* null — future iteration can map specific offsets.
*/
override fun decodeCaseInfo(message: AapMessage): AapCaseInfo? {
if (message.commandType != AapMessageType.CASE_INFO.value) return null
return AapCaseInfo(rawPayload = message.payload.copyOf())
}
override fun decodeSleepEvent(message: AapMessage): AapSleepEvent? {
if (message.commandType != AapMessageType.SLEEP_DETECTION_UPDATE.value) return null
return AapSleepEvent(rawPayload = message.payload.copyOf())
}
override fun decodeDynamicEndOfChargeEvent(message: AapMessage): AapDynamicEndOfChargeEvent? {
if (message.commandType != AapMessageType.DYNAMIC_END_OF_CHARGE.value) return null
return AapDynamicEndOfChargeEvent(rawPayload = message.payload.copyOf())
}
private fun buildRenameMessage(name: String): ByteArray { private fun buildRenameMessage(name: String): ByteArray {
// Uses the opcode 0x1A format from the LibrePods AAP docs + Linux implementation. // Uses the opcode 0x1A format from the LibrePods AAP docs + Linux implementation.
// Verified end-to-end on AirPods Pro 2 USB-C (firmware 81.2675...): the device accepts // Verified end-to-end on AirPods Pro 2 USB-C (firmware 81.2675...): the device accepts
@@ -510,31 +554,89 @@ class DefaultAapDeviceProfile(
) + nameBytes ) + nameBytes
} }
private fun parseNullTerminatedStrings(data: ByteArray): List<String> { /**
// Skip binary header until the first printable byte (device name always starts * Heuristic binary-prefix skip: walk forward until we hit a printable byte.
// with a printable character). This skips the length/type prefix bytes. * The real header schema is `[02 XX 00 04 00]` in every capture to date, but
var headerEnd = 0 * documenting that without an authoritative source would be guessing. The
while (headerEnd < data.size) { * heuristic breaks only for device names that start with a non-printable
val b = data[headerEnd].toInt() and 0xFF * byte \u2014 theoretically emoji names (UTF-8 first byte 0xF0-0xF4, outside the
if (b in 0x20..0x7E) break * printable range) could misalign here. None observed in real captures so far.
headerEnd++ */
private fun skipBinaryHeader(data: ByteArray): Int {
var offset = 0
while (offset < data.size) {
val b = data[offset].toInt() and 0xFF
if (b in 0x20..0x7E) return offset
offset++
}
return data.size
} }
if (headerEnd >= data.size) return emptyList()
// Split on NUL bytes and decode each segment as UTF-8. private fun parseDeviceInfoPayload(data: ByteArray): DeviceInfoSegments? {
// This handles device names with non-ASCII characters (e.g. curly quotes var offset = skipBinaryHeader(data)
// in "Matthias\u2019s AirPods Pro") that the old ASCII-only scanner would break on. if (offset >= data.size) return null
// Segments 0..10: NUL-delimited UTF-8 strings. UTF-8 means non-ASCII (e.g.
// curly quotes in "Matthias's AirPods Pro") decodes correctly.
val strings = mutableListOf<String>() val strings = mutableListOf<String>()
var i = headerEnd while (strings.size < 11 && offset < data.size) {
while (i < data.size) { while (offset < data.size && data[offset] == 0x00.toByte()) offset++
// Skip NUL separators if (offset >= data.size) break
while (i < data.size && data[i] == 0x00.toByte()) i++ val segStart = offset
if (i >= data.size) break while (offset < data.size && data[offset] != 0x00.toByte()) offset++
strings.add(String(data, segStart, offset - segStart, Charsets.UTF_8))
}
val segStart = i // Skip the NUL that terminated segment 10 (if present) before the UUID blob.
while (i < data.size && data[i] != 0x00.toByte()) i++ while (offset < data.size && data[offset] == 0x00.toByte()) offset++
strings.add(String(data, segStart, i - segStart, Charsets.UTF_8))
// Segments 11 and 12: fixed 17-byte UUIDs (NOT NUL-terminated, may contain 0x00).
// Best-effort: if payload is shorter, both UUIDs are null.
val leftUuid: ByteArray? = if (offset + UUID_LEN <= data.size) {
val slice = data.copyOfRange(offset, offset + UUID_LEN)
offset += UUID_LEN
slice
} else null
val rightUuid: ByteArray? = if (offset + UUID_LEN <= data.size) {
val slice = data.copyOfRange(offset, offset + UUID_LEN)
offset += UUID_LEN
slice
} else null
// Segments 13 and 14: NUL-delimited ASCII-decimal epoch seconds (e.g. "1697480211").
val trailingStrings = mutableListOf<String>()
while (trailingStrings.size < 2 && offset < data.size) {
while (offset < data.size && data[offset] == 0x00.toByte()) offset++
if (offset >= data.size) break
val segStart = offset
while (offset < data.size && data[offset] != 0x00.toByte()) offset++
trailingStrings.add(String(data, segStart, offset - segStart, Charsets.UTF_8))
} }
return strings
val leftPaired = trailingStrings.getOrNull(0).parseEpochSecondsOrNull()
val rightPaired = trailingStrings.getOrNull(1).parseEpochSecondsOrNull()
return DeviceInfoSegments(
strings = strings,
leftEarbudUuid = leftUuid,
rightEarbudUuid = rightUuid,
leftEarbudFirstPaired = leftPaired,
rightEarbudFirstPaired = rightPaired,
)
} }
private fun String?.parseEpochSecondsOrNull(): java.time.Instant? {
if (this.isNullOrBlank()) return null
val seconds = this.toLongOrNull() ?: return null
return runCatching { java.time.Instant.ofEpochSecond(seconds) }.getOrNull()
}
private data class DeviceInfoSegments(
val strings: List<String>,
val leftEarbudUuid: ByteArray?,
val rightEarbudUuid: ByteArray?,
val leftEarbudFirstPaired: java.time.Instant?,
val rightEarbudFirstPaired: java.time.Instant?,
)
} }
@@ -2,6 +2,7 @@ package eu.darken.capod.pods.core.apple.ble
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.logSummary import eu.darken.capod.common.bluetooth.logSummary
import eu.darken.capod.common.bluetooth.redactedForLogs
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -119,7 +120,7 @@ class AppleFactory @Inject constructor(
} }
} }
factory.create( val device = factory.create(
scanResult = scanResult, scanResult = scanResult,
payload = payload, payload = payload,
meta = ApplePods.AppleMeta( meta = ApplePods.AppleMeta(
@@ -127,6 +128,18 @@ class AppleFactory @Inject constructor(
profile = profile, profile = profile,
), ),
) )
log(TAG, DEBUG) {
val rawHex = scanResult.manufacturerSpecificData.entries.joinToString("; ") { (id, bytes) ->
"$id:${bytes.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}"
}
val publicHex = payload.public.data.joinToString(" ") { "%02X".format(it.toInt()) }
val privateHex = payload.private?.data?.joinToString(" ") { "%02X".format(it.toInt()) } ?: "-"
"Apple decoded: model=${device.model}, addr=${scanResult.address.redactedForLogs()}, " +
"irkMatch=$isIrkMatch, raw=[$rawHex], public=[$publicHex], private=[$privateHex]"
}
device
} }
companion object { companion object {
@@ -3,7 +3,6 @@ package eu.darken.capod.pods.core.apple.ble
import dagger.Reusable import dagger.Reusable
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.logSummary import eu.darken.capod.common.bluetooth.logSummary
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
@@ -26,7 +25,7 @@ class PodFactory @Inject constructor(
device = unknownFactory.create(scanResult) device = unknownFactory.create(scanResult)
} }
log(TAG, DEBUG) { "Pod created: ${device.logSummary()}" } log(TAG, VERBOSE) { "Pod created: ${device.logSummary()}" }
return Result(scanResult = scanResult, device = device) return Result(scanResult = scanResult, device = device)
} }
@@ -37,14 +37,14 @@ data class ProximityMessage(
log( log(
TAG, TAG,
Logging.Priority.ERROR Logging.Priority.ERROR
) { "Failed to decrypt $message with ${key.toByteString()}\n${e.asLog()}" } ) { "Failed to decrypt $message\n${e.asLog()}" }
null null
} }
log( log(
TAG, TAG,
Logging.Priority.VERBOSE Logging.Priority.VERBOSE
) { "Decrypted $message with ${key.toByteString()} to ${decryptedData?.toByteString()}" } ) { "Decrypted $message to ${decryptedData?.toByteString()}" }
if (decryptedData == null || decryptedData.size != 16) return null if (decryptedData == null || decryptedData.size != 16) return null
@@ -55,4 +55,19 @@ data class AppleDeviceProfile(
showPopUpOnCaseOpen = showPopUpOnCaseOpen, showPopUpOnCaseOpen = showPopUpOnCaseOpen,
showPopUpOnConnection = showPopUpOnConnection, showPopUpOnConnection = showPopUpOnConnection,
) )
override fun toString(): String = "AppleDeviceProfile(" +
"id=$id, label=$label, priority=$priority, model=$model, " +
"minimumSignalQuality=$minimumSignalQuality, " +
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
"autoConnectCondition=$autoConnectCondition, " +
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
"showPopUpOnConnection=$showPopUpOnConnection, " +
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
"stemActions=$stemActions" +
")"
} }
@@ -65,6 +65,30 @@ class DeviceProfilesRepo @Inject constructor(
if (!settings.reactionMigrationDone.valueBlocking) { if (!settings.reactionMigrationDone.valueBlocking) {
migrateLegacyReactions() 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 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 { suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock {
val currentContainer = settings.profiles.valueBlocking val currentContainer = settings.profiles.valueBlocking
checkAddressUniqueness(profile, currentContainer.profiles) checkAddressUniqueness(profile, currentContainer.profiles)
@@ -34,5 +34,6 @@ class DeviceProfilesSettings @Inject constructor(
val singleToMultiMigrationDone = dataStore.createValue("profiles.migration.v2.done", false) val singleToMultiMigrationDone = dataStore.createValue("profiles.migration.v2.done", false)
val defaultProfileCreated = dataStore.createValue("profiles.default.v2.created", false) val defaultProfileCreated = dataStore.createValue("profiles.default.v2.created", false)
val reactionMigrationDone = dataStore.createValue("profiles.reactions.migration.done", 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.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -261,7 +262,7 @@ private fun ProfileRow(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
IconButton(onClick = onDeviceSettings) { OutlinedIconButton(onClick = onDeviceSettings) {
Icon( Icon(
imageVector = Icons.TwoTone.Tune, imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd), 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.MediaControl
import eu.darken.capod.common.bluetooth.BluetoothManager2 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.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log 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.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice 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.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -30,6 +34,9 @@ class PlayPause @Inject constructor(
fun monitor() = run { fun monitor() = run {
var pendingPlayConfirmation: PendingPlayConfirmation? = null var pendingPlayConfirmation: PendingPlayConfirmation? = null
var pendingPauseDebounce: PendingPauseDebounce? = null
var hasLastMonitorKey = false
var lastMonitorKey: PlayPauseMonitorKey? = null
deviceMonitor.primaryDevice() deviceMonitor.primaryDevice()
.map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true } .map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true }
@@ -39,12 +46,18 @@ class PlayPause @Inject constructor(
bluetoothManager.connectedDevices bluetoothManager.connectedDevices
} else { } else {
pendingPlayConfirmation = null pendingPlayConfirmation = null
pendingPauseDebounce = null
hasLastMonitorKey = false
lastMonitorKey = null
emptyFlow() emptyFlow()
} }
} }
.flatMapLatest { connected -> .flatMapLatest { connected ->
if (connected.isEmpty()) { if (connected.isEmpty()) {
pendingPlayConfirmation = null pendingPlayConfirmation = null
pendingPauseDebounce = null
hasLastMonitorKey = false
lastMonitorKey = null
log(TAG) { "No known devices connected." } log(TAG) { "No known devices connected." }
emptyFlow() emptyFlow()
} else { } else {
@@ -53,9 +66,20 @@ class PlayPause @Inject constructor(
} }
} }
// Cache persistence can update battery timestamps without changing any reaction-relevant state. // 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 -> .onEach { device ->
log(TAG, VERBOSE) { "Post-distinct: profileId=${device?.profileId}" } log(TAG, VERBOSE) { "Post-monitor-filter: profileId=${device?.profileId}" }
} }
.withPrevious() .withPrevious()
.filter { (previous, current) -> .filter { (previous, current) ->
@@ -63,7 +87,13 @@ class PlayPause @Inject constructor(
// Use profileId (stable across BLE address rotations) rather than BLE identifier. // Use profileId (stable across BLE address rotations) rather than BLE identifier.
// Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause). // Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause).
val match = previous.profileId != null && previous.profileId == current.profileId 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 match
} }
.onEach { (previous, current) -> .onEach { (previous, current) ->
@@ -76,6 +106,26 @@ class PlayPause @Inject constructor(
if (reactions == null) { if (reactions == null) {
log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" } log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" }
pendingPlayConfirmation = null 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 return@onEach
} }
@@ -109,6 +159,10 @@ class PlayPause @Inject constructor(
else -> { else -> {
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" } log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
pendingPlayConfirmation = null pendingPlayConfirmation = null
if (pendingPauseDebounce != null) {
log(TAG, DEBUG) { "Pause debounce reset: device lost ear detection" }
pendingPauseDebounce = null
}
return@onEach return@onEach
} }
} }
@@ -116,6 +170,8 @@ class PlayPause @Inject constructor(
val isCurrentlyPlaying = mediaControl.isPlaying val isCurrentlyPlaying = mediaControl.isPlaying
val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap
val source = current.earDetectionSource()
// Evaluate what action to take // Evaluate what action to take
val rawDecision = evaluatePlayPauseAction( val rawDecision = evaluatePlayPauseAction(
previous = prevState, previous = prevState,
@@ -125,11 +181,18 @@ class PlayPause @Inject constructor(
wasRecentlyPausedByUs = wasRecentlyPausedByUs, 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 && val shouldStageBleOnlyPlay = rawDecision.shouldPlay &&
reactions.autoPlay && reactions.autoPlay &&
!reactions.onePodMode && !reactions.onePodMode &&
current.hasDualPods && current.hasDualPods &&
current.aap?.aapEarDetection == null (source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS)
val confirmation = applyBleOnlyPlayConfirmation( val confirmation = applyBleOnlyPlayConfirmation(
pending = pendingPlayConfirmation, pending = pendingPlayConfirmation,
@@ -151,7 +214,36 @@ class PlayPause @Inject constructor(
log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" } 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) { if (decision.usedRecentCapPauseOverride) {
log(TAG, VERBOSE) { log(TAG, VERBOSE) {
"Resume override: recent CAP pause window is active, allowing play despite playing=true" "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 -> { decision.shouldPause && reactions.autoPause -> {
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" } val pauseSent = mediaControl.sendPause()
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 -> { 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( data class EarDetectionState(
val leftInEar: Boolean?, // null for single pod devices val leftInEar: Boolean?, // null for single pod devices
val rightInEar: 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, 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( internal data class PlayPauseMonitorKey(
val profileId: String?, val profileId: String?,
val autoPlay: Boolean, val autoPlay: Boolean,
@@ -402,10 +679,44 @@ class PlayPause @Inject constructor(
val isEitherPodInEar: Boolean?, val isEitherPodInEar: Boolean?,
val hasAapEarDetection: Boolean, val hasAapEarDetection: Boolean,
val hasBleSnapshot: 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 = internal fun PodDevice.earDetectionSource(): EarDetectionSource {
PlayPauseMonitorKey( 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, profileId = profileId,
autoPlay = reactions.autoPlay, autoPlay = reactions.autoPlay,
autoPause = reactions.autoPause, autoPause = reactions.autoPause,
@@ -418,20 +729,45 @@ class PlayPause @Inject constructor(
isEitherPodInEar = isEitherPodInEar, isEitherPodInEar = isEitherPodInEar,
hasAapEarDetection = aap?.aapEarDetection != null, hasAapEarDetection = aap?.aapEarDetection != null,
hasBleSnapshot = ble != 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. * 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 left = isLeftInEar
val right = isRightInEar val right = isRightInEar
if (left != null && right != null) { if (left != null && right != null) {
return EarDetectionState.fromDualPod(left = left, right = right) return EarDetectionState.fromDualPod(left = left, right = right)
} }
// Per-side unavailable (resolvedPrimaryPod is null) — use aggregate AAP state.
return EarDetectionState.fromAapAggregate( return EarDetectionState.fromAapAggregate(
isBeingWorn = isBeingWorn ?: false, isBeingWorn = isBeingWorn ?: false,
isEitherPodInEar = isEitherPodInEar ?: false, isEitherPodInEar = isEitherPodInEar ?: false,
@@ -440,5 +776,11 @@ class PlayPause @Inject constructor(
companion object { companion object {
private val TAG = logTag("Reaction", "PlayPause") 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
} }
} }
@@ -0,0 +1,68 @@
package eu.darken.capod.reaction.core.sleep
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SleepReaction @Inject constructor(
private val aapManager: AapConnectionManager,
private val deviceMonitor: DeviceMonitor,
private val mediaControl: MediaControl,
private val notifications: SleepReactionNotifications,
private val timeSource: TimeSource,
) {
private val cooldowns = ConcurrentHashMap<BluetoothAddress, Long>()
fun monitor(): Flow<Unit> = aapManager.sleepEvents
.onEach { address -> handle(address) }
.map { }
.setupCommonEventHandlers(TAG) { "sleepReaction" }
private suspend fun handle(address: BluetoothAddress) {
val now = timeSource.elapsedRealtime()
val last = cooldowns[address]
if (last != null && now - last < COOLDOWN_MS) {
log(TAG) { "Sleep event from $address suppressed by cooldown" }
return
}
val primary = deviceMonitor.primaryDevice().first()
if (primary?.address != address) {
log(TAG) { "Sleep event from $address ignored — not primary device (primary=${primary?.address})" }
return
}
// Use sendPause's return value as the atomic check+act: true means we really paused
// something, false means nothing was playing. Gating the cooldown and notification on
// this closes the race where audio could stop between an upfront isPlaying check and
// the key dispatch, and avoids burning the 5-minute window on no-ops.
val paused = mediaControl.sendPause()
if (!paused) {
log(TAG) { "Sleep event from $address ignored — nothing was playing" }
return
}
cooldowns[address] = now
val label = primary.label ?: primary.model.label
log(TAG, INFO) { "Sleep detected on $address ($label) — paused media, notifying" }
notifications.show(label)
}
companion object {
private val TAG = logTag("Reaction", "Sleep")
private const val COOLDOWN_MS = 5L * 60L * 1000L
}
}
@@ -0,0 +1,66 @@
package eu.darken.capod.reaction.core.sleep
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
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 eu.darken.capod.main.ui.MainActivity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SleepReactionNotifications @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManager,
) {
init {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reaction_sleep_channel_label),
NotificationManager.IMPORTANCE_LOW,
)
)
}
fun show(deviceLabel: String) {
if (!notificationManager.areNotificationsEnabled()) {
log(TAG, WARN) { "Notifications disabled — sleep pause will be silent" }
return
}
val openPi = PendingIntent.getActivity(
context,
PENDING_INTENT_REQUEST_CODE,
Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE,
)
val text = context.getString(R.string.reaction_sleep_notification_text, deviceLabel)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.device_earbuds_generic_both)
.setContentTitle(context.getString(R.string.reaction_sleep_notification_title))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(openPi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
companion object {
private val TAG = logTag("Reaction", "Sleep", "Notifications")
private val CHANNEL_ID = "${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.sleep"
private const val NOTIFICATION_ID = 3
private const val PENDING_INTENT_REQUEST_CODE = 0
}
}
@@ -16,6 +16,7 @@ import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.ble.BlePodMonitor 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.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
@@ -40,6 +41,7 @@ class TroubleShooterViewModel @Inject constructor(
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
private val profilesRepo: DeviceProfilesRepo, private val profilesRepo: DeviceProfilesRepo,
private val blePodMonitor: BlePodMonitor, private val blePodMonitor: BlePodMonitor,
private val bleScanModeController: BleScanModeController,
private val deviceMonitor: DeviceMonitor, private val deviceMonitor: DeviceMonitor,
private val debugSettings: DebugSettings, private val debugSettings: DebugSettings,
private val timeSource: TimeSource, private val timeSource: TimeSource,
@@ -84,8 +86,7 @@ class TroubleShooterViewModel @Inject constructor(
fun troubleShootBle() = launch(context = dispatcherProvider.IO) { fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
log(TAG, INFO) { "troubleShootBle()" } log(TAG, INFO) { "troubleShootBle()" }
generalSettings.scannerMode.valueBlocking = ScannerMode.LOW_LATENCY bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
run { run {
progress("Checking for headphones...") progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) { val mainDevice = withTimeoutOrNull(STEP_TIME) {
@@ -93,7 +94,7 @@ class TroubleShooterViewModel @Inject constructor(
} }
if (mainDevice != null) { if (mainDevice != null) {
success("Headphones found, nothing to troubleshoot.") success("Headphones found, nothing to troubleshoot.")
return@launch return@override
} else { } else {
progress("Headphones not detected.\n") progress("Headphones not detected.\n")
} }
@@ -152,7 +153,7 @@ class TroubleShooterViewModel @Inject constructor(
generalSettings.useIndirectScanResultCallback.valueBlocking = false generalSettings.useIndirectScanResultCallback.valueBlocking = false
debugSettings.showUnfiltered.valueBlocking = false debugSettings.showUnfiltered.valueBlocking = false
return@launch return@override
} }
progress("We received at least some BLE data.\n") progress("We received at least some BLE data.\n")
@@ -175,7 +176,7 @@ class TroubleShooterViewModel @Inject constructor(
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@launch return@override
} }
progress("Found some headphones that are supported by CAPod.\n") progress("Found some headphones that are supported by CAPod.\n")
@@ -187,7 +188,7 @@ class TroubleShooterViewModel @Inject constructor(
} }
if (mainDevice != null) { if (mainDevice != null) {
success("Found your headphones, new BLE settings worked :)!") success("Found your headphones, new BLE settings worked :)!")
return@launch return@override
} }
} }
@@ -210,7 +211,7 @@ class TroubleShooterViewModel @Inject constructor(
if (otherDevices.isEmpty()) { if (otherDevices.isEmpty()) {
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES) failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
return@launch return@override
} }
progress("Headphones found nearby, but not detected as yours.\n") progress("Headphones found nearby, but not detected as yours.\n")
@@ -234,8 +235,6 @@ class TroubleShooterViewModel @Inject constructor(
deviceMonitor.primaryDevice().filterNotNull().firstOrNull() deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
} }
generalSettings.scannerMode.valueBlocking = ScannerMode.BALANCED
if (mainDevice != null) { if (mainDevice != null) {
success("Success! Detected your headphones.") success("Success! Detected your headphones.")
} else { } else {
@@ -243,6 +242,7 @@ class TroubleShooterViewModel @Inject constructor(
} }
} }
} }
}
sealed class BleState { sealed class BleState {
class Intro : BleState() class Intro : BleState()
+20 -7
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_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_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_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_label">Outo-pouse</string>
<string name="settings_autopause_description">Pouseer oudio wanneer die toestel uit jou oor verwyder word.</string> <string name="settings_autopause_description">Pouseer oudio wanneer die toestel uit jou oor verwyder word.</string>
<string name="settings_autopplay_label">Outo-speel</string> <string name="settings_autopplay_label">Outo-speel</string>
@@ -152,6 +150,7 @@
<string name="widget_config_transparency_label">Agtergrond-deursigtigheid</string> <string name="widget_config_transparency_label">Agtergrond-deursigtigheid</string>
<string name="widget_config_show_device_label">Wys toestelnaam</string> <string name="widget_config_show_device_label">Wys toestelnaam</string>
<string name="widget_config_reset_label">Stel terug na standaarde</string> <string name="widget_config_reset_label">Stel terug na standaarde</string>
<string name="widget_config_preview_resize_hint">U kan die widget vergroot of verklein nadat u dit op u tuisskerm geplaas het.</string>
<string name="widget_config_custom_label">Aangepas</string> <string name="widget_config_custom_label">Aangepas</string>
<string name="widget_config_preset_material_you">Material You</string> <string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Donker</string> <string name="widget_config_preset_dark">Donker</string>
@@ -221,9 +220,6 @@
<string name="permission_required_title">Die volgende toestemming is vereis:</string> <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_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="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_manual_label">Wanneer toepassing oop is</string>
<string name="settings_monitor_mode_automatic_label">Wanneer toestel gekoppel is</string> <string name="settings_monitor_mode_automatic_label">Wanneer toestel gekoppel is</string>
<string name="settings_monitor_mode_always_label">Altyd</string> <string name="settings_monitor_mode_always_label">Altyd</string>
@@ -250,6 +246,7 @@
<string name="pods_unknown_contact_dev">Dit is \'n onbekende toestel, maar dit gebruik \'n soortgelyke boodskapformaat. Kom ons voeg ondersteuning daarvoor by, kontak my :)</string> <string name="pods_unknown_contact_dev">Dit is \'n onbekende toestel, maar dit gebruik \'n soortgelyke boodskapformaat. Kom ons voeg ondersteuning daarvoor by, kontak my :)</string>
<string name="pods_none_label_short">Geen toestel</string> <string name="pods_none_label_short">Geen toestel</string>
<string name="pods_charging_label">Laai</string> <string name="pods_charging_label">Laai</string>
<string name="pods_charging_optimized_label">Geoptimaliseer</string>
<string name="pods_inear_label">In oor</string> <string name="pods_inear_label">In oor</string>
<string name="pods_microphone_label">Mikrofoon</string> <string name="pods_microphone_label">Mikrofoon</string>
<string name="anc_mode_off">Af</string> <string name="anc_mode_off">Af</string>
@@ -407,11 +404,15 @@
<string name="device_settings_info_bt_name_label">Bluetooth-toestelaanduiding</string> <string name="device_settings_info_bt_name_label">Bluetooth-toestelaanduiding</string>
<string name="device_settings_info_model_label">Model</string> <string name="device_settings_info_model_label">Model</string>
<string name="device_settings_info_manufacturer_label">Vervaardiger</string> <string name="device_settings_info_manufacturer_label">Vervaardiger</string>
<string name="device_settings_info_hardware_label">Hardeware</string>
<string name="device_settings_info_serial_label">Serienommer</string> <string name="device_settings_info_serial_label">Serienommer</string>
<string name="device_settings_info_firmware_label">Firmware</string> <string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_info_firmware_pending_label">Hangende Firmware</string>
<string name="device_settings_info_build_label">Bou</string> <string name="device_settings_info_build_label">Bou</string>
<string name="device_settings_info_left_serial_label">Linker Pod Serienommer</string> <string name="device_settings_info_left_serial_label">Linker Pod Serienommer</string>
<string name="device_settings_info_right_serial_label">Regter Pod Serienommer</string> <string name="device_settings_info_right_serial_label">Regter Pod Serienommer</string>
<string name="device_settings_info_left_bonded_label">Links Gekoppel</string>
<string name="device_settings_info_right_bonded_label">Regs Gekoppel</string>
<string name="device_settings_info_details_label">Toestelbesonderhede</string> <string name="device_settings_info_details_label">Toestelbesonderhede</string>
<string name="device_settings_info_details_action">Wys toestelbesonderhede</string> <string name="device_settings_info_details_action">Wys toestelbesonderhede</string>
<string name="device_settings_info_status_label">Status</string> <string name="device_settings_info_status_label">Status</string>
@@ -421,8 +422,11 @@
<string name="device_settings_not_connected_description">Hierdie toestel is naby maar nie aan hierdie foon gekoppel nie. Koppel om toegang tot instellings en beheer te verkry.</string> <string name="device_settings_not_connected_description">Hierdie toestel is naby maar nie aan hierdie foon gekoppel nie. Koppel om toegang tot instellings en beheer te verkry.</string>
<string name="device_settings_not_connected_connect_action">Koppel</string> <string name="device_settings_not_connected_connect_action">Koppel</string>
<string name="device_settings_not_connected_open_settings_action">Maak Bluetooth-instellings oop</string> <string name="device_settings_not_connected_open_settings_action">Maak Bluetooth-instellings oop</string>
<string name="device_settings_not_nearby_label">Toestel nie naby nie</string>
<string name="device_settings_not_nearby_description">Instellings is slegs beskikbaar wanneer hierdie toestel naby is en aan jou foon gekoppel is.</string>
<string name="device_settings_aap_unavailable_label">Gevorderde instellings nie beskikbaar nie</string> <string name="device_settings_aap_unavailable_label">Gevorderde instellings nie beskikbaar nie</string>
<string name="device_settings_aap_unavailable_description">Gevorderde AirPods-instellings vereis \'n Bluetooth-kenmerk wat nie op hierdie foon beskikbaar is nie. Dit kan deur \'n toekomstige Android-opdatering van jou toestelvervaardiger opgelos word.</string> <string name="device_settings_aap_unavailable_description">Gevorderde AirPods-instellings vereis \'n Bluetooth-kenmerk wat nie op hierdie foon beskikbaar is nie. Dit kan deur \'n toekomstige Android-opdatering van jou toestelvervaardiger opgelos word.</string>
<string name="device_settings_aap_unavailable_action">Bekyk verenigbaarheidsnaspeurder</string>
<string name="device_settings_category_sound_label">Klank</string> <string name="device_settings_category_sound_label">Klank</string>
<string name="device_settings_category_controls_label">Beheer</string> <string name="device_settings_category_controls_label">Beheer</string>
<string name="device_settings_nc_one_airpod_label">ANC met een pod</string> <string name="device_settings_nc_one_airpod_label">ANC met een pod</string>
@@ -460,7 +464,9 @@
<string name="device_settings_end_call_mute_mic_option_b_title">Enkel druk om mikrofoon te demp</string> <string name="device_settings_end_call_mute_mic_option_b_title">Enkel druk om mikrofoon te demp</string>
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Dubbel druk om oproep te beëindig</string> <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="device_settings_open_cd">Maak toestel-instellings oop</string>
<string name="overview_card_missing_paired_device_cd">Profiel het geen gekoppelde Bluetooth-toestel nie — tik om profiel te wysig</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 --> <!-- New device settings -->
<string name="device_settings_category_general_label">Algemeen</string> <string name="device_settings_category_general_label">Algemeen</string>
<string name="device_settings_microphone_mode_label">Mikrofoon</string> <string name="device_settings_microphone_mode_label">Mikrofoon</string>
@@ -483,6 +489,9 @@
<string name="device_settings_allow_off_description">Laat Af toe as \'n kiesbare geraasbeheermodus. Wanneer gedeaktiveer, slaan stingels Af oor tydens siklus.</string> <string name="device_settings_allow_off_description">Laat Af toe as \'n kiesbare geraasbeheermodus. Wanneer gedeaktiveer, slaan stingels Af oor tydens siklus.</string>
<string name="device_settings_sleep_detection_label">Slaapopsporing</string> <string name="device_settings_sleep_detection_label">Slaapopsporing</string>
<string name="device_settings_sleep_detection_description">Pauses oudio outomaties wanneer jy aan die slaap raak</string> <string name="device_settings_sleep_detection_description">Pauses oudio outomaties wanneer jy aan die slaap raak</string>
<string name="reaction_sleep_channel_label">Slaapopsporing</string>
<string name="reaction_sleep_notification_title">Gepauseer deur Slaapopsporing</string>
<string name="reaction_sleep_notification_text">%1$s het aangemeld dat jy aan die slaap geraak het, so jou musiek is gepauseer. Jy kan dit in die toestelinstellings deaktiveer.</string>
<string name="device_settings_rename_label">Hernoem</string> <string name="device_settings_rename_label">Hernoem</string>
<string name="device_settings_rename_hint">Toestelnaam</string> <string name="device_settings_rename_hint">Toestelnaam</string>
<string name="device_settings_rename_confirm">Hernoem</string> <string name="device_settings_rename_confirm">Hernoem</string>
@@ -492,10 +501,14 @@
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth-instellings</string> <string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth-instellings</string>
<string name="device_settings_send_failed">Kon nie instelling toepas nie: %1$s</string> <string name="device_settings_send_failed">Kon nie instelling toepas nie: %1$s</string>
<string name="device_settings_anc_off_rejected_message">Af-modus is nie op hierdie toestel geaktiveer nie. Aktiveer \"Laat Af-modus toe\" onder Geraasbeheersing.</string> <string name="device_settings_anc_off_rejected_message">Af-modus is nie op hierdie toestel geaktiveer nie. Aktiveer \"Laat Af-modus toe\" onder Geraasbeheersing.</string>
<string name="device_settings_category_battery_label">Batterij</string>
<string name="device_settings_charge_cap_label">Geoptimaliseerde Laailimiet</string>
<string name="device_settings_charge_cap_description">Leer jou roetine en laat laai rondom 80%% pouseer om batteryleeftyd te verleng, en vul die dopjes aan voordat jy hulle waarskynlik sal gebruik.</string>
<string name="device_settings_charge_cap_rejected_message">Geoptimaliseerde Laailimiet kon nie verander word nie. Dit is \'n eksperimentele funksie — meld asseblief as dit aanhou misluk.</string>
<string name="device_settings_category_connections_label">Gekoppelde toestelle</string> <string name="device_settings_category_connections_label">Gekoppelde toestelle</string>
<string name="device_settings_connected_devices_description">Ander toestelle wat tans aan hierdie AirPods gekoppel is</string> <string name="device_settings_connected_devices_description">Ander toestelle wat tans aan hierdie AirPods gekoppel is</string>
<string name="device_settings_connected_device_label">Toestel %d</string> <string name="device_settings_connected_device_label">Toestel %d</string>
<string name="device_settings_connected_device_call">In \n oproep</string> <string name="device_settings_connected_device_call">In n oproep</string>
<string name="device_settings_connected_device_media">Speel media</string> <string name="device_settings_connected_device_media">Speel media</string>
<string name="device_settings_noise_control_label">Geraasbeheer</string> <string name="device_settings_noise_control_label">Geraasbeheer</string>
<string name="device_settings_eq_label">Egaliseerder</string> <string name="device_settings_eq_label">Egaliseerder</string>
+19 -6
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string> <string name="settings_monitor_connected_notification_description">አንድ መሣሪያ ሲገናኝ ተጨማሪ ማሳወቂያ ያሳያል። ይህ የ \"የመሣሪያ ሁኔታ\" ቻናልን በማሰናከል ቋሚውን \"ምንም መሣሪያዎች የሉም\" የሚለውን ማሳወቂያ እንዲደብቁ ያስችልዎታል።</string>
<string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string> <string name="settings_keep_notification_after_disconnect_label">ከተነተቀ በኋላ ማሳወቂያውን ይያዙ</string>
<string name="settings_keep_notification_after_disconnect_description">AirPods ከተነተቁ በኋላም የመጨረሻውን የታወቀ የባትሪ ደረጃ ማሳየቱን ይቀጥሉ</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_label">ራስ-ሰር ለአፍታ ማቆም</string>
<string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string> <string name="settings_autopause_description">መሳሪያውን ከጆሮዎ ሲያወጡ ድምጽን ለአፍታ ያቁሙ።</string>
<string name="settings_autopplay_label">ራስ-ሰር አጫውት</string> <string name="settings_autopplay_label">ራስ-ሰር አጫውት</string>
@@ -152,6 +150,7 @@
<string name="widget_config_transparency_label">የዳራ ግልጽነት</string> <string name="widget_config_transparency_label">የዳራ ግልጽነት</string>
<string name="widget_config_show_device_label">የመሳሪያ ስም አሳይ</string> <string name="widget_config_show_device_label">የመሳሪያ ስም አሳይ</string>
<string name="widget_config_reset_label">ወደ ነባሪ መመለስ</string> <string name="widget_config_reset_label">ወደ ነባሪ መመለስ</string>
<string name="widget_config_preview_resize_hint">ዊጄቱን በዋናው ስክሪን ላይ ካስቀመጡት በኋላ መጠኑን መቀየር ይችላሉ።</string>
<string name="widget_config_custom_label">ብጁ</string> <string name="widget_config_custom_label">ብጁ</string>
<string name="widget_config_preset_material_you">Material You</string> <string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">ጨለማ</string> <string name="widget_config_preset_dark">ጨለማ</string>
@@ -221,9 +220,6 @@
<string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string> <string name="permission_required_title">የሚከተለው ፈቃድ ያስፈልጋል:</string>
<string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string> <string name="permission_system_alert_window_label">የስርዓት ማስጠንቀቂያ መስኮት</string>
<string name="permission_system_alert_window_description">\"ብቅ-ባይ አሳይ\" ባህሪውን ለማስቻል CAPod በሌሎች መተግበሪያዎች ላይ እንዲሳል ፍቀድ።</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_manual_label">መተግበሪያው ሲከፈት</string>
<string name="settings_monitor_mode_automatic_label">መሣሪያ ሲገናኝ</string> <string name="settings_monitor_mode_automatic_label">መሣሪያ ሲገናኝ</string>
<string name="settings_monitor_mode_always_label">ሁልጊዜ</string> <string name="settings_monitor_mode_always_label">ሁልጊዜ</string>
@@ -250,6 +246,7 @@
<string name="pods_unknown_contact_dev">ይህ ያልታወቀ መሣሪያ ነው ግን ተመሳሳይ የመልዕክት ቅርጸት ይጠቀማል። ድጋፍ እንጨምርለት፣ ያግኙኝ :)</string> <string name="pods_unknown_contact_dev">ይህ ያልታወቀ መሣሪያ ነው ግን ተመሳሳይ የመልዕክት ቅርጸት ይጠቀማል። ድጋፍ እንጨምርለት፣ ያግኙኝ :)</string>
<string name="pods_none_label_short">ምንም መሣሪያ የለም</string> <string name="pods_none_label_short">ምንም መሣሪያ የለም</string>
<string name="pods_charging_label">በመሙላት ላይ</string> <string name="pods_charging_label">በመሙላት ላይ</string>
<string name="pods_charging_optimized_label">ተመቻችቷል</string>
<string name="pods_inear_label">በጆሮ ውስጥ</string> <string name="pods_inear_label">በጆሮ ውስጥ</string>
<string name="pods_microphone_label">ማይክሮፎን</string> <string name="pods_microphone_label">ማይክሮፎን</string>
<string name="anc_mode_off">ጠፍቷል</string> <string name="anc_mode_off">ጠፍቷል</string>
@@ -407,11 +404,15 @@
<string name="device_settings_info_bt_name_label">የብሉቱዝ መሳሪያ ስያሜ</string> <string name="device_settings_info_bt_name_label">የብሉቱዝ መሳሪያ ስያሜ</string>
<string name="device_settings_info_model_label">ሞዴል</string> <string name="device_settings_info_model_label">ሞዴል</string>
<string name="device_settings_info_manufacturer_label">አምራች</string> <string name="device_settings_info_manufacturer_label">አምራች</string>
<string name="device_settings_info_hardware_label">ሃርድዌር</string>
<string name="device_settings_info_serial_label">ተከታታይ ቁጥር</string> <string name="device_settings_info_serial_label">ተከታታይ ቁጥር</string>
<string name="device_settings_info_firmware_label">Firmware</string> <string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_info_firmware_pending_label">ወደ ላይ ለሚጫን ፈርምዌር</string>
<string name="device_settings_info_build_label">ግንባታ</string> <string name="device_settings_info_build_label">ግንባታ</string>
<string name="device_settings_info_left_serial_label">የግራ ፖድ ተከታታይ ቅጥር</string> <string name="device_settings_info_left_serial_label">የግራ ፖድ ተከታታይ ቅጥር</string>
<string name="device_settings_info_right_serial_label">የቀኝ ፖድ ተከታታይ ቅጥር</string> <string name="device_settings_info_right_serial_label">የቀኝ ፖድ ተከታታይ ቅጥር</string>
<string name="device_settings_info_left_bonded_label">ግራ ተያይዟል</string>
<string name="device_settings_info_right_bonded_label">ቀኝ ተያይዟል</string>
<string name="device_settings_info_details_label">የመሳሪያ ዝርዝሮች</string> <string name="device_settings_info_details_label">የመሳሪያ ዝርዝሮች</string>
<string name="device_settings_info_details_action">የመሳሪያ ዝርዝሮችን አሳይ</string> <string name="device_settings_info_details_action">የመሳሪያ ዝርዝሮችን አሳይ</string>
<string name="device_settings_info_status_label">ሁኔታ</string> <string name="device_settings_info_status_label">ሁኔታ</string>
@@ -421,8 +422,11 @@
<string name="device_settings_not_connected_description">ይህ መሣሪያ አቅራቢያ አለ ነገር ግን ከዚህ ስልክ ጋር አልተገናኘም። ቅንብሮችን እና መቆጣጠሪያዎችን ለማግኘት ያገናኙ።</string> <string name="device_settings_not_connected_description">ይህ መሣሪያ አቅራቢያ አለ ነገር ግን ከዚህ ስልክ ጋር አልተገናኘም። ቅንብሮችን እና መቆጣጠሪያዎችን ለማግኘት ያገናኙ።</string>
<string name="device_settings_not_connected_connect_action">ያገናኙ</string> <string name="device_settings_not_connected_connect_action">ያገናኙ</string>
<string name="device_settings_not_connected_open_settings_action">የብሉቱዝ ቅንብሮችን ይክፈቱ</string> <string name="device_settings_not_connected_open_settings_action">የብሉቱዝ ቅንብሮችን ይክፈቱ</string>
<string name="device_settings_not_nearby_label">መሣሪያው ቅርብ አይደለም</string>
<string name="device_settings_not_nearby_description">ቅንብሮቹ የሚገኙት ይህ መሣሪያ ቅርብ ሆኖ ከስልክዎ ጋር ሲገናኝ ብቻ ነው።</string>
<string name="device_settings_aap_unavailable_label">የላቁ ቅንብሮች አይገኙም</string> <string name="device_settings_aap_unavailable_label">የላቁ ቅንብሮች አይገኙም</string>
<string name="device_settings_aap_unavailable_description">የላቀ AirPods ቅንብሮች በዚህ ስለክ ላይ የማይገኝ የብሉቱዝ ባህሪ ይፈልጋሉ። ይህ ከወደፊቱ ከመሳሪያ አምራችዎ የሚመጣ የAndroid ዝማኔ ሊፈታ ይችላል።</string> <string name="device_settings_aap_unavailable_description">የላቀ AirPods ቅንብሮች በዚህ ስለክ ላይ የማይገኝ የብሉቱዝ ባህሪ ይፈልጋሉ። ይህ ከወደፊቱ ከመሳሪያ አምራችዎ የሚመጣ የAndroid ዝማኔ ሊፈታ ይችላል።</string>
<string name="device_settings_aap_unavailable_action">የተኳሃኝነት ተከታይ ይመልከቱ</string>
<string name="device_settings_category_sound_label">ድምፅ</string> <string name="device_settings_category_sound_label">ድምፅ</string>
<string name="device_settings_category_controls_label">መቀጣጣሪያዎች</string> <string name="device_settings_category_controls_label">መቀጣጣሪያዎች</string>
<string name="device_settings_nc_one_airpod_label">ANC በአንድ ፖድ</string> <string name="device_settings_nc_one_airpod_label">ANC በአንድ ፖድ</string>
@@ -460,7 +464,9 @@
<string name="device_settings_end_call_mute_mic_option_b_title">ማይክሮፎኑን ለማጥፈት አንድ ጊዜ ጀን</string> <string name="device_settings_end_call_mute_mic_option_b_title">ማይክሮፎኑን ለማጥፈት አንድ ጊዜ ጀን</string>
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ጥሪን ለመጨረስ ሁለት ጊዜ ጀን</string> <string name="device_settings_end_call_mute_mic_option_b_subtitle">ጥሪን ለመጨረስ ሁለት ጊዜ ጀን</string>
<string name="device_settings_open_cd">የመሣሪያ ቅንብሮችን ክፈት</string> <string name="device_settings_open_cd">የመሣሪያ ቅንብሮችን ክፈት</string>
<string name="overview_card_missing_paired_device_cd">ፕሮፋይሉ ምንም ተጣምሮ የ Bluetooth መሳሪያ የለም — ፕሮፋይሉን ለማስተካከል መታ ያድርጉ</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 --> <!-- New device settings -->
<string name="device_settings_category_general_label">አጠቃላይ</string> <string name="device_settings_category_general_label">አጠቃላይ</string>
<string name="device_settings_microphone_mode_label">ማይክሮፎን</string> <string name="device_settings_microphone_mode_label">ማይክሮፎን</string>
@@ -483,6 +489,9 @@
<string name="device_settings_allow_off_description">ጠፍን እንደ ሊመረጥ የሚችል የጫጫታ ቁጥጥር ሁኔታ ፍቀድ። ሲሰናከል፣ ቀንዶቹ ዑደት ሲያደርጉ ጠፍን ይዝለሉ።</string> <string name="device_settings_allow_off_description">ጠፍን እንደ ሊመረጥ የሚችል የጫጫታ ቁጥጥር ሁኔታ ፍቀድ። ሲሰናከል፣ ቀንዶቹ ዑደት ሲያደርጉ ጠፍን ይዝለሉ።</string>
<string name="device_settings_sleep_detection_label">እንቅልፍ ማወቃያ</string> <string name="device_settings_sleep_detection_label">እንቅልፍ ማወቃያ</string>
<string name="device_settings_sleep_detection_description">ሲተኝ ድምጹን ራስ-ሰር አቁም</string> <string name="device_settings_sleep_detection_description">ሲተኝ ድምጹን ራስ-ሰር አቁም</string>
<string name="reaction_sleep_channel_label">የእንቅልፍ ማወቂያ</string>
<string name="reaction_sleep_notification_title">በእንቅልፍ ማወቂያ ምክንያት ታግዷል</string>
<string name="reaction_sleep_notification_text">%1$s እርስዎ ተኝተዋል ብሎ ዘገበ፣ ስለዚህ ሙዚቃዎ ተቋርጧል። ይህን በመሣሪያ ቅንብሮች ውስጥ ማሰናከል ይችላሉ።</string>
<string name="device_settings_rename_label">ስም ቀይር</string> <string name="device_settings_rename_label">ስም ቀይር</string>
<string name="device_settings_rename_hint">የመሣሪያ ስም</string> <string name="device_settings_rename_hint">የመሣሪያ ስም</string>
<string name="device_settings_rename_confirm">ስም ቀይር</string> <string name="device_settings_rename_confirm">ስም ቀይር</string>
@@ -492,6 +501,10 @@
<string name="device_settings_rename_system_unavailable_bt_settings_action">የ Bluetooth ቅንብሮች</string> <string name="device_settings_rename_system_unavailable_bt_settings_action">የ Bluetooth ቅንብሮች</string>
<string name="device_settings_send_failed">ቅንብሩን መተግበር አልተቻለም: %1$s</string> <string name="device_settings_send_failed">ቅንብሩን መተግበር አልተቻለም: %1$s</string>
<string name="device_settings_anc_off_rejected_message">ጠፍ ሁኔታ በዚህ መሳሪያ ላይ አልነቃም። \"ጠፍ ሁኔታን ፍቀድ\" በጫጫታ ቁጥጥር ስር አንቃ።</string> <string name="device_settings_anc_off_rejected_message">ጠፍ ሁኔታ በዚህ መሳሪያ ላይ አልነቃም። \"ጠፍ ሁኔታን ፍቀድ\" በጫጫታ ቁጥጥር ስር አንቃ።</string>
<string name="device_settings_category_battery_label">ባትሪ</string>
<string name="device_settings_charge_cap_label">ተመቻችቶ የሚቆጠር የኃይል ወሰን</string>
<string name="device_settings_charge_cap_description">የእርስዎን ልምድ ተምሮ የባትሪ ሕይወት ለማሳደግ ወደ 80%% ሲደርስ ኤሌክትሪኩን ያቆማል፣ ሊጠቀሟቸው ከመሆናቸው በፊት ድብዳቤ ኮዳቹን ሙሉ ይሞላቸዋል።</string>
<string name="device_settings_charge_cap_rejected_message">ተመቻችቶ የሚቆጠር የኃይል ወሰን ሊቀየር አልቻለም። ይህ የሙከራ ባህሪ ነው — አሁንም ቢሆን ካልሰራ እባክዎ ሪፖርት ያድርጉ።</string>
<string name="device_settings_category_connections_label">የተገናኙ መሣሪያዎች</string> <string name="device_settings_category_connections_label">የተገናኙ መሣሪያዎች</string>
<string name="device_settings_connected_devices_description">አሁን ለእነዚህ AirPods የተገናኙ ሌሎች መሣሪያዎች</string> <string name="device_settings_connected_devices_description">አሁን ለእነዚህ AirPods የተገናኙ ሌሎች መሣሪያዎች</string>
<string name="device_settings_connected_device_label">መሣሪያ %d</string> <string name="device_settings_connected_device_label">መሣሪያ %d</string>
+19 -6
View File
@@ -46,8 +46,6 @@
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string> <string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string> <string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
<string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</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_label">إيقاف مؤقت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string> <string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopplay_label">تشغيل تلقائي</string> <string name="settings_autopplay_label">تشغيل تلقائي</string>
@@ -152,6 +150,7 @@
<string name="widget_config_transparency_label">شفافية الخلفية</string> <string name="widget_config_transparency_label">شفافية الخلفية</string>
<string name="widget_config_show_device_label">إظهار اسم الجهاز</string> <string name="widget_config_show_device_label">إظهار اسم الجهاز</string>
<string name="widget_config_reset_label">إعادة تعيين إلى الإعدادات الافتراضية</string> <string name="widget_config_reset_label">إعادة تعيين إلى الإعدادات الافتراضية</string>
<string name="widget_config_preview_resize_hint">يمكنك تغيير حجم الأداة بعد وضعها على شاشتك الرئيسية.</string>
<string name="widget_config_custom_label">مخصص</string> <string name="widget_config_custom_label">مخصص</string>
<string name="widget_config_preset_material_you">Material You</string> <string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">مظلم</string> <string name="widget_config_preset_dark">مظلم</string>
@@ -229,9 +228,6 @@
<string name="permission_required_title">الإذن التالي مطلوب:</string> <string name="permission_required_title">الإذن التالي مطلوب:</string>
<string name="permission_system_alert_window_label">نافذة تنبيه النظام</string> <string name="permission_system_alert_window_label">نافذة تنبيه النظام</string>
<string name="permission_system_alert_window_description">السماح لـ CAPod بالرسم فوق التطبيقات الأخرى لتمكين ميزة \"إظهار النافذة المنبثقة\".</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_manual_label">عند فتح التطبيق</string>
<string name="settings_monitor_mode_automatic_label">عند اتصال الجهاز</string> <string name="settings_monitor_mode_automatic_label">عند اتصال الجهاز</string>
<string name="settings_monitor_mode_always_label">دائمًا</string> <string name="settings_monitor_mode_always_label">دائمًا</string>
@@ -258,6 +254,7 @@
<string name="pods_unknown_contact_dev">هذا جهاز غير معروف، لكنه يستخدم تنسيق رسائل مشابهًا. دعونا نضيف دعمًا له، تواصل معي :)</string> <string name="pods_unknown_contact_dev">هذا جهاز غير معروف، لكنه يستخدم تنسيق رسائل مشابهًا. دعونا نضيف دعمًا له، تواصل معي :)</string>
<string name="pods_none_label_short">لا يوجد جهاز</string> <string name="pods_none_label_short">لا يوجد جهاز</string>
<string name="pods_charging_label">جارٍ الشحن</string> <string name="pods_charging_label">جارٍ الشحن</string>
<string name="pods_charging_optimized_label">محسَّن</string>
<string name="pods_inear_label">في الأذن</string> <string name="pods_inear_label">في الأذن</string>
<string name="pods_microphone_label">الميكروفون</string> <string name="pods_microphone_label">الميكروفون</string>
<string name="anc_mode_off">إيقاف</string> <string name="anc_mode_off">إيقاف</string>
@@ -427,11 +424,15 @@
<string name="device_settings_info_bt_name_label">تسمية جهاز البلوتوث</string> <string name="device_settings_info_bt_name_label">تسمية جهاز البلوتوث</string>
<string name="device_settings_info_model_label">الطراز</string> <string name="device_settings_info_model_label">الطراز</string>
<string name="device_settings_info_manufacturer_label">الشركة المصنّعة</string> <string name="device_settings_info_manufacturer_label">الشركة المصنّعة</string>
<string name="device_settings_info_hardware_label">الجهاز</string>
<string name="device_settings_info_serial_label">الرقم التسلسلي</string> <string name="device_settings_info_serial_label">الرقم التسلسلي</string>
<string name="device_settings_info_firmware_label">البرنامج الثابت</string> <string name="device_settings_info_firmware_label">البرنامج الثابت</string>
<string name="device_settings_info_firmware_pending_label">البرنامج الثابت المعلق</string>
<string name="device_settings_info_build_label">الإصدار</string> <string name="device_settings_info_build_label">الإصدار</string>
<string name="device_settings_info_left_serial_label">الرقم التسلسلي للسماعة اليسرى</string> <string name="device_settings_info_left_serial_label">الرقم التسلسلي للسماعة اليسرى</string>
<string name="device_settings_info_right_serial_label">الرقم التسلسلي للسماعة اليمنى</string> <string name="device_settings_info_right_serial_label">الرقم التسلسلي للسماعة اليمنى</string>
<string name="device_settings_info_left_bonded_label">اليسار مقترن</string>
<string name="device_settings_info_right_bonded_label">اليمين مقترن</string>
<string name="device_settings_info_details_label">تفاصيل الجهاز</string> <string name="device_settings_info_details_label">تفاصيل الجهاز</string>
<string name="device_settings_info_details_action">عرض تفاصيل الجهاز</string> <string name="device_settings_info_details_action">عرض تفاصيل الجهاز</string>
<string name="device_settings_info_status_label">الحالة</string> <string name="device_settings_info_status_label">الحالة</string>
@@ -441,8 +442,11 @@
<string name="device_settings_not_connected_description">هذا الجهاز قريب لكنه غير متصل بهذا الهاتف. اتصل للوصول إلى الإعدادات والعناصر.</string> <string name="device_settings_not_connected_description">هذا الجهاز قريب لكنه غير متصل بهذا الهاتف. اتصل للوصول إلى الإعدادات والعناصر.</string>
<string name="device_settings_not_connected_connect_action">اتصال</string> <string name="device_settings_not_connected_connect_action">اتصال</string>
<string name="device_settings_not_connected_open_settings_action">فتح إعدادات البلوتوث</string> <string name="device_settings_not_connected_open_settings_action">فتح إعدادات البلوتوث</string>
<string name="device_settings_not_nearby_label">الجهاز غير قريب</string>
<string name="device_settings_not_nearby_description">الإعدادات متاحة فقط عندما يكون هذا الجهاز قريبًا ومتصلًا بهاتفك.</string>
<string name="device_settings_aap_unavailable_label">الإعدادات المتقدمة غير متاحة</string> <string name="device_settings_aap_unavailable_label">الإعدادات المتقدمة غير متاحة</string>
<string name="device_settings_aap_unavailable_description">تتطلب إعدادات AirPods المتقدمة ميزة بلوتوث غير متاحة على هذا الهاتف. قد يتم حل هذا بتحديث Android مستقبلي من الشركة المصنّعة لجهازك.</string> <string name="device_settings_aap_unavailable_description">تتطلب إعدادات AirPods المتقدمة ميزة بلوتوث غير متاحة على هذا الهاتف. قد يتم حل هذا بتحديث Android مستقبلي من الشركة المصنّعة لجهازك.</string>
<string name="device_settings_aap_unavailable_action">عرض متتبع التوافق</string>
<string name="device_settings_category_sound_label">الصوت</string> <string name="device_settings_category_sound_label">الصوت</string>
<string name="device_settings_category_controls_label">عناصر التحكم</string> <string name="device_settings_category_controls_label">عناصر التحكم</string>
<string name="device_settings_nc_one_airpod_label">إلغاء ضوضاء نشط مع سماعة واحدة</string> <string name="device_settings_nc_one_airpod_label">إلغاء ضوضاء نشط مع سماعة واحدة</string>
@@ -480,7 +484,9 @@
<string name="device_settings_end_call_mute_mic_option_b_title">ضغطة واحدة لكتم صوت الميكروفون</string> <string name="device_settings_end_call_mute_mic_option_b_title">ضغطة واحدة لكتم صوت الميكروفون</string>
<string name="device_settings_end_call_mute_mic_option_b_subtitle">ضغطة مزدوجة لإنهاء المكالمة</string> <string name="device_settings_end_call_mute_mic_option_b_subtitle">ضغطة مزدوجة لإنهاء المكالمة</string>
<string name="device_settings_open_cd">فتح إعدادات الجهاز</string> <string name="device_settings_open_cd">فتح إعدادات الجهاز</string>
<string name="overview_card_missing_paired_device_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 --> <!-- New device settings -->
<string name="device_settings_category_general_label">عام</string> <string name="device_settings_category_general_label">عام</string>
<string name="device_settings_microphone_mode_label">ميكروفون</string> <string name="device_settings_microphone_mode_label">ميكروفون</string>
@@ -503,6 +509,9 @@
<string name="device_settings_allow_off_description">السماح بوضع إيقاف كوضع تحكم قابل للتحديد في الضوضاء. عند التعطيل، تتجاوز الذراعان وضع إيقاف أثناء الدوران.</string> <string name="device_settings_allow_off_description">السماح بوضع إيقاف كوضع تحكم قابل للتحديد في الضوضاء. عند التعطيل، تتجاوز الذراعان وضع إيقاف أثناء الدوران.</string>
<string name="device_settings_sleep_detection_label">اكتشاف النوم</string> <string name="device_settings_sleep_detection_label">اكتشاف النوم</string>
<string name="device_settings_sleep_detection_description">إيقاف الصوت تلقائيًا عند النوم</string> <string name="device_settings_sleep_detection_description">إيقاف الصوت تلقائيًا عند النوم</string>
<string name="reaction_sleep_channel_label">اكتشاف النوم</string>
<string name="reaction_sleep_notification_title">تم الإيقاف المؤقت بواسطة اكتشاف النوم</string>
<string name="reaction_sleep_notification_text">%1$s أفاد بأنك نمت، لذا تم إيقاف موسيقاك مؤقتًا. يمكنك تعطيل هذا في إعدادات الجهاز.</string>
<string name="device_settings_rename_label">إعادة تسمية</string> <string name="device_settings_rename_label">إعادة تسمية</string>
<string name="device_settings_rename_hint">اسم الجهاز</string> <string name="device_settings_rename_hint">اسم الجهاز</string>
<string name="device_settings_rename_confirm">إعادة تسمية</string> <string name="device_settings_rename_confirm">إعادة تسمية</string>
@@ -512,6 +521,10 @@
<string name="device_settings_rename_system_unavailable_bt_settings_action">إعدادات البلوتوث</string> <string name="device_settings_rename_system_unavailable_bt_settings_action">إعدادات البلوتوث</string>
<string name="device_settings_send_failed">تعذّر تطبيق الإعداد: %1$s</string> <string name="device_settings_send_failed">تعذّر تطبيق الإعداد: %1$s</string>
<string name="device_settings_anc_off_rejected_message">وضع إيقاف غير مفعّل على هذا الجهاز. فعّل \"السماح بوضع إيقاف\" ضمن التحكم في الضوضاء.</string> <string name="device_settings_anc_off_rejected_message">وضع إيقاف غير مفعّل على هذا الجهاز. فعّل \"السماح بوضع إيقاف\" ضمن التحكم في الضوضاء.</string>
<string name="device_settings_category_battery_label">البطارية</string>
<string name="device_settings_charge_cap_label">حد الشحن المحسَّن</string>
<string name="device_settings_charge_cap_description">يتعلم روتينك ويوقف الشحن مؤقتًا عند حوالي 80%% لإطالة عمر البطارية، مع شحن السماعات بالكامل قبل استخدامك المتوقع لها.</string>
<string name="device_settings_charge_cap_rejected_message">تعذّر تغيير حد الشحن المحسَّن. هذه ميزة تجريبية — يرجى الإبلاغ إذا استمر الفشل.</string>
<string name="device_settings_category_connections_label">الأجهزة المتصلة</string> <string name="device_settings_category_connections_label">الأجهزة المتصلة</string>
<string name="device_settings_connected_devices_description">الأجهزة الأخرى المتصلة حاليًا بهذه AirPods</string> <string name="device_settings_connected_devices_description">الأجهزة الأخرى المتصلة حاليًا بهذه AirPods</string>
<string name="device_settings_connected_device_label">الجهاز %d</string> <string name="device_settings_connected_device_label">الجهاز %d</string>
+20 -7
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_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_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_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_label">Avtomatik fasilə</string>
<string name="settings_autopause_description">Cihazı qulağınızdan çıxardıqda səsi dayandırın.</string> <string name="settings_autopause_description">Cihazı qulağınızdan çıxardıqda səsi dayandırın.</string>
<string name="settings_autopplay_label">Avtomatik oxutma</string> <string name="settings_autopplay_label">Avtomatik oxutma</string>
@@ -152,6 +150,7 @@
<string name="widget_config_transparency_label">Fon şəffaflığı</string> <string name="widget_config_transparency_label">Fon şəffaflığı</string>
<string name="widget_config_show_device_label">Cihaz adını göstər</string> <string name="widget_config_show_device_label">Cihaz adını göstər</string>
<string name="widget_config_reset_label">Varsayılan ayarlara sıfırla</string> <string name="widget_config_reset_label">Varsayılan ayarlara sıfırla</string>
<string name="widget_config_preview_resize_hint">Vidceti ana ekranınıza yerləşdirdikdən sonra ölçüsünü dəyişə bilərsiniz.</string>
<string name="widget_config_custom_label">Fərdi</string> <string name="widget_config_custom_label">Fərdi</string>
<string name="widget_config_preset_material_you">Material You</string> <string name="widget_config_preset_material_you">Material You</string>
<string name="widget_config_preset_dark">Tünd</string> <string name="widget_config_preset_dark">Tünd</string>
@@ -221,9 +220,6 @@
<string name="permission_required_title">Aşağıdakı icazə tələb olunur:</string> <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_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="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_manual_label">Tətbiq açıq olduqda</string>
<string name="settings_monitor_mode_automatic_label">Cihaz qoşulduqda</string> <string name="settings_monitor_mode_automatic_label">Cihaz qoşulduqda</string>
<string name="settings_monitor_mode_always_label">Həmişə</string> <string name="settings_monitor_mode_always_label">Həmişə</string>
@@ -233,7 +229,7 @@
<string name="pods_dual_left_label">Sol qulaqlıq</string> <string name="pods_dual_left_label">Sol qulaqlıq</string>
<string name="pods_dual_right_label">Sağ qulaqlıq</string> <string name="pods_dual_right_label">Sağ qulaqlıq</string>
<string name="pods_dual_left_short_label">S</string> <string name="pods_dual_left_short_label">S</string>
<string name="pods_dual_right_short_label">S</string> <string name="pods_dual_right_short_label">SĚ</string>
<string name="pods_case_label">Qutu</string> <string name="pods_case_label">Qutu</string>
<string name="battery_unavailable_label">Batareya məlumatı yoxdur</string> <string name="battery_unavailable_label">Batareya məlumatı yoxdur</string>
<string name="pods_case_status_open_label">Açıq</string> <string name="pods_case_status_open_label">Açıq</string>
@@ -250,6 +246,7 @@
<string name="pods_unknown_contact_dev">Bu naməlum bir cihazdır, lakin oxşar mesaj formatından istifadə edir. Onun üçün dəstək əlavə edək, mənimlə əlaqə saxlayın :)</string> <string name="pods_unknown_contact_dev">Bu naməlum bir cihazdır, lakin oxşar mesaj formatından istifadə edir. Onun üçün dəstək əlavə edək, mənimlə əlaqə saxlayın :)</string>
<string name="pods_none_label_short">Cihaz yoxdur</string> <string name="pods_none_label_short">Cihaz yoxdur</string>
<string name="pods_charging_label">Şarj olunur</string> <string name="pods_charging_label">Şarj olunur</string>
<string name="pods_charging_optimized_label">Optimallaşdırılmış</string>
<string name="pods_inear_label">Qulaqda</string> <string name="pods_inear_label">Qulaqda</string>
<string name="pods_microphone_label">Mikrofon</string> <string name="pods_microphone_label">Mikrofon</string>
<string name="anc_mode_off">Söndür</string> <string name="anc_mode_off">Söndür</string>
@@ -407,11 +404,15 @@
<string name="device_settings_info_bt_name_label">Bluetooth Cihaz Etiketə</string> <string name="device_settings_info_bt_name_label">Bluetooth Cihaz Etiketə</string>
<string name="device_settings_info_model_label">Model</string> <string name="device_settings_info_model_label">Model</string>
<string name="device_settings_info_manufacturer_label">İstehsalcı</string> <string name="device_settings_info_manufacturer_label">İstehsalcı</string>
<string name="device_settings_info_hardware_label">Aparat</string>
<string name="device_settings_info_serial_label">Seriya Nömrəsi</string> <string name="device_settings_info_serial_label">Seriya Nömrəsi</string>
<string name="device_settings_info_firmware_label">Proqram təminı</string> <string name="device_settings_info_firmware_label">Proqram təminı</string>
<string name="device_settings_info_firmware_pending_label">Gözləmədə olan proqram təminatı</string>
<string name="device_settings_info_build_label">Yazılım versiyası</string> <string name="device_settings_info_build_label">Yazılım versiyası</string>
<string name="device_settings_info_left_serial_label">Sol Qulaqcıq Seriya Nömrəsi</string> <string name="device_settings_info_left_serial_label">Sol Qulaqcıq Seriya Nömrəsi</string>
<string name="device_settings_info_right_serial_label">Sağ Qulaqcıq Seriya Nömrəsi</string> <string name="device_settings_info_right_serial_label">Sağ Qulaqcıq Seriya Nömrəsi</string>
<string name="device_settings_info_left_bonded_label">Sol cütləşdirilmiş</string>
<string name="device_settings_info_right_bonded_label">Sağ cütləşdirilmiş</string>
<string name="device_settings_info_details_label">Cihaz Ǝtraflı Məlumatları</string> <string name="device_settings_info_details_label">Cihaz Ǝtraflı Məlumatları</string>
<string name="device_settings_info_details_action">Cihaz təfsilatlarını göstər</string> <string name="device_settings_info_details_action">Cihaz təfsilatlarını göstər</string>
<string name="device_settings_info_status_label">Vəziyyət</string> <string name="device_settings_info_status_label">Vəziyyət</string>
@@ -421,8 +422,11 @@
<string name="device_settings_not_connected_description">Bu cihaz yaxında yerləşir, lakin bu telefona bağlı deyil. Parametrlərə və idarəetməyə daxil olmaq üçün qoşulun.</string> <string name="device_settings_not_connected_description">Bu cihaz yaxında yerləşir, lakin bu telefona bağlı deyil. Parametrlərə və idarəetməyə daxil olmaq üçün qoşulun.</string>
<string name="device_settings_not_connected_connect_action">Qoşul</string> <string name="device_settings_not_connected_connect_action">Qoşul</string>
<string name="device_settings_not_connected_open_settings_action">Bluetooth Parametrlərini Aç</string> <string name="device_settings_not_connected_open_settings_action">Bluetooth Parametrlərini Aç</string>
<string name="device_settings_not_nearby_label">Cihaz yaxında deyil</string>
<string name="device_settings_not_nearby_description">Parametrlər yalnız bu cihaz yaxında olduqda və telefonunuza qoşulduqda mövcuddur.</string>
<string name="device_settings_aap_unavailable_label">Ətraflı parametrlər əlçatmazdır</string> <string name="device_settings_aap_unavailable_label">Ətraflı parametrlər əlçatmazdır</string>
<string name="device_settings_aap_unavailable_description">Qabaqcıl AirPods parametrləri bu telefondan əldati olunan Bluetooth funksiyasını tələb edir. Bu problem cihaz istehsalcınızdan gələcək Android yenilməsi ilə həll oluna bilər.</string> <string name="device_settings_aap_unavailable_description">Qabaqcıl AirPods parametrləri bu telefondan əldati olunan Bluetooth funksiyasını tələb edir. Bu problem cihaz istehsalcınızdan gələcək Android yenilməsi ilə həll oluna bilər.</string>
<string name="device_settings_aap_unavailable_action">Uyğunluq izləyicisinə baxın</string>
<string name="device_settings_category_sound_label">Səs</string> <string name="device_settings_category_sound_label">Səs</string>
<string name="device_settings_category_controls_label">İdarəetmələr</string> <string name="device_settings_category_controls_label">İdarəetmələr</string>
<string name="device_settings_nc_one_airpod_label">Tək qulaqcıqla ANC</string> <string name="device_settings_nc_one_airpod_label">Tək qulaqcıqla ANC</string>
@@ -460,7 +464,9 @@
<string name="device_settings_end_call_mute_mic_option_b_title">Mikrofonu susdurmağ üçün bir dəfə basın</string> <string name="device_settings_end_call_mute_mic_option_b_title">Mikrofonu susdurmağ üçün bir dəfə basın</string>
<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_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="device_settings_open_cd">Cihaz parametrlərini açın</string>
<string name="overview_card_missing_paired_device_cd">Profilin cütləşdirilmiş Bluetooth cihazı yoxdur — profili redaktə etmək üçün toxunun</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 --> <!-- New device settings -->
<string name="device_settings_category_general_label">Ümumi</string> <string name="device_settings_category_general_label">Ümumi</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string> <string name="device_settings_microphone_mode_label">Mikrofon</string>
@@ -483,6 +489,9 @@
<string name="device_settings_allow_off_description">Söndürmə rejiminin səs-küy nəzarətində seçilə bilməsinə icazə ver. Deaktiv edildçə, gövə dövr kəsindi Söndürməni atlayar.</string> <string name="device_settings_allow_off_description">Söndürmə rejiminin səs-küy nəzarətində seçilə bilməsinə icazə ver. Deaktiv edildçə, gövə dövr kəsindi Söndürməni atlayar.</string>
<string name="device_settings_sleep_detection_label">Yuxu Aşkarlama</string> <string name="device_settings_sleep_detection_label">Yuxu Aşkarlama</string>
<string name="device_settings_sleep_detection_description">Yuxuya getdikdə audionu avtomatik dayandırın</string> <string name="device_settings_sleep_detection_description">Yuxuya getdikdə audionu avtomatik dayandırın</string>
<string name="reaction_sleep_channel_label">Yuxu Aşkarlaması</string>
<string name="reaction_sleep_notification_title">Yuxu Aşkarlaması ilə dayandırıldı</string>
<string name="reaction_sleep_notification_text">%1$s sizin yuxuya getdiyinizi bildirdi, buna görə musiqiniz dayandırıldı. Bunu cihaz parametrlərindən söndürə bilərsiniz.</string>
<string name="device_settings_rename_label">Adı dəyişdirin</string> <string name="device_settings_rename_label">Adı dəyişdirin</string>
<string name="device_settings_rename_hint">Cihaz adı</string> <string name="device_settings_rename_hint">Cihaz adı</string>
<string name="device_settings_rename_confirm">Adı dəyişdirin</string> <string name="device_settings_rename_confirm">Adı dəyişdirin</string>
@@ -492,6 +501,10 @@
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Parametrləri</string> <string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Parametrləri</string>
<string name="device_settings_send_failed">Parametr tətbiq edilə bilmədi: %1$s</string> <string name="device_settings_send_failed">Parametr tətbiq edilə bilmədi: %1$s</string>
<string name="device_settings_anc_off_rejected_message">Söndürmə rejimi bu cihazda aktiv deyil. Səs-Küy Nəzarəti altında \"Söndürmə rejiminə icazə ver\" seçimini aktiv edin.</string> <string name="device_settings_anc_off_rejected_message">Söndürmə rejimi bu cihazda aktiv deyil. Səs-Küy Nəzarəti altında \"Söndürmə rejiminə icazə ver\" seçimini aktiv edin.</string>
<string name="device_settings_category_battery_label">Batareya</string>
<string name="device_settings_charge_cap_label">Optimallaşdırılmış Şarj Həddi</string>
<string name="device_settings_charge_cap_description">Batareya ömrünü uzatmaq üçün rutininizi öyrənir və şarjı təxminən 80%%-də dayandırır, qulluqlardan istifadə etməzdən əvvəl onları şarj edir.</string>
<string name="device_settings_charge_cap_rejected_message">Optimallaşdırılmış Şarj Həddini dəyişdirmək mümkün olmadı. Bu eksperimental bir xüsusiyyətdir — davam edərsə xahiş edirik bildirin.</string>
<string name="device_settings_category_connections_label">Bağlı Cihazlar</string> <string name="device_settings_category_connections_label">Bağlı Cihazlar</string>
<string name="device_settings_connected_devices_description">Bu AirPodlara hal-hazırda bağlı olan digər cihazlar</string> <string name="device_settings_connected_devices_description">Bu AirPodlara hal-hazırda bağlı olan digər cihazlar</string>
<string name="device_settings_connected_device_label">Cihaz %d</string> <string name="device_settings_connected_device_label">Cihaz %d</string>

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