Compare commits

..
68 Commits
Author SHA1 Message Date
darken 7883dcbafb Release: 5.0.1-rc0 2026-04-08 20:27:19 +02:00
darken 300e6b8c49 chore: .gitignore 2026-04-08 20:25:13 +02:00
darken 33b6fbd0e0 General: Update app translations from Crowdin
Pull latest string translations from Crowdin. Validated and reverted any problematic changes.
2026-04-08 20:24:34 +02:00
darken f2bf39bda1 fix(monitor): Use AAP message time for Last Seen while connected 2026-04-08 19:21:19 +02:00
darken 87d366bcb7 refactor(ui): Improve device settings layout and grouping 2026-04-08 19:09:59 +02:00
darken df5732569f docs: Add Crowdin to README acknowledgments 2026-04-08 18:11:47 +02:00
darken 56e01cfde9 chore: Streamline PR description format in Claude rules 2026-04-08 18:07:52 +02:00
darken ed37e142ad fix(ui): Fix signal bars and restore individual connection icons 2026-04-08 18:07:06 +02:00
darken cb915d6263 feat(aap): Also update system Bluetooth alias when renaming
After the AAP rename succeeds, try to update Android's per-device bond alias via the hidden BluetoothDevice.setAlias(String) method, so the new name also shows up in the system Bluetooth settings on this phone.

Known failure mode on Android 12+: setAlias is gated behind a Companion Device Manager (CDM) association at the service layer, and raises 'does not have a CDM association with the Bluetooth Device' for third-party apps that don't hold one. In that case, a dedicated snackbar explains that the system rename didn't go through and suggests renaming manually in system settings or re-pairing.

The AAP-level rename is always attempted first and is the load-bearing part; the system alias is a best-effort extra.
2026-04-08 15:36:18 +02:00
darken 41245d928c fix(aap): Make AirPods rename actually apply on device
Switch the AAP rename packet to the opcode 0x1A format (04 00 04 00 1A 00 01 [size] 00 [name]) matching the LibrePods documentation and Linux implementation. The previous 0x1E variant (from the LibrePods Android code) was silently ignored by AirPods Pro 2 USB-C firmware — no 0x001D echo, no persistence across reconnect.

Verified on AirPods Pro 2 USB-C (firmware 81.2675...): the device now echoes the new name back via the next 0x001D INFORMATION message, and the name persists after disconnect/reconnect.

Also hardens the rename UX: gate the edit icon on isAapReady (was isAapConnected, which allowed sending during HANDSHAKING), apply an optimistic deviceInfo update with a scoped rollback on send failure, surface send errors via a new Event.SendFailed + snackbar, and restrict dialog input to ASCII with inline error feedback. Unifies send() / sendProGated() through a single sendInternal() helper so error plumbing benefits every command, not just rename.

Note: this only updates the AirPods firmware's self-reported name. Android's system Bluetooth settings read from the bond database and are not affected — renaming there still requires the Android system Bluetooth UI.
2026-04-08 15:36:18 +02:00
darken ccd95a0981 feat(aap): Show 'Not connected' card when AAP is unavailable 2026-04-08 11:49:07 +02:00
darken 2d38db5cba feat(aap): Log decoded 0x1D segments for engraving discovery
Diagnostic-only NUL-delimited UTF-8 segmentation of the 0x1D INFORMATION packet, logged at INFO so it lands in debug recordings via the existing FileLogger pipeline. The production decode path stays untouched — this only adds visibility, no behavior change. Refs #173.
2026-04-08 11:21:15 +02:00
darken 175f8e36b5 fix(aap): Address device settings review feedback
- Show connected device MACs in correct byte order (remove stale reversal)

- Rename 'Volume Swipe Length' setting to 'Volume Swipe Wait Time'

- Hide 'Charging Sounds' toggle: real case tones go over ATT and the actual effect of this AAP setting is unknown. Decode kept internally so AAP freshness signal still refreshes.

- Fix duplicate commands on End Call/Mute Mic radio buttons: use Modifier.selectable with Role.RadioButton, short-circuit when already selected, and wrap options in selectableGroup for TalkBack.
2026-04-07 18:53:54 +02:00
darken a102b03261 fix: Show signal strength as bar indicator instead of percent 2026-04-07 17:56:29 +02:00
darken 57fc4d8e7b ui: Remove noise control label from DeviceSettingsScreen 2026-04-07 15:36:20 +02:00
darken f67336d4fe fix(aap): Synthesize AAP-only devices for cold-start window 2026-04-07 15:24:03 +02:00
darken a511c57b7d fix(aap): Keep device settings visible when BLE scan goes stale 2026-04-07 13:28:37 +02:00
darken 66e3ab1670 Release: 5.0.0-beta0 2026-04-04 11:47:09 +02:00
darken 7e73ac80e9 fix(aap): Fix stem actions volume control, reset button, and Default/No Action semantics
Fix volume up/down doing nothing by using adjustSuggestedStreamVolume instead of dispatchMediaKeyEvent which ignores volume keycodes.

Add reset-to-defaults button with confirmation dialog in the stem actions TopAppBar.

Rename 'None' to 'Default' (firmware handles the press) and add 'No Action' (claimed but do nothing) to correctly model per-press-type claim mask semantics.

Remove disableNone lock; add cross-side auto-set logic so selecting Default resets both sides and selecting an action promotes the other side from Default to No Action.
2026-04-04 11:41:26 +02:00
darken 1ae9ae587e feat: Add device settings and controls to upgrade screen benefits 2026-04-04 11:41:26 +02:00
darken 4631aa8bb2 fix: Hide stale cached battery indicator when AAP is connected 2026-04-04 11:41:26 +02:00
darken 33c8fe76a6 fix(aap): Polish noise control UI and fix ANC mode switching
Combine ANC mode selector with listening mode cycle into a card with eye icon visibility toggles and radio button mode selection. Send AllowOff command when toggling OFF visibility.

Fix ANC resend logic fighting rapid mode changes by cancelling pending resend jobs on new commands. Reorder settings into Noise Control, Sound, Controls, General, and Connections sections.
2026-04-04 11:41:26 +02:00
darken e356344ba6 feat(aap): Add new device settings, stem actions, and noise control UI
Add 8 new AAP writable settings (microphone mode, ear detection toggle, listening mode cycle, allow off, stem config, sleep detection, in-case tone, device rename) and 4 new data reception features (stem press events, connected devices, audio source, EQ data).

Implement stem press action system with per-bud configurable Android actions (play/pause, next/prev track, volume), auto-sent stem config on connection, and dedicated config screen.

Combine ANC mode selector with listening mode cycle visibility into a unified noise control component. Eye icons control which modes appear in the dashboard card and settings. Pro gating with stars icon for upgrade-required features.

Add 64 unit tests covering all new decoders, model feature flags, malformed payloads, and rename byte-length validation.
2026-04-04 11:41:26 +02:00
darken e014ad8045 feat: Slim down overview cards and fix cached device icons
Remove timestamps, connection state, and conversation awareness from overview cards. Move signal badge inline with model subtitle. Relocate removed info to device settings screen info card. Fix cached devices showing Gen 1 icons by adding per-pod icon properties to PodModel. Add profile name subtitle to device settings toolbar. Simplify preview coverage to full/minimal/cached variants.
2026-04-03 15:36:27 +02:00
darken 9d446938bd fix(aap): Re-send ANC mode when firmware overrides after ear insertion 2026-04-03 13:38:53 +02:00
darken fea1092a95 refactor: Move AAP auto-connect from MonitorService to DeviceMonitor scope
AAP connections now run in appScope via AapLifecycleManager, independent of MonitorMode. Fixes AAP not connecting when monitor mode is MANUAL.

Also fixes reconnect cleanup on flow cancellation (try/finally).
2026-04-03 13:27:52 +02:00
darken adfedc1cce feat: Add device settings screen for AAP controls 2026-04-03 13:09:31 +02:00
darken c609b03148 feat(overview): Gate dashboard device count for free users
Free users see only their highest-priority profiled device. Additional devices are hidden behind an upgrade card showing the count and a flavor-aware CTA (Upgrade/Donate).
2026-04-02 18:30:40 +02:00
Matthias Urhahn 927a198d8e Merge pull request #475 from d4rken-org/feat/profile-drag-reorder
Device: Add drag-to-reorder for device profiles
2026-04-02 18:18:08 +02:00
darken 1f5f406b33 feat(profiles): Add drag-to-reorder for profile list
Wire up the missing drag-to-reorder UI that the priority hint already advertises. Long-press a profile row to reorder. Extracts a reusable ReorderableState component into common/compose.

Backend (repo + ViewModel) was already in place; this adds the gesture handling, auto-scroll, visual feedback, and ID-based reorder validation.
2026-04-02 18:17:00 +02:00
Matthias Urhahn 4a475419e8 Merge pull request #469 from d4rken-org/feat/device-state-cache
Device: Remember battery levels and show offline device cards
2026-04-02 17:36:59 +02:00
darken 7c37854076 refactor: Extract toCachedState extension and add tests
Move per-device persist logic from DeviceMonitor into a pure PodDevice.toCachedState() extension in the cache package. Add ToCachedStateTest with coverage for creates, skips, dedup, and slot preservation. Delete unused PodSorter.
2026-04-02 16:46:56 +02:00
darken 30d7efeec2 Moving packages around 2026-04-02 16:40:56 +02:00
darken 942e03af36 refactor: Merge device state persistence into DeviceMonitor
Eliminate DeviceStatePersister class by chaining persistence as a side effect in DeviceMonitor's flow. The flow is now shared via replayingShare(appScope) so persistence runs once per emission regardless of subscriber count, and works for BLE-only devices without MonitorService.
2026-04-02 16:36:12 +02:00
darken 24a84a49f1 refactor: Remove meta from PodDevice public API and delete unused PodMonitorExtensions
All external consumers now use top-level PodDevice properties. BlePodMonitor extensions were dead code — all callers use DeviceMonitor equivalents.
2026-04-02 16:01:00 +02:00
darken e3693628c2 refactor: Use PodDevice properties instead of reaching through BLE metadata
Replace device.meta?.profile?.address/id/label with device.address/profileId/label in AutoConnect, PopUpReaction, and BatteryGlanceWidget.
2026-04-02 15:42:12 +02:00
darken 94156b6200 fix: Show profile name on cached-only device cards
Add label property to PodDevice, populated from the profile in DeviceMonitor. Cards now use device.label instead of reaching through BLE metadata, so cached-only cards display the profile name instead of '?'.
2026-04-02 15:37:33 +02:00
darken 5ae15d828d refactor: Use PodDevice charging properties instead of duplicating fallback logic 2026-04-02 15:23:16 +02:00
darken 360067b07e refactor: Move persisters to monitor package and fix stale BLE device eviction
Move DeviceStatePersister and AapKeyPersister from reaction to monitor package since they are always-on infrastructure, not user-togglable reactions.

Add periodic ticker to BlePodMonitor to force stale device eviction when BLE scanner produces no results, fixing cached card not appearing after disconnect.
2026-04-02 15:14:47 +02:00
darken e91243e577 feat: Add unified device state cache for persistent battery display
Replace PodDeviceCache (raw BLE scan bytes) with DeviceStateCache that stores decoded combined device state (battery, charging, model) per profile.

Battery values persist across app restarts with per-slot timestamps. Cached-only cards appear for offline devices with muted visuals and a staleness indicator. Fallback chain: AAP -> BLE -> cached.
2026-04-02 13:56:09 +02:00
darken 19d61ca5cb feat: Add AAP session tests for AirPods Pro 2 USB-C
Add AirPodsPro2UsbcAapSessionTest with real captured bytes from a live

device session (model A3048, Pixel 8, 2026-04-02). Covers handshake,

device info, battery states, private keys, all Pro 2 USB-C settings

(including ADAPTIVE ANC, VolumeSwipe, ConversationalAwareness),

ear detection across 6 transitions, and unhandled messages.

Also document ChargingState observations across models and

EndCallMuteMic subtype variations in code comments.
2026-04-02 13:45:52 +02:00
darken 7b403defab fix: Add Compose stability annotations and fix lazy list keys
Add @Stable to PodDevice to enable Compose referential equality checks, reducing unnecessary recompositions from the 3-second update ticker. Make icon properties non-null with built-in defaults and fix lazy list keys to use stable string identifiers.
2026-04-02 13:18:05 +02:00
darken ceb4108306 feat(profiles): Filter paired devices already used by other profiles
Show devices claimed by other profiles as disabled in the dropdown with the claiming profile's name. Enforce address uniqueness at the repo layer.
2026-04-02 12:23:06 +02:00
darken fe4ac306cf fix: Correct feature flags for multiple PodModel entries
Remove false positive hasEarDetection from Beats Studio Buds and Studio Buds+ (neither has in-ear detection). Remove false positive hasVolumeSwipe and hasVolumeSwipeLength from AirPods 4 ANC (volume swipe is exclusive to AirPods Pro). Add missing hasEarDetection to AirPods Max, AirPods Max USB-C, AirPods Gen 4, Beats Solo Pro, and Beats Studio 3 (all have head/ear detection per Apple docs).
2026-04-01 06:57:45 +02:00
darken 618229b96b feat: Add AAP session tests for AirPods Pro Gen 1
Add AirPodsProAapSessionTest with real captured protocol data from AirPods Pro (A2084). Fix AIRPODS_PRO feature flags: remove hasVolumeSwipe/Length (hardware limitation), add hasEndCallMuteMic (firmware-supported). Fix primary pod decoder to accept byte[2]=0x00 format sent by Pro 1 on initial connect.
2026-03-31 22:36:23 +02:00
darken 1931e60b07 feat: Speed up AAP L2CAP reconnection
Only attempt AAP connections to classically-connected devices, eliminating futile retries for devices connected to other phones.

- Filter initialConnect() to devices in connectedAddresses

- Use mapLatest so stale retry loops cancel on state changes

- Parallelize per-profile connection attempts

- Add 5s connect timeout (best-effort) to cap retry cycles

- Add classic BT check to reconnectOnDisconnect()

- Keep MonitorService alive when AAP connections are active
2026-03-31 22:06:23 +02:00
darken 7bdc9ca212 feat: Auto-correct device model from AAP device info
When connected via AAP, the device reports its hardware model number. If the profile has a wrong or missing model, detect the mismatch and automatically correct it with a reconnect to apply correct feature flags (ANC modes, InitExt, etc.).

Adds modelNumbers field to PodModel enum with Apple hardware identifiers for all known devices, and a fromModelNumber() lookup function.
2026-03-31 20:52:57 +02:00
darken 04ea3d0cd8 feat: Add retry logic for AAP L2CAP connection failures
Initial connect had no retry — a single failed L2CAP attempt was silently swallowed. Reconnect-on-disconnect used separate longer backoff delays.

Both paths now share the same retry schedule (3s,3s,3s,5s,5s,10s,10s) giving 7 retries over ~39s. Initial connect checks if another path already reconnected before each retry.
2026-03-31 19:17:09 +02:00
darken fef6c97d2d feat: Detect primary pod via AAP cmd 0x0008 for faster ear detection 2026-03-31 19:17:09 +02:00
darken dc2e318374 fix: Trigger AAP connection when classic Bluetooth connects
initialConnect() only ran on profile changes, so if the L2CAP connection failed at service start (device not yet connected), it was never retried. Now also triggers on connectedDevices changes.

Also fix reconnect BLE address comparison: was comparing BLE RPA with bonded BR/EDR address (never matches), now uses profile address.
2026-03-31 19:17:09 +02:00
darken 71638c13ab feat: Decode AAP ear detection and stabilize ANC mode changes
Decode cmd 0x0006 as EarDetection with per-pod placement (IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED). Map AAP primary/secondary to left/right using BLE primary pod bit.

Queue ANC mode changes when no pod is in ear, auto-send when a pod goes in ear. Show pending mode in UI with secondary color treatment.

Debounce device-initiated ANC mode cycling during ear transitions. Skip debounce for user-initiated commands and initial handshake. Optimistic UI update on send for instant feedback.
2026-03-31 19:17:09 +02:00
darken bd5c6bf858 feat: Add MagicPodsCore and LibrePods to acknowledgements 2026-03-31 19:17:09 +02:00
darken 70e5b99e1d feat: Add connection type indicators to SignalBadge
Move BLE key and AAP connection icons into the SignalBadge pill.

Add BleKeyState enum on PodDevice to expose IRK/ENC state cleanly.

Key icon: outlined for IRK-only, solid for IRK+ENC.

Bluetooth icon shown when AAP transport is active.
2026-03-31 19:17:09 +02:00
darken f65c6e4a29 feat: Add BLE device support for 6 new Beats models
Solo Pro (0x0C20), Solo 4 (0x2520), Solo Buds (0x2620), Studio Buds (0x1120), Studio Buds+ (0x1620), Studio Pro (0x1720). Tests use handcrafted data pending real captures.
2026-03-31 19:17:09 +02:00
darken c860ecc430 feat: Integrate AAP connection freshness into signal quality indicator 2026-03-31 19:17:09 +02:00
darken a8593eecd3 refactor: Reorganize pods package into ble/ and aap/ domains
Move BLE-specific code (snapshots, devices, factories, protocol) under apple/ble/. Move AAP code under apple/aap/ with protocol/ subdirectory. PodModel stays in apple/ as the only shared type.
2026-03-31 19:17:09 +02:00
darken 94e28fb0b5 refactor: Move Proximity* classes into protocol.ble package 2026-03-31 19:17:09 +02:00
darken 4808e04146 fix: Update Dagger component reference after BaseBlePodsTest rename 2026-03-31 19:17:09 +02:00
darken aebf43c355 style: Reformat PodModel enum for better maintainability
Standardize the formatting of pod models and their feature sets. This improves the readability of device-specific capabilities and ensures cleaner diffs for future hardware additions.
2026-03-31 19:17:09 +02:00
darken c1cd99f701 feat: Add AAP L2CAP protocol support for reading and writing AirPods settings
Full integration of the Apple Accessory Protocol (AAP) over L2CAP, enabling direct communication with AirPods for 1% battery granularity, ANC mode control, Conversation Awareness toggle, and private key exchange for BLE encrypted battery.
2026-03-31 19:17:09 +02:00
darken 3fa01bb510 refactor: Rename PodDevice to BlePodSnapshot, MonitoredDevice to PodDevice, extract PodModel
Complete type rename chain: PodDevice (interface) becomes BlePodSnapshot, MonitoredDevice (facade) claims PodDevice name, Model enum extracted to top-level PodModel. Delete redundant type alias files. Fix stale comments and log tags.
2026-03-31 19:17:09 +02:00
darken e84f8762b4 Extract Model to PodModel.kt 2026-03-31 19:17:09 +02:00
darken ac21261d4d refactor: Migrate all consumers from PodDevice to MonitoredDevice
Replace direct PodDevice/PodMonitor usage with MonitoredDevice/DeviceMonitor across ViewModels, UI screens, notifications, widgets, reactions, and service. All interface cast-based property access replaced with flat MonitoredDevice properties. Make L2capSocketFactory injectable.
2026-03-31 19:17:09 +02:00
darken c19ee88ae6 refactor: Add BlePodSnapshot and BlePodMonitor type aliases
Introduce type aliases for the planned rename (PodDevice -> BlePodSnapshot, PodMonitor -> BlePodMonitor). New code uses the aliases to clarify BLE-specific types vs the unified MonitoredDevice facade.

Full codebase rename deferred to IDE refactoring pass (103 files, 470 occurrences).
2026-03-31 19:17:09 +02:00
darken f751cdb37e feat: Add MonitoredDevice facade and DeviceMonitor merge point
MonitoredDevice unifies BLE and AAP data sources with dynamic resolution.

DeviceMonitor combines PodMonitor + AapConnectionManager into a single Flow.

To be renamed to PodDevice/PodMonitor when the old types are renamed to BlePodSnapshot/BlePodMonitor.
2026-03-31 19:17:09 +02:00
darken 28cd3b1340 test: Add unit tests for AAP protocol layer and Model.Features 2026-03-31 19:17:09 +02:00
darken 3e2852e622 feat: Add AAP protocol layer, L2CAP socket factory, and Model.Features
Add L2CAP infrastructure (HiddenApiBypass, ArtMirror, L2capSocketFactory) in common/bluetooth/l2cap/ for BR/EDR L2CAP socket creation on Android 17+.

Add AAP protocol abstraction (AapSetting, AapCommand, AapMessage, AapFramer, AapDeviceProfile, AapConnection, AapConnectionManager) with sealed domain classes containing zero wire bytes.

Add Model.Features capability flags (hasDualPods, hasCase, hasEarDetection, hasAncControl) to all 25 device model enum entries.

Add debug-only L2capPocActivity for testing AAP connections.
2026-03-31 19:17:09 +02:00
darken b919d8d19b chore: Harden GitHub Actions workflow permissions
Add explicit permissions and persist-credentials: false to all workflows.

Without an explicit permissions block, GITHUB_TOKEN inherits the repo default (write-all). These CI workflows only need contents: read. The release workflow already declares contents: write at job level where needed.

persist-credentials: false prevents the token from lingering in .git/config for subsequent steps, reducing attack surface if a third-party action is compromised.
2026-03-28 09:22:01 +01:00
303 changed files with 23935 additions and 1975 deletions
+23 -2
View File
@@ -40,6 +40,18 @@ PR titles appear in auto-generated changelogs and are read by users. Use **ELI5,
## PR Description Format
PRs are reviewed in **GitHub's web UI**, which already shows the file tree, the diff, and the tests. Don't duplicate any
of it. The description should answer questions the diff can't — not restate it.
Only these sections, in this order:
1. `## What changed`
2. `## Technical Context`
3. `## Review checklist` *(optional)*
No `Scope`, `Files changed`, `Tests`, or `Review guidance` sub-sections — GitHub shows the files and tests, and review
notes belong in the checklist. Fold anything critical into a Technical Context bullet.
### What changed
User-friendly explanation of what this PR does. Describe the problem that was fixed or the feature that was added from the user's perspective. No internal class or method names.
@@ -53,9 +65,18 @@ Explain what's hard to extract from the diff alone. Focus on:
- **Why** this approach was chosen (and alternatives considered/rejected)
- **Root cause** for bug fixes (the diff shows the fix, not what caused it)
- **Non-obvious side effects** or behavioral changes not apparent from reading the code
- **Review guidance** — what's tricky or deserves close attention
Keep it scannable with bullet points. Don't restate what's visible in the diff (file names, class renames, line-level changes).
Format rules:
- **One bullet per point.** No prose paragraphs, no nested sub-headers like `**Bug 1** / **Bug 2**` — if a PR fixes
multiple bugs, one bullet per bug is enough.
- **Don't restate the diff.** File paths, class renames, test names, and line-level changes are all visible in the web
UI.
### Review checklist (optional)
For PRs with multiple non-trivial review points, add a `## Review checklist` section with `- [ ]` tasks the reviewer can
tick off as they verify. Skip it for small PRs — a single tricky thing can stay as a Technical Context bullet.
### Example
+11
View File
@@ -6,6 +6,9 @@ on:
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
lint-vital:
name: Lint vitals
@@ -19,6 +22,8 @@ jobs:
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -37,6 +42,8 @@ jobs:
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -54,6 +61,8 @@ jobs:
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -66,5 +75,7 @@ jobs:
steps:
- name: Checkout source code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Validate metadata lengths
run: bash fastlane/check_metadata_length.sh
@@ -8,10 +8,15 @@ on:
branches:
- main
permissions:
contents: read
jobs:
validation:
name: "Validation"
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
+5
View File
@@ -11,6 +11,9 @@ on:
type: boolean
default: true
permissions:
contents: read
jobs:
release-github:
name: Create GitHub release
@@ -33,6 +36,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -108,6 +112,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
+2 -1
View File
@@ -16,4 +16,5 @@
/fastlane/README.md
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/
app/src/screenshotTest*/reference/
.codex
+2 -1
View File
@@ -1,4 +1,4 @@
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400" alt="CAPod banner">
# Companion App for AirPods (CAPod)
@@ -87,6 +87,7 @@ Currently supported models:
check it out.
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
* [Crowdin](https://crowdin.com/) for supporting open-source projects.
## License
+1 -1
View File
@@ -1 +1 @@
4.0.3-rc0 40003000
5.0.1-rc0 50001000
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="eu.darken.capod.debug.l2cap.L2capPocActivity"
android:exported="true"
android:label="L2CAP PoC">
<intent-filter>
<action android:name="eu.darken.capod.L2CAP_POC" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,256 @@
package eu.darken.capod.debug.l2cap
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class L2capPocActivity : ComponentActivity() {
companion object {
private const val TAG = "L2capPoc"
private const val PSM = 0x1001
private val HANDSHAKE = byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
private val ANC_OFF = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x01, 0x00, 0x00, 0x00
)
private val ANC_ON = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x02, 0x00, 0x00, 0x00
)
private val TRANSPARENCY = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x03, 0x00, 0x00, 0x00
)
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) Log.d(TAG, "BLUETOOTH_CONNECT granted")
else Log.w(TAG, "BLUETOOTH_CONNECT denied")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED
) {
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
PocScreen()
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@SuppressLint("MissingPermission")
@Composable
private fun PocScreen() {
val btManager = remember { getSystemService(BluetoothManager::class.java) }
val adapter: BluetoothAdapter? = remember { btManager?.adapter }
val logEntries = remember { mutableStateListOf<String>() }
val logListState = rememberLazyListState()
var selectedDevice by remember { mutableStateOf<BluetoothDevice?>(null) }
var socket by remember { mutableStateOf<BluetoothSocket?>(null) }
var outputStream by remember { mutableStateOf<OutputStream?>(null) }
var connected by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var readerJob by remember { mutableStateOf<Job?>(null) }
val timeFmt = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.US) }
fun log(msg: String) {
val line = "${timeFmt.format(Date())} $msg"
Log.d(TAG, msg)
logEntries.add(line)
}
fun ByteArray.hex(): String = joinToString(" ") { "%02X".format(it) }
LaunchedEffect(logEntries.size) {
if (logEntries.isNotEmpty()) logListState.animateScrollToItem(logEntries.size - 1)
}
fun startReader(input: InputStream) {
readerJob = scope.launch(Dispatchers.IO) {
val buf = ByteArray(1024)
try {
while (isActive) {
val len = input.read(buf)
if (len == -1) {
launch(Dispatchers.Main) { log("<<< Stream closed by remote") }
break
}
val data = buf.copyOf(len)
launch(Dispatchers.Main) { log("<<< [${data.size}] ${data.hex()}") }
}
} catch (e: Exception) {
launch(Dispatchers.Main) { log("<<< Read error: ${e.message}") }
}
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("L2CAP PoC — AAP Connection", style = MaterialTheme.typography.titleMedium)
if (adapter == null) {
Text("No Bluetooth adapter found", color = MaterialTheme.colorScheme.error)
return@Column
}
// Bonded devices
if (!connected) {
Text("Bonded Devices:", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
val bonded = remember(adapter) {
try { adapter.bondedDevices?.toList() ?: emptyList() }
catch (_: SecurityException) { emptyList() }
}
bonded.forEach { device ->
val name = try { device.name ?: "Unknown" } catch (_: SecurityException) { "Unknown" }
val isSelected = selectedDevice == device
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { selectedDevice = device },
colors = if (isSelected) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else CardDefaults.cardColors()
) {
Row(modifier = Modifier.padding(8.dp)) {
Text("$name ${device.address}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
// Connection controls
FlowRow(modifier = Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
val dev = selectedDevice ?: return@Button
log("=== Connecting to ${dev.address} ===")
scope.launch(Dispatchers.IO) {
try {
val sock = L2capSocketFactory().createSocket(dev, PSM)
withContext(Dispatchers.Main) { log("Socket created, connecting...") }
sock.connect()
withContext(Dispatchers.Main) {
log("Connected!")
socket = sock
outputStream = sock.outputStream
connected = true
}
startReader(sock.inputStream)
withContext(Dispatchers.Main) { log(">>> Handshake [${HANDSHAKE.size}] ${HANDSHAKE.hex()}") }
sock.outputStream.write(HANDSHAKE)
sock.outputStream.flush()
} catch (e: Exception) {
withContext(Dispatchers.Main) { log("Failed: ${e::class.simpleName}: ${e.message}") }
}
}
},
enabled = selectedDevice != null && !connected
) { Text("Connect") }
Button(
onClick = {
log("=== Disconnecting ===")
readerJob?.cancel()
readerJob = null
try { socket?.close() } catch (_: Exception) {}
socket = null
outputStream = null
connected = false
},
enabled = connected
) { Text("Disconnect") }
}
// AAP command buttons
if (connected) {
FlowRow(modifier = Modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
fun sendCommand(name: String, cmd: ByteArray) {
scope.launch(Dispatchers.IO) {
try {
withContext(Dispatchers.Main) { log(">>> $name [${cmd.size}] ${cmd.hex()}") }
outputStream?.write(cmd)
outputStream?.flush()
} catch (e: Exception) {
withContext(Dispatchers.Main) { log(">>> Send error: ${e.message}") }
}
}
}
Button(onClick = { sendCommand("ANC Off", ANC_OFF) }) { Text("ANC Off") }
Button(onClick = { sendCommand("ANC On", ANC_ON) }) { Text("ANC On") }
Button(onClick = { sendCommand("Transparency", TRANSPARENCY) }) { Text("Transparency") }
Button(onClick = { sendCommand("Handshake", HANDSHAKE) }) { Text("Handshake") }
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
LazyColumn(state = logListState, modifier = Modifier.weight(1f)) {
items(logEntries) { entry ->
Text(text = entry, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 14.sp)
}
}
}
}
}
@@ -11,17 +11,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.toArgb
import eu.darken.capod.R
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetTheme
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme
@@ -29,7 +22,12 @@ import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel
import eu.darken.capod.main.ui.settings.SettingsScreen
import eu.darken.capod.main.ui.settings.SettingsViewModel
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
import eu.darken.capod.main.ui.widget.WidgetRenderState
import eu.darken.capod.main.ui.widget.WidgetTheme
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.ui.DeviceManagerScreen
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationScreen
@@ -37,6 +35,7 @@ import eu.darken.capod.profiles.ui.creation.DeviceProfileCreationViewModel
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.ui.ReactionSettingsScreen
import eu.darken.capod.reaction.ui.ReactionSettingsViewModel
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
@@ -47,15 +46,15 @@ internal fun DashboardContent() = PreviewWrapper {
now = MOCK_NOW,
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.airPodsProMixed(),
MockPodDataProvider.airPodsMax(),
MockPodDataProvider.unknownDevice(),
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
),
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
showUnmatchedDevices = false,
@@ -73,9 +72,9 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
DeviceManagerScreen(
state = DeviceManagerViewModel.State(
profiles = listOf(
MockPodDataProvider.profile("Work AirPods", PodDevice.Model.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
MockPodDataProvider.profile("Gym Beats", PodDevice.Model.POWERBEATS_PRO),
MockPodDataProvider.profile("Work AirPods", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
MockPodDataProvider.profile("Gym Beats", PodModel.POWERBEATS_PRO),
),
),
onBack = {},
@@ -92,11 +91,11 @@ internal fun AddProfileContent() = PreviewWrapper {
name = "",
nameError = null,
selectedModel = null,
availableModels = PodDevice.Model.entries.filter { it != PodDevice.Model.UNKNOWN },
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
identityKey = null,
encryptionKey = null,
selectedDevice = null,
bondedDevices = emptyList(),
bondedDeviceItems = emptyList(),
minimumSignalQuality = 0.15f,
canSave = false,
),
@@ -158,8 +157,8 @@ internal fun ReactionSettingsContent() = PreviewWrapper {
@Composable
internal fun WidgetConfigurationContent() = PreviewWrapper {
val profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
)
WidgetConfigurationScreen(
state = WidgetConfigurationViewModel.State(
@@ -201,7 +200,7 @@ internal fun CasePopUpContent() {
contentAlignment = Alignment.Center,
) {
PopUpCard(
device = MockPodDataProvider.airPodsProMixed(),
device = MockPodDataProvider.dualPodMonitoredMixed(),
onClose = {},
modifier = Modifier.padding(horizontal = 32.dp),
)
@@ -5,14 +5,11 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -24,6 +21,8 @@ import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -36,6 +35,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -50,6 +50,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -111,24 +112,35 @@ fun UpgradeScreen(
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
)
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { paddingValues ->
Box(modifier = Modifier
.windowInsetsPadding(WindowInsets.systemBars)
.padding(paddingValues)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(paddingValues)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(16.dp))
Box(contentAlignment = Alignment.Center) {
Surface(
@@ -208,6 +220,14 @@ fun UpgradeScreen(
}
}
Text(
text = stringResource(R.string.upgrade_benefit_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Button(
@@ -238,18 +258,6 @@ fun UpgradeScreen(
Spacer(modifier = Modifier.height(24.dp))
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier.padding(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
+2 -2
View File
@@ -3,8 +3,8 @@
<string name="foss_upgrade_donate_label">Пожертвовать</string>
<string name="foss_upgrade_alreadydonated_label">Я уже пожертвовал</string>
<string name="foss_upgrade_no_money_label">Я потратил все свои деньги на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если Вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
<string name="upgrade_foss_sponsor_action">Поддержать разработку</string>
<string name="upgrade_foss_sponsor_subtitle">Без рекламы. Без слежки. Без привязки к Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Вернулись уже? Ваша поддержка помогает CAPod жить.</string>
<string name="upgrade_foss_sponsor_returned_early">Вернулись? Ваша поддержка помогает CAPod жить.</string>
</resources>
@@ -7,14 +7,11 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,6 +24,8 @@ import androidx.compose.material.icons.twotone.Favorite
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material.icons.twotone.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -37,8 +36,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -125,17 +126,33 @@ fun UpgradeScreen(
Benefit(Icons.TwoTone.BluetoothConnected, R.string.upgrade_benefit_autoconnect),
Benefit(Icons.AutoMirrored.TwoTone.Message, R.string.upgrade_benefit_popups),
Benefit(Icons.TwoTone.Widgets, R.string.upgrade_benefit_widgets),
Benefit(Icons.TwoTone.Tune, R.string.upgrade_benefit_device_settings),
Benefit(Icons.TwoTone.Headphones, R.string.upgrade_benefit_device_controls),
Benefit(Icons.TwoTone.Favorite, R.string.upgrade_benefit_support),
)
Box(modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars)) {
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
)
}
},
)
},
) { paddingValues ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(paddingValues)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(16.dp))
Box(contentAlignment = Alignment.Center) {
Surface(
@@ -215,6 +232,14 @@ fun UpgradeScreen(
}
}
Text(
text = stringResource(R.string.upgrade_benefit_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(24.dp))
if (state == null) {
@@ -231,17 +256,6 @@ fun UpgradeScreen(
Spacer(modifier = Modifier.height(24.dp))
}
IconButton(
onClick = onNavigateUp,
modifier = Modifier.padding(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
+4 -3
View File
@@ -12,7 +12,8 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.widget.WidgetManager
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CoroutineScope
@@ -27,7 +28,7 @@ import javax.inject.Inject
open class App : Application() {
@Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject @AppScope lateinit var appScope: CoroutineScope
@@ -42,7 +43,7 @@ open class App : Application() {
appScope.launch { widgetManager.refreshWidgets() }
podMonitor.devicesWithProfiles()
deviceMonitor.devicesWithProfiles()
.distinctUntilChanged()
.throttleLatest(1000)
.onEach {
@@ -45,7 +45,7 @@ class MediaControl @Inject constructor(
}
}
private suspend fun sendKey(keyCode: Int) {
internal suspend fun sendKey(keyCode: Int) {
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
val eventTime = SystemClock.uptimeMillis()
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
@@ -53,6 +53,24 @@ class MediaControl @Inject constructor(
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
}
fun adjustVolumeUp() {
log(TAG, INFO) { "adjustVolumeUp()" }
audioManager.adjustSuggestedStreamVolume(
AudioManager.ADJUST_RAISE,
AudioManager.USE_DEFAULT_STREAM_TYPE,
AudioManager.FLAG_SHOW_UI,
)
}
fun adjustVolumeDown() {
log(TAG, INFO) { "adjustVolumeDown()" }
audioManager.adjustSuggestedStreamVolume(
AudioManager.ADJUST_LOWER,
AudioManager.USE_DEFAULT_STREAM_TYPE,
AudioManager.FLAG_SHOW_UI,
)
}
companion object {
private val TAG = logTag("MediaControl")
}
@@ -22,7 +22,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
@@ -307,6 +307,37 @@ class BluetoothManager2 @Inject constructor(
private var _isNudgeAvailable: Boolean = true
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
/**
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
* `setAlias(String)` method because the public API 30+ variant requires `BLUETOOTH_PRIVILEGED`,
* which third-party apps cannot hold.
*
* Returns `true` on success, `false` if the call threw, was rejected, or returned `false`.
*
* Known failure mode on Android 12+: `SecurityException: does not have a CDM association with
* the Bluetooth Device`. The hidden method was moved behind a Companion Device Manager (CDM)
* permission check at the service layer — only apps that have explicitly requested and been
* granted a CDM association for this specific device can rename it. CAPod does not currently
* pursue a CDM association (that's a user-visible pairing flow), so setAlias is effectively
* unavailable on modern Android and callers should be prepared to surface a user-facing
* fallback when it returns false. See DeviceSettingsViewModel.setDeviceName.
*/
@android.annotation.SuppressLint("MissingPermission")
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
return try {
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
.apply { isAccessible = true }
val result = method.invoke(device.internal, alias) as? Boolean ?: false
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
result
} catch (e: Exception) {
val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e
log(TAG, WARN) { "setDeviceAlias(${device.address}, $alias) failed: $cause" }
false
}
}
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
@@ -315,10 +346,9 @@ class BluetoothManager2 @Inject constructor(
"connect", BluetoothDevice::class.java
).apply { isAccessible = true }
connectMethod.invoke(bluetoothProfile.proxy, device.internal)
log(TAG) { "Nudged connection to $device" }
true
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
accepted
} catch (e: Exception) {
val isSecurityException = e is SecurityException ||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
@@ -0,0 +1,71 @@
@file:Suppress("unused")
package eu.darken.capod.common.bluetooth.l2cap
import androidx.annotation.Keep
import java.lang.invoke.MethodType
/**
* Mirror classes matching ART's internal field layout for offset calculation.
* [sun.misc.Unsafe.objectFieldOffset] on these classes gives the same offsets as the real
* hidden fields, because the field types and order are identical.
*
* Based on LSPosed/AndroidHiddenApiBypass (Apache 2.0)
* https://github.com/LSPosed/AndroidHiddenApiBypass
*/
@Keep
object ArtMirror {
@Keep
open class AccessibleObject {
private val override: Boolean = false
}
@Keep
class Executable : AccessibleObject() {
private val declaringClass: Any? = null
private val declaringClassOfOverriddenMethod: Any? = null
private val parameters: Array<Any>? = null
@JvmField val artMethod: Long = 0
private val accessFlags: Int = 0
}
@Keep
class MethodHandle {
private val type: MethodType? = null
private val nominalType: MethodType? = null
private val cachedSpreadInvoker: MethodHandle? = null
private val handleKind: Int = 0
@JvmField val artFieldOrMethod: Long = 0
}
@Keep
class Class {
private val classLoader: ClassLoader? = null
private val componentType: kotlin.Any? = null
private val dexCache: kotlin.Any? = null
private val extData: kotlin.Any? = null
private val ifTable: Array<kotlin.Any>? = null
private val name: String? = null
private val superClass: kotlin.Any? = null
private val vtable: kotlin.Any? = null
@JvmField val iFields: Long = 0
@JvmField val methods: Long = 0
@JvmField val sFields: Long = 0
private val accessFlags: Int = 0
private val classFlags: Int = 0
private val classSize: Int = 0
private val clinitThreadId: Int = 0
private val dexClassDefIndex: Int = 0
@Volatile private var dexTypeIndex: Int = 0
private val numReferenceInstanceFields: Int = 0
private val numReferenceStaticFields: Int = 0
private val objectSize: Int = 0
private val objectSizeAllocFastPath: Int = 0
private val primitiveType: Int = 0
private val referenceInstanceOffsets: Int = 0
private val status: Int = 0
private val copiedMethodsOffset: Short = 0
private val virtualMethodsOffset: Short = 0
}
}
@@ -0,0 +1,132 @@
package eu.darken.capod.common.bluetooth.l2cap
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import java.lang.invoke.MethodHandles
import java.lang.invoke.MethodType
import java.lang.reflect.Method
/**
* Bypasses Android's hidden API restrictions by calling [dalvik.system.VMRuntime.setHiddenApiExemptions].
*
* Technique: Uses [sun.misc.Unsafe] to iterate ART's internal method array and swap a stub method's
* artMethod pointer to execute hidden methods. Mirror classes in [ArtMirror] provide field offsets
* without triggering hidden API checks.
*
* Based on LSPosed/AndroidHiddenApiBypass (Apache 2.0)
* https://github.com/LSPosed/AndroidHiddenApiBypass
*
* Copyright 2021 LSPosed
* Licensed under the Apache License, Version 2.0
*/
object HiddenApiBypass {
private val TAG = logTag("HiddenApiBypass")
private val exemptedPrefixes = mutableSetOf<String>()
@androidx.annotation.Keep
private object InvokeStub {
@JvmStatic
fun invoke(vararg args: Any?): Any? {
throw IllegalStateException("Stub — artMethod not swapped")
}
}
@Synchronized
fun setExemptions(vararg prefixes: String) {
// Merge with existing prefixes (VMRuntime.setHiddenApiExemptions replaces, not merges)
val newPrefixes = prefixes.filter { it !in exemptedPrefixes }
if (newPrefixes.isEmpty()) return
exemptedPrefixes.addAll(newPrefixes)
val allPrefixes = exemptedPrefixes.toTypedArray()
val unsafe = Class.forName("sun.misc.Unsafe").getDeclaredMethod("getUnsafe").invoke(null)
val unsafeClass = unsafe::class.java
val objectFieldOffset = unsafeClass.getMethod("objectFieldOffset", java.lang.reflect.Field::class.java)
val getLong = unsafeClass.getMethod("getLong", Any::class.java, Long::class.javaPrimitiveType)
val putLong = unsafeClass.getMethod("putLong", Any::class.java, Long::class.javaPrimitiveType, Long::class.javaPrimitiveType)
val getInt = unsafeClass.getMethod("getInt", Long::class.javaPrimitiveType)
// --- Offset calculation from mirror classes ---
val artMethodOff = objectFieldOffset.invoke(unsafe,
ArtMirror.Executable::class.java.getDeclaredField("artMethod")) as Long
val artFieldOrMethodOff = objectFieldOffset.invoke(unsafe,
ArtMirror.MethodHandle::class.java.getDeclaredField("artFieldOrMethod")) as Long
val methodsOff = objectFieldOffset.invoke(unsafe,
ArtMirror.Class::class.java.getDeclaredField("methods")) as Long
// --- Sanity check: verify artMethod offset matches the real accessible field ---
val realArtMethodOff = objectFieldOffset.invoke(unsafe,
java.lang.reflect.Executable::class.java.getDeclaredField("artMethod")) as Long
check(artMethodOff == realArtMethodOff) {
"ArtMirror.Executable.artMethod offset ($artMethodOff) != real offset ($realArtMethodOff). " +
"ART field layout has changed — mirror classes need updating."
}
// --- Calculate artMethodSize and bias from NeverCall ---
val mA = NeverCall::class.java.getDeclaredMethod("a").apply { isAccessible = true }
val mB = NeverCall::class.java.getDeclaredMethod("b").apply { isAccessible = true }
val mhA = MethodHandles.lookup().unreflect(mA)
val mhB = MethodHandles.lookup().unreflect(mB)
val aAddr = getLong.invoke(unsafe, mhA, artFieldOrMethodOff) as Long
val bAddr = getLong.invoke(unsafe, mhB, artFieldOrMethodOff) as Long
val artMethodSize = bAddr - aAddr
check(artMethodSize in 16..256) {
"artMethodSize=$artMethodSize is outside expected range [16, 256]. ART internals may have changed."
}
val ncMethods = getLong.invoke(unsafe, NeverCall::class.java, methodsOff) as Long
val artMethodBias = aAddr - ncMethods - artMethodSize
// --- Iterate VMRuntime methods ---
val vmRuntimeClass = Class.forName("dalvik.system.VMRuntime")
val vmMethods = getLong.invoke(unsafe, vmRuntimeClass, methodsOff) as Long
val numMethods = getInt.invoke(unsafe, vmMethods) as Int
check(numMethods in 1..10000) {
"VMRuntime numMethods=$numMethods is outside expected range. Pointer may be invalid."
}
val stubMethod: Method = InvokeStub::class.java.getDeclaredMethod("invoke", Array<Any?>::class.java)
stubMethod.isAccessible = true
val originalArtMethod = getLong.invoke(unsafe, stubMethod, artMethodOff) as Long
var runtime: Any? = null
var exemptionsSet = false
try {
for (i in 0 until numMethods) {
val methodPtr = vmMethods + i * artMethodSize + artMethodBias
putLong.invoke(unsafe, stubMethod, artMethodOff, methodPtr)
val name = stubMethod.name
val params = stubMethod.parameterTypes
if (name == "getRuntime" && params.isEmpty() && runtime == null) {
runtime = stubMethod.invoke(null)
}
if (name == "setHiddenApiExemptions" && params.size == 1 && params[0] == Array<String>::class.java) {
if (runtime == null) {
throw IllegalStateException("Found setHiddenApiExemptions before getRuntime")
}
stubMethod.invoke(runtime, allPrefixes as Any)
exemptionsSet = true
log(TAG, VERBOSE) { "setHiddenApiExemptions OK: ${allPrefixes.contentToString()}" }
}
if (runtime != null && exemptionsSet) break
}
} finally {
// Always restore the stub's original artMethod pointer
putLong.invoke(unsafe, stubMethod, artMethodOff, originalArtMethod)
}
if (runtime == null) throw RuntimeException("VMRuntime.getRuntime() not found in method array")
if (!exemptionsSet) throw RuntimeException("VMRuntime.setHiddenApiExemptions() not found in method array")
}
}
@@ -0,0 +1,102 @@
package eu.darken.capod.common.bluetooth.l2cap
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Creates BR/EDR L2CAP sockets for connecting to devices like AirPods.
*
* Tries the public [android.bluetooth.BluetoothSocketSettings] API first (API 37+).
* Falls back to the hidden `createInsecureL2capSocket` method via [HiddenApiBypass].
*/
@Singleton
@SuppressLint("MissingPermission")
class L2capSocketFactory @Inject constructor() {
private val TAG = logTag("L2capSocketFactory")
private val TYPE_L2CAP = 3
/**
* Creates an insecure BR/EDR L2CAP socket for the given [device] and [psm].
*
* The socket is created but not connected — call [BluetoothSocket.connect] separately.
*
* @throws IllegalArgumentException if [psm] is invalid
* @throws SecurityException if BLUETOOTH_CONNECT permission is missing
* @throws NoSuchMethodException if the hidden API is unavailable on this Android version
* @throws IOException if socket creation fails at the transport level
*/
fun createSocket(device: BluetoothDevice, psm: Int): BluetoothSocket {
require(psm > 0) { "Invalid PSM: $psm" }
// Strategy 1: Public API via BluetoothSocketSettings (API 37+)
tryPublicApi(device, psm)?.let { socket ->
log(TAG, VERBOSE) { "Socket created via public BluetoothSocketSettings API" }
return socket
}
// Strategy 2: Hidden API via reflection + bypass
log(TAG, VERBOSE) { "Public API unavailable, using hidden API bypass" }
return createViaHiddenApi(device, psm)
}
private fun tryPublicApi(device: BluetoothDevice, psm: Int): BluetoothSocket? {
return try {
val settingsClass = Class.forName("android.bluetooth.BluetoothSocketSettings")
val builderClass = Class.forName("android.bluetooth.BluetoothSocketSettings\$Builder")
val builder = builderClass.getDeclaredConstructor().newInstance()
builderClass.getMethod("setSocketType", Int::class.javaPrimitiveType).invoke(builder, TYPE_L2CAP)
builderClass.getMethod("setL2capPsm", Int::class.javaPrimitiveType).invoke(builder, psm)
builderClass.getMethod("setAuthenticationRequired", Boolean::class.javaPrimitiveType).invoke(builder, false)
builderClass.getMethod("setEncryptionRequired", Boolean::class.javaPrimitiveType).invoke(builder, false)
val settings = builderClass.getMethod("build").invoke(builder)
val createMethod = BluetoothDevice::class.java.getMethod("createUsingSocketSettings", settingsClass)
createMethod.invoke(device, settings) as BluetoothSocket
} catch (e: ClassNotFoundException) {
log(TAG, VERBOSE) { "BluetoothSocketSettings not available (pre-API 37)" }
null
} catch (e: Exception) {
val cause = if (e is java.lang.reflect.InvocationTargetException) e.cause ?: e else e
when (cause) {
is IllegalArgumentException -> {
log(TAG, VERBOSE) { "BluetoothSocketSettings does not support TYPE_L2CAP: ${cause.message}" }
null
}
is SecurityException -> throw cause
else -> {
log(TAG, VERBOSE) { "BluetoothSocketSettings failed: ${cause::class.simpleName}: ${cause.message}" }
null
}
}
}
}
private fun createViaHiddenApi(device: BluetoothDevice, psm: Int): BluetoothSocket {
HiddenApiBypass.setExemptions("Landroid/bluetooth/")
return try {
val method = BluetoothDevice::class.java.getDeclaredMethod(
"createInsecureL2capSocket",
Int::class.javaPrimitiveType
)
method.invoke(device, psm) as BluetoothSocket
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.cause ?: IOException("createInsecureL2capSocket failed", e)
} catch (e: NoSuchMethodException) {
throw e
} catch (e: SecurityException) {
throw e
} catch (e: ReflectiveOperationException) {
throw IOException("Failed to create L2CAP socket via hidden API", e)
}
}
}
@@ -0,0 +1,14 @@
package eu.darken.capod.common.bluetooth.l2cap;
import androidx.annotation.Keep;
/**
* Helper class for ART method size calculation.
* Methods a() and b() must be adjacent in ART's internal method array.
* Plain Java (not Kotlin) to avoid synthetic bridge methods from companion objects.
*/
@Keep
class NeverCall {
private static void a() { throw new RuntimeException(); }
private static void b() { throw new RuntimeException(); }
}
@@ -0,0 +1,191 @@
package eu.darken.capod.common.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
class ReorderableState<T>(
val lazyListState: LazyListState,
private val hapticFeedback: HapticFeedback,
private val key: (T) -> Any,
) {
private val _items = mutableStateListOf<T>()
val items: List<T> get() = _items
private var originalItems: List<T> = emptyList()
var draggedIndex: Int? by mutableStateOf(null)
private set
var dragOffsetY: Float by mutableFloatStateOf(0f)
private set
val isDragging: Boolean get() = draggedIndex != null
fun syncItems(newItems: List<T>) {
if (isDragging) return
_items.clear()
_items.addAll(newItems)
}
fun onDragStart(item: T) {
val itemKey = key(item)
val index = _items.indexOfFirst { key(it) == itemKey }
if (index == -1) return
originalItems = _items.toList()
draggedIndex = index
dragOffsetY = 0f
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
fun onDrag(delta: Float) {
val currentIndex = draggedIndex ?: return
dragOffsetY += delta
val layoutInfo = lazyListState.layoutInfo
val draggedItemInfo = layoutInfo.visibleItemsInfo
.firstOrNull { it.index == currentIndex } ?: return
val draggedCenter = draggedItemInfo.offset + draggedItemInfo.size / 2 + dragOffsetY.toInt()
val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { itemInfo ->
itemInfo.index != currentIndex &&
itemInfo.index < _items.size &&
draggedCenter in itemInfo.offset..(itemInfo.offset + itemInfo.size)
} ?: return
val targetIndex = targetInfo.index
dragOffsetY += (draggedItemInfo.offset - targetInfo.offset).toFloat()
_items.swap(currentIndex, targetIndex)
draggedIndex = targetIndex
}
fun onDragEnd(onReorder: (List<T>) -> Unit) {
val reordered = _items.toList()
draggedIndex = null
dragOffsetY = 0f
originalItems = emptyList()
onReorder(reordered)
}
fun onDragCancel() {
if (originalItems.isNotEmpty()) {
_items.clear()
_items.addAll(originalItems)
}
draggedIndex = null
dragOffsetY = 0f
originalItems = emptyList()
}
}
private fun <T> MutableList<T>.swap(from: Int, to: Int) {
val temp = this[from]
this[from] = this[to]
this[to] = temp
}
@Composable
fun <T> rememberReorderableState(
lazyListState: LazyListState,
items: List<T>,
key: (T) -> Any,
): ReorderableState<T> {
val hapticFeedback = LocalHapticFeedback.current
return remember(lazyListState) {
ReorderableState(lazyListState, hapticFeedback, key).also {
it.syncItems(items)
}
}
}
@Composable
fun <T> ReorderableAutoScroll(state: ReorderableState<T>) {
val scrollThresholdPx = with(LocalDensity.current) { 80.dp.toPx() }
LaunchedEffect(state.isDragging) {
if (!state.isDragging) return@LaunchedEffect
while (isActive) {
val layoutInfo = state.lazyListState.layoutInfo
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
val draggedIdx = state.draggedIndex ?: break
val draggedItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggedIdx }
if (draggedItem != null) {
val itemCenter = draggedItem.offset + draggedItem.size / 2 + state.dragOffsetY
val scrollSpeed = 8f
val delta = when {
itemCenter < viewportStart + scrollThresholdPx -> -scrollSpeed
itemCenter > viewportEnd - scrollThresholdPx -> scrollSpeed
else -> 0f
}
if (delta != 0f) state.lazyListState.scrollBy(delta)
}
delay(16)
}
}
}
@Composable
fun <T> reorderableItemModifier(
state: ReorderableState<T>,
index: Int,
item: T,
enabled: Boolean = true,
onReorder: (List<T>) -> Unit = {},
): Modifier {
val isDragging = state.draggedIndex == index
val dragOffsetY = if (isDragging) state.dragOffsetY else 0f
val currentItem by rememberUpdatedState(item)
val currentOnReorder by rememberUpdatedState(onReorder)
val surfaceColor = MaterialTheme.colorScheme.surface
return Modifier
.zIndex(if (isDragging) 1f else 0f)
.background(surfaceColor)
.then(
if (enabled) {
Modifier.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { state.onDragStart(currentItem) },
onDrag = { change, dragAmount ->
change.consume()
state.onDrag(dragAmount.y)
},
onDragEnd = { state.onDragEnd(currentOnReorder) },
onDragCancel = { state.onDragCancel() },
)
}
} else {
Modifier
}
)
.graphicsLayer {
if (isDragging) {
translationY = dragOffsetY
shadowElevation = 8.dp.toPx()
}
}
}
@@ -4,19 +4,24 @@ import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.unknown.UnknownDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cache.CachedDeviceState
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.profiles.core.DeviceProfile
import java.time.Instant
@@ -37,8 +42,8 @@ object MockPodDataProvider {
// --- Dual pod scenarios ---
fun airPodsProFullCharge(): DualPodDevice = MockDualPodDevice(
_model = PodDevice.Model.AIRPODS_PRO2,
fun airPodsProFullCharge(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "Work AirPods",
batteryLeftPodPercent = 1.0f,
batteryRightPodPercent = 1.0f,
@@ -46,10 +51,11 @@ object MockPodDataProvider {
leftPodIcon = R.drawable.device_airpods_pro2_left,
rightPodIcon = R.drawable.device_airpods_pro2_right,
_caseIcon = R.drawable.device_airpods_pro2_case,
_address = "AA:BB:CC:DD:EE:FF",
)
fun airPodsProMixed(): DualPodDevice = MockDualPodDevice(
_model = PodDevice.Model.AIRPODS_PRO2,
fun airPodsProMixed(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.80f,
batteryRightPodPercent = 0.45f,
@@ -60,8 +66,8 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_pro2_case,
)
fun airPodsProLowBattery(): DualPodDevice = MockDualPodDevice(
_model = PodDevice.Model.AIRPODS_PRO2,
fun airPodsProLowBattery(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.10f,
batteryRightPodPercent = 0.05f,
@@ -73,8 +79,8 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_pro2_case,
)
fun airPodsProInCase(): DualPodDevice = MockDualPodDevice(
_model = PodDevice.Model.AIRPODS_PRO2,
fun airPodsProInCase(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = null,
batteryRightPodPercent = null,
@@ -84,8 +90,8 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_pro2_case,
)
fun airPodsGen1Wearing(): DualPodDevice = MockDualPodDevice(
_model = PodDevice.Model.AIRPODS_GEN1,
fun airPodsGen1Wearing(): DualBlePodSnapshot = MockDualBlePodSnapshot(
_model = PodModel.AIRPODS_GEN1,
_label = "Old AirPods",
batteryLeftPodPercent = 0.70f,
batteryRightPodPercent = 0.65f,
@@ -98,8 +104,8 @@ object MockPodDataProvider {
_caseIcon = R.drawable.device_airpods_gen1_case,
)
fun powerBeatsPro(): DualPodDevice = MockDualPodDeviceNoCase(
_model = PodDevice.Model.POWERBEATS_PRO,
fun powerBeatsPro(): DualBlePodSnapshot = MockDualBlePodSnapshotNoCase(
_model = PodModel.POWERBEATS_PRO,
_label = "PowerBeats Pro",
batteryLeftPodPercent = 0.55f,
batteryRightPodPercent = 0.60f,
@@ -109,41 +115,159 @@ object MockPodDataProvider {
// --- Single pod scenarios ---
fun airPodsMax(): SinglePodDevice = MockSinglePodDevice(
_model = PodDevice.Model.AIRPODS_MAX,
fun airPodsMax(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
_model = PodModel.AIRPODS_MAX,
_label = "AirPods Max",
batteryHeadsetPercent = 0.85f,
_isBeingWorn = true,
)
fun airPodsMaxCharging(): SinglePodDevice = MockSingleApplePodDevice(
_model = PodDevice.Model.AIRPODS_MAX,
fun airPodsMaxCharging(): SingleBlePodSnapshot = MockSingleAppleBlePodSnapshot(
_model = PodModel.AIRPODS_MAX,
_label = "AirPods Max",
batteryHeadsetPercent = 0.40f,
_isHeadsetBeingCharged = true,
_isIRKMatch = true,
_hasPrivatePayload = true,
_address = "11:22:33:44:55:66",
)
fun beatsSolo3(): SinglePodDevice = MockSinglePodDevice(
_model = PodDevice.Model.BEATS_SOLO_3,
fun beatsSolo3(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
_model = PodModel.BEATS_SOLO_3,
_label = "Beats Solo 3",
batteryHeadsetPercent = 0.70f,
)
// --- Unknown device ---
fun unknownDevice(): PodDevice = UnknownDevice(
fun unknownDevice(): BlePodSnapshot = UnknownSnapshotBle(
scanResult = dummyScanResult(rssi = -70),
)
// --- Profile helpers ---
fun profile(label: String, model: PodDevice.Model): AppleDeviceProfile = AppleDeviceProfile(
fun profile(label: String, model: PodModel): AppleDeviceProfile = AppleDeviceProfile(
label = label,
model = model,
)
// --- Dual pod with Apple keys ---
fun airPodsProWithKeys(): DualBlePodSnapshot = MockDualAppleBlePodSnapshot(
_model = PodModel.AIRPODS_PRO2,
_label = "My AirPods Pro",
batteryLeftPodPercent = 0.80f,
batteryRightPodPercent = 0.45f,
_batteryCasePercent = 0.60f,
_isIRKMatch = true,
_hasPrivatePayload = true,
leftPodIcon = R.drawable.device_airpods_pro2_left,
rightPodIcon = R.drawable.device_airpods_pro2_right,
_caseIcon = R.drawable.device_airpods_pro2_case,
_address = "AA:BB:CC:DD:EE:FF",
)
// --- PodDevice wrappers ---
fun dualPodMonitored(): PodDevice = PodDevice(
profileId = "preview-dual",
ble = airPodsProFullCharge(),
aap = null,
)
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
profileId = "preview-dual-mixed",
ble = airPodsProMixed(),
aap = null,
)
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
profileId = "preview-dual-keys",
ble = airPodsProWithKeys(),
aap = null,
)
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap",
ble = airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
),
),
),
)
fun singlePodMonitored(): PodDevice = PodDevice(
profileId = "preview-single",
ble = airPodsMax(),
aap = null,
)
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-single-aap",
ble = airPodsMaxCharging(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
),
),
),
),
)
fun unknownMonitored(): PodDevice = PodDevice(
profileId = null,
ble = unknownDevice(),
aap = null,
)
/** Cached-only dual pod — device fully offline, showing last known state. */
fun dualPodCachedOnly(): PodDevice = PodDevice(
profileId = "preview-cached",
ble = null,
aap = null,
cached = CachedDeviceState(
profileId = "preview-cached",
model = PodModel.AIRPODS_PRO2,
address = "AA:BB:CC:DD:EE:FF",
left = CachedDeviceState.CachedBatterySlot(0.65f, MOCK_NOW.minusSeconds(3600)),
right = CachedDeviceState.CachedBatterySlot(0.50f, MOCK_NOW.minusSeconds(3600)),
case = CachedDeviceState.CachedBatterySlot(0.80f, MOCK_NOW.minusSeconds(3600)),
isLeftCharging = false,
isRightCharging = false,
isCaseCharging = false,
lastSeenAt = MOCK_NOW.minusSeconds(3600),
),
)
/** Cached-only single pod — device fully offline, showing last known state. */
fun singlePodCachedOnly(): PodDevice = PodDevice(
profileId = "preview-cached-single",
ble = null,
aap = null,
cached = CachedDeviceState(
profileId = "preview-cached-single",
model = PodModel.AIRPODS_MAX,
headset = CachedDeviceState.CachedBatterySlot(0.40f, MOCK_NOW.minusSeconds(7200)),
lastSeenAt = MOCK_NOW.minusSeconds(7200),
),
)
// --- UpgradeInfo ---
fun fossInfo(isPro: Boolean = false): UpgradeRepo.Info = MockUpgradeInfo(
@@ -164,8 +288,8 @@ private data class MockUpgradeInfo(
override val error: Throwable? = null,
) : UpgradeRepo.Info
private class MockDualPodDevice(
private val _model: PodDevice.Model,
private class MockDualBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryLeftPodPercent: Float?,
override val batteryRightPodPercent: Float?,
@@ -180,10 +304,11 @@ private class MockDualPodDevice(
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
private val _address: String? = null,
rssi: Int = -50,
) : DualPodDevice, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: PodDevice.Id = PodDevice.Id()
override val model: PodDevice.Model = _model
) : DualBlePodSnapshot, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
@@ -191,8 +316,8 @@ private class MockDualPodDevice(
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.75f
override val iconRes: Int = _model.iconRes
override val meta: PodDevice.Meta = object : PodDevice.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model, address = _address)
}
override fun getLabel(context: Context): String = _model.label
@@ -215,17 +340,17 @@ private class MockDualPodDevice(
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
}
private class MockDualPodDeviceNoCase(
private val _model: PodDevice.Model,
private class MockDualBlePodSnapshotNoCase(
private val _model: PodModel,
private val _label: String,
override val batteryLeftPodPercent: Float?,
override val batteryRightPodPercent: Float?,
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
rssi: Int = -50,
) : DualPodDevice, HasChargeDetectionDual, HasEarDetectionDual {
override val identifier: PodDevice.Id = PodDevice.Id()
override val model: PodDevice.Model = _model
) : DualBlePodSnapshot, HasChargeDetectionDual, HasEarDetectionDual {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
@@ -233,7 +358,7 @@ private class MockDualPodDeviceNoCase(
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.70f
override val iconRes: Int = _model.iconRes
override val meta: PodDevice.Meta = object : PodDevice.Meta {
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
}
@@ -248,16 +373,16 @@ private class MockDualPodDeviceNoCase(
override val isRightPodInEar: Boolean = false
}
private class MockSinglePodDevice(
private val _model: PodDevice.Model,
private class MockSingleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryHeadsetPercent: Float?,
private val _isHeadsetBeingCharged: Boolean = false,
private val _isBeingWorn: Boolean = false,
rssi: Int = -50,
) : SinglePodDevice, HasChargeDetection, HasEarDetection {
override val identifier: PodDevice.Id = PodDevice.Id()
override val model: PodDevice.Model = _model
) : SingleBlePodSnapshot, HasChargeDetection, HasEarDetection {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
@@ -265,7 +390,7 @@ private class MockSinglePodDevice(
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.80f
override val iconRes: Int = _model.iconRes
override val meta: PodDevice.Meta = object : PodDevice.Meta {
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
}
@@ -279,18 +404,19 @@ private class MockSinglePodDevice(
}
@Suppress("PropertyName")
private class MockSingleApplePodDevice(
private val _model: PodDevice.Model,
private class MockSingleAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryHeadsetPercent: Float?,
private val _isHeadsetBeingCharged: Boolean = false,
private val _isBeingWorn: Boolean = false,
private val _isIRKMatch: Boolean = false,
private val _hasPrivatePayload: Boolean = false,
private val _address: String? = null,
rssi: Int = -50,
) : SinglePodDevice, ApplePods, HasChargeDetection, HasEarDetection {
override val identifier: PodDevice.Id = PodDevice.Id()
override val model: PodDevice.Model = _model
) : SingleBlePodSnapshot, ApplePods, HasChargeDetection, HasEarDetection {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
@@ -300,7 +426,7 @@ private class MockSingleApplePodDevice(
override val iconRes: Int = _model.iconRes
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
isIRKMatch = _isIRKMatch,
profile = AppleDeviceProfile(label = _label, model = _model),
profile = AppleDeviceProfile(label = _label, model = _model, address = _address),
)
override val payload: ProximityPayload = ProximityPayload(
public = ProximityPayload.Public(UByteArray(9)),
@@ -315,3 +441,63 @@ private class MockSingleApplePodDevice(
// HasEarDetection
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
private class MockDualAppleBlePodSnapshot(
private val _model: PodModel,
private val _label: String,
override val batteryLeftPodPercent: Float?,
override val batteryRightPodPercent: Float?,
private val _batteryCasePercent: Float?,
private val _isCaseCharging: Boolean = false,
private val _isLeftPodCharging: Boolean = false,
private val _isRightPodCharging: Boolean = false,
private val _isLeftPodInEar: Boolean = false,
private val _isRightPodInEar: Boolean = false,
private val _isLeftPodMicrophone: Boolean = false,
private val _isRightPodMicrophone: Boolean = false,
private val _isIRKMatch: Boolean = false,
private val _hasPrivatePayload: Boolean = false,
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
private val _address: String? = null,
rssi: Int = -50,
) : DualBlePodSnapshot, ApplePods, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
override val model: PodModel = _model
override val seenLastAt: Instant = MOCK_NOW
override val seenFirstAt: Instant = MOCK_NOW
override val seenCounter: Int = 5
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.75f
override val iconRes: Int = _model.iconRes
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
isIRKMatch = _isIRKMatch,
profile = AppleDeviceProfile(label = _label, model = _model, address = _address),
)
override val payload: ProximityPayload = ProximityPayload(
public = ProximityPayload.Public(UByteArray(9)),
private = if (_hasPrivatePayload) ProximityPayload.Private(UByteArray(8)) else null,
)
override fun getLabel(context: Context): String = _model.label
// HasCase
override val batteryCasePercent: Float? = _batteryCasePercent
override val isCaseCharging: Boolean = _isCaseCharging
override val caseIcon: Int = _caseIcon
// HasChargeDetectionDual
override val isLeftPodCharging: Boolean = _isLeftPodCharging
override val isRightPodCharging: Boolean = _isRightPodCharging
// HasEarDetectionDual
override val isLeftPodInEar: Boolean = _isLeftPodInEar
override val isRightPodInEar: Boolean = _isRightPodInEar
// HasDualMicrophone
override val isLeftPodMicrophone: Boolean = _isLeftPodMicrophone
override val isRightPodMicrophone: Boolean = _isRightPodMicrophone
}
@@ -33,4 +33,5 @@ class AndroidModule {
@Singleton
fun audioManager(context: Context): AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
@@ -21,6 +21,12 @@ object Nav {
@Serializable
data object Upgrade : Main
@Serializable
data class DeviceSettings(val address: String) : Main
@Serializable
data object StemActionConfig : Main
}
sealed interface Settings : NavigationDestination {
@@ -58,18 +58,26 @@ fun SettingsBaseItem(
Color.Unspecified
}
val iconModifier = if (subtitle != null) {
Modifier
.align(Alignment.Top)
.padding(top = 2.dp)
.size(iconSize)
} else {
Modifier.size(iconSize)
}
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(iconSize),
modifier = iconModifier,
tint = tint,
)
} else if (iconPainter != null) {
Icon(
painter = iconPainter,
contentDescription = null,
modifier = Modifier.size(iconSize),
modifier = iconModifier,
tint = tint,
)
}
@@ -1,8 +1,11 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
@@ -26,6 +29,7 @@ fun SettingsSliderItem(
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
enabled: Boolean = true,
onValueChangeFinished: (() -> Unit)? = null,
valueLabel: ((Float) -> String)? = null,
) {
Column(modifier = modifier.fillMaxWidth()) {
@@ -35,28 +39,33 @@ fun SettingsSliderItem(
subtitle = subtitle,
onClick = {},
enabled = enabled,
trailingContent = if (valueLabel != null) {
{
Text(
text = valueLabel(value),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(start = 16.dp)
)
}
} else null,
)
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
enabled = enabled,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 56.dp)
.padding(bottom = 8.dp),
)
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Slider(
value = value,
onValueChange = onValueChange,
onValueChangeFinished = onValueChangeFinished,
valueRange = valueRange,
steps = steps,
enabled = enabled,
modifier = Modifier.weight(1f),
)
if (valueLabel != null) {
Text(
text = valueLabel(value),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
modifier = Modifier.width(42.dp),
)
}
}
}
}
@@ -16,9 +16,9 @@ import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.json.Json
import javax.inject.Inject
@@ -54,7 +54,8 @@ class GeneralSettings @Inject constructor(
writer = { value -> value },
)
val oldMainDeviceModel = dataStore.createValue("core.maindevice.model", PodDevice.Model.UNKNOWN, json, onErrorFallbackToDefault = true)
val oldMainDeviceModel =
dataStore.createValue("core.maindevice.model", PodModel.UNKNOWN, json, onErrorFallbackToDefault = true)
val oldMainDeviceIdentityKey = dataStore.createValue<IdentityResolvingKey?>(
key = "core.maindevice.identitykey",
@@ -0,0 +1,28 @@
package eu.darken.capod.main.ui.devicesettings
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class DeviceSettingsNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.DeviceSettings> { key ->
DeviceSettingsScreenHost(address = key.address)
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: DeviceSettingsNavigation): NavigationEntry
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
package eu.darken.capod.main.ui.devicesettings
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.isActive
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class DeviceSettingsViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val deviceMonitor: DeviceMonitor,
private val aapManager: AapConnectionManager,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
) : ViewModel4(dispatcherProvider) {
private val targetAddress = MutableStateFlow<BluetoothAddress?>(null)
private var initialized = false
fun initialize(address: BluetoothAddress) {
if (initialized && targetAddress.value == address) return
initialized = true
targetAddress.value = address
}
private val updateTicker = channelFlow<Unit> {
while (isActive) {
trySend(Unit)
delay(3000)
}
}
private val isForceConnecting = MutableStateFlow(false)
sealed interface Event {
data object OpenBluetoothSettings : Event
data class SendFailed(val command: AapCommand, val message: String?) : Event
data object SystemRenameUnavailable : Event
}
val events = SingleEventFlow<Event>()
val state = targetAddress.flatMapLatest { address ->
if (address == null) return@flatMapLatest flowOf(State(device = null))
combine(
updateTicker,
deviceMonitor.devices,
upgradeRepo.upgradeInfo,
isForceConnecting,
) { _, devices, upgrade, forcing ->
State(
device = devices.firstOrNull { it.address == address },
now = Instant.now(),
isPro = upgrade.isPro,
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
isForceConnecting = forcing,
)
}
}.asLiveState()
data class State(
val device: PodDevice?,
val now: Instant = Instant.now(),
val isPro: Boolean = false,
val isNudgeAvailable: Boolean = true,
val isForceConnecting: Boolean = false,
)
fun forceConnect() = launch {
if (!isForceConnecting.compareAndSet(expect = false, update = true)) {
log(TAG) { "forceConnect already in progress" }
return@launch
}
try {
val address = targetAddress.value ?: run {
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
val bonded = try {
bluetoothManager.bondedDevices().first().firstOrNull { it.address == address }
} catch (e: Exception) {
log(TAG, WARN) { "bondedDevices() failed: ${e.message}" }
null
}
if (bonded == null) {
log(TAG, WARN) { "No bonded device for $address — opening Bluetooth settings" }
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
if (!bluetoothManager.isNudgeAvailable) {
events.tryEmit(Event.OpenBluetoothSettings)
return@launch
}
val accepted = try {
bluetoothManager.nudgeConnection(bonded)
} catch (e: Exception) {
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
false
}
log(TAG) { "nudgeConnection($bonded) accepted=$accepted" }
if (!accepted) {
events.tryEmit(Event.OpenBluetoothSettings)
}
// On accepted=true, AapAutoConnect.initialConnect() will pick up the new
// connectedDevices entry and trigger the AAP handshake. The card disappears
// when device.isAapConnected becomes true.
} finally {
isForceConnecting.value = false
}
}
private suspend fun sendInternal(command: AapCommand) {
val address = targetAddress.value ?: return
try {
aapManager.sendCommand(address, command)
log(TAG) { "Sent $command to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "Failed to send $command: ${e.message}" }
// SingleEventFlow is backed by a BUFFERED Channel — use the suspending emit to avoid
// dropping the event under momentary backpressure.
events.emit(Event.SendFailed(command, e.message))
}
}
private fun send(command: AapCommand) {
launch { sendInternal(command) }
}
private fun sendProGated(command: AapCommand) = launch {
if (upgradeRepo.isPro()) {
sendInternal(command)
} else {
navTo(Nav.Main.Upgrade)
}
}
fun setAncMode(mode: AapSetting.AncMode.Value) = send(AapCommand.SetAncMode(mode))
fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled))
fun setNcWithOneAirPod(enabled: Boolean) = send(AapCommand.SetNcWithOneAirPod(enabled))
fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled))
fun setToneVolume(level: Int) = send(AapCommand.SetToneVolume(level))
fun setAdaptiveAudioNoise(level: Int) = send(AapCommand.SetAdaptiveAudioNoise(level))
fun setPressSpeed(value: AapSetting.PressSpeed.Value) = send(AapCommand.SetPressSpeed(value))
fun setPressHoldDuration(value: AapSetting.PressHoldDuration.Value) = send(AapCommand.SetPressHoldDuration(value))
fun setVolumeSwipe(enabled: Boolean) = send(AapCommand.SetVolumeSwipe(enabled))
fun setVolumeSwipeLength(value: AapSetting.VolumeSwipeLength.Value) = send(AapCommand.SetVolumeSwipeLength(value))
fun setEndCallMuteMic(
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
endCall: AapSetting.EndCallMuteMic.EndCallMode,
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall))
fun setMicrophoneMode(mode: AapSetting.MicrophoneMode.Mode) = send(AapCommand.SetMicrophoneMode(mode))
fun setEarDetectionEnabled(enabled: Boolean) = send(AapCommand.SetEarDetectionEnabled(enabled))
fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask))
fun setAllowOffOption(enabled: Boolean) = sendProGated(AapCommand.SetAllowOffOption(enabled))
fun setSleepDetection(enabled: Boolean) = sendProGated(AapCommand.SetSleepDetection(enabled))
fun setDeviceName(name: String) = launch {
val address = targetAddress.value ?: return@launch
sendInternal(AapCommand.SetDeviceName(name))
// Also try to update the Android-local bond alias so the new name shows in Android's
// Bluetooth settings too. The AAP rename only changes what the AirPods themselves report;
// Android's system display reads from the bond database and needs a separate update.
val bonded = try {
bluetoothManager.bondedDevices().first().firstOrNull { it.address == address }
} catch (e: Exception) {
log(TAG, WARN) { "bondedDevices() failed while renaming: ${e.message}" }
null
}
val aliasOk = bonded?.let { bluetoothManager.setDeviceAlias(it, name) } ?: false
if (!aliasOk) {
log(TAG, WARN) { "System bond alias rename failed for $address — user must rename in system settings or re-pair" }
events.emit(Event.SystemRenameUnavailable)
}
}
fun navToStemConfig() = launch {
if (upgradeRepo.isPro()) {
navTo(Nav.Main.StemActionConfig)
} else {
navTo(Nav.Main.Upgrade)
}
}
companion object {
private val TAG = logTag("DeviceSettings", "VM")
}
}
@@ -9,9 +9,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material.icons.twotone.Settings
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -24,10 +27,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material.icons.twotone.Settings
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -37,16 +36,17 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
@@ -54,9 +54,9 @@ import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
@Composable
@@ -129,6 +129,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onSettings = { vm.goToSettings() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
)
}
@@ -140,6 +142,8 @@ fun OverviewScreen(
onSettings: () -> Unit,
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
onDeviceSettings: (PodDevice) -> Unit = {},
) {
Scaffold(
topBar = {
@@ -218,13 +222,30 @@ fun OverviewScreen(
}
}
// 4. Profiled device cards
// 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) {
items(
items = state.profiledDevices,
key = { it.identifier.hashCode() },
items = state.visibleProfiledDevices,
key = { it.identifier?.toString() ?: it.hashCode() },
) { device ->
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
PodDeviceCard(
device = device,
showDebug = state.isDebugMode,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onDeviceSettings = { onDeviceSettings(device) },
)
}
// 4b. Upgrade card when additional devices are hidden
if (state.hiddenProfiledDeviceCount > 0) {
item(key = "device_limit_upgrade") {
DeviceLimitUpgradeCard(
hiddenCount = state.hiddenProfiledDeviceCount,
upgradeType = state.upgradeInfo.type,
onUpgrade = onUpgrade,
)
}
}
// 5. Monitoring active card
@@ -247,9 +268,15 @@ fun OverviewScreen(
if (state.showUnmatchedDevices) {
items(
items = state.unmatchedDevices,
key = { "unmatched_${it.identifier.hashCode()}" },
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
) { device ->
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
PodDeviceCard(
device = device,
showDebug = state.isDebugMode,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onDeviceSettings = { onDeviceSettings(device) },
)
}
}
}
@@ -259,11 +286,25 @@ fun OverviewScreen(
}
@Composable
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
when (device) {
is DualPodDevice -> DualPodsCard(device = device, showDebug = showDebug, now = now)
is SinglePodDevice -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
private fun PodDeviceCard(
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onDeviceSettings: (() -> Unit)? = null,
) {
when {
device.hasDualPods -> DualPodsCard(
device = device, showDebug = showDebug, now = now,
onAncModeChange = onAncModeChange,
onDeviceSettings = onDeviceSettings,
)
device.model != PodModel.UNKNOWN -> SinglePodsCard(
device = device, showDebug = showDebug, now = now,
onAncModeChange = onAncModeChange,
onDeviceSettings = onDeviceSettings,
)
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug)
}
}
@@ -275,15 +316,15 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
now = Instant.now(),
permissions = emptySet(),
devices = listOf(
MockPodDataProvider.airPodsProMixed(),
MockPodDataProvider.airPodsMax(),
MockPodDataProvider.unknownDevice(),
MockPodDataProvider.dualPodMonitoredMixed(),
MockPodDataProvider.singlePodMonitored(),
MockPodDataProvider.unknownMonitored(),
),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
),
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
@@ -306,7 +347,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
devices = emptyList(),
isDebugMode = false,
isBluetoothEnabled = true,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodDevice.Model.AIRPODS_PRO2)),
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
),
@@ -350,7 +391,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
devices = emptyList(),
isDebugMode = false,
isBluetoothEnabled = false,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodDevice.Model.AIRPODS_PRO2)),
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
showUnmatchedDevices = false,
),
@@ -3,12 +3,12 @@ package eu.darken.capod.main.ui.overview
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.SingleEventFlow
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.permissions.Permission
@@ -17,12 +17,14 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import java.time.Instant
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
@@ -33,20 +35,21 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@HiltViewModel
class OverviewViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val monitorControl: MonitorControl,
private val podMonitor: PodMonitor,
private val deviceMonitor: DeviceMonitor,
private val permissionTool: PermissionTool,
private val generalSettings: GeneralSettings,
debugSettings: DebugSettings,
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager,
) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>()
@@ -71,6 +74,7 @@ class OverviewViewModel @Inject constructor(
false
}
}
MonitorMode.ALWAYS -> true
}
if (shouldStart) {
@@ -92,7 +96,7 @@ class OverviewViewModel @Inject constructor(
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
}
podMonitor.devices
deviceMonitor.devices
}
.catch { errorEvents.emitBlocking(it) }
.throttleLatest(1000)
@@ -130,8 +134,11 @@ class OverviewViewModel @Inject constructor(
val showUnmatchedDevices: Boolean,
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
val profiledDevices: List<PodDevice> get() = devices.filter { it.profileId != null }
val visibleProfiledDevices: List<PodDevice>
get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT)
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
}
fun onPermissionResult(@Suppress("UNUSED_PARAMETER") granted: Boolean) {
@@ -146,6 +153,11 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Main.DeviceManager)
}
fun goToDeviceSettings(device: PodDevice) {
val address = device.address ?: return
navTo(Nav.Main.DeviceSettings(address))
}
fun onUpgrade() {
navTo(Nav.Main.Upgrade)
}
@@ -158,7 +170,20 @@ class OverviewViewModel @Inject constructor(
requestPermissionEvent.tryEmit(permission)
}
fun setAncMode(device: PodDevice, mode: AapSetting.AncMode.Value) {
val address = device.address ?: return
launch {
try {
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
log(TAG) { "ANC mode set to $mode for $address" }
} catch (e: Exception) {
log(TAG) { "Failed to set ANC mode: ${e.message}" }
}
}
}
companion object {
private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
}
}
@@ -0,0 +1,94 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.upgrade.UpgradeRepo
@Composable
fun DeviceLimitUpgradeCard(
hiddenCount: Int,
upgradeType: UpgradeRepo.Type,
onUpgrade: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = pluralStringResource(R.plurals.overview_more_devices_upgrade, hiddenCount, hiddenCount),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.upgrade_capod_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onUpgrade,
modifier = Modifier.align(Alignment.End),
) {
Icon(
imageVector = Icons.TwoTone.Stars,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(
when (upgradeType) {
UpgradeRepo.Type.FOSS -> R.string.general_donate_action
UpgradeRepo.Type.GPLAY -> R.string.general_upgrade_action
}
),
)
}
}
}
}
@Preview2
@Composable
private fun DeviceLimitUpgradeCardGplayPreview() = PreviewWrapper {
DeviceLimitUpgradeCard(
hiddenCount = 2,
upgradeType = UpgradeRepo.Type.GPLAY,
onUpgrade = {},
)
}
@Preview2
@Composable
private fun DeviceLimitUpgradeCardFossPreview() = PreviewWrapper {
DeviceLimitUpgradeCard(
hiddenCount = 1,
upgradeType = UpgradeRepo.Type.FOSS,
onUpgrade = {},
)
}
@@ -18,15 +18,15 @@ 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.outlined.Key
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.GridView
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -44,30 +45,24 @@ import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.HasPodStyle
import eu.darken.capod.pods.core.HasStateDetection
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.toBatteryFloat
import eu.darken.capod.pods.core.toBatteryOrNull
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted
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.LidState
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import java.time.Instant
@Composable
fun DualPodsCard(
device: DualPodDevice,
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onDeviceSettings: (() -> Unit)? = null,
) {
val context = LocalContext.current
@@ -89,73 +84,79 @@ fun DualPodsCard(
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp),
modifier = Modifier.size(44.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = device.meta.profile?.label ?: "?",
text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
SignalIndicator(
signalQuality = device.rssiQuality,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
if (device is ApplePods && device.meta.isIRKMatch) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (device.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
val deviceLabel = buildString {
append(device.getLabel(context))
if (device is HasPodStyle && showDebug) {
append(" (${device.podStyle.getColor(context)})")
val podStyle = device.ble as? HasPodStyle
if (podStyle != null && showDebug) {
append(" (${podStyle.podStyle.getColor(context)})")
}
if (device is DualApplePods && showDebug) {
append(" [${device.primaryPod.name}]")
val dualApple = device.ble as? DualApplePods
if (dualApple != null && showDebug) {
append(" [${dualApple.primaryPod.name}]")
}
}
Text(
text = deviceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = deviceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
DeviceConnectionBadge(
state = device.connectionState,
modifier = Modifier.padding(start = 6.dp),
)
}
}
SignalBadge(signalText = device.getSignalQuality(context))
}
Spacer(modifier = Modifier.height(4.dp))
// Timestamps
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (device.address != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
Surface(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -166,31 +167,31 @@ fun DualPodsCard(
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent.toBatteryFloat(),
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
batteryPercent = device.batteryLeft.toBatteryFloat(),
isCharging = device.isLeftPodCharging ?: false,
isInEar = device.isLeftInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
)
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent.toBatteryFloat(),
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
batteryPercent = device.batteryRight.toBatteryFloat(),
isCharging = device.isRightPodCharging ?: false,
isInEar = device.isRightInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
)
}
// Case row
if (device is HasCase) {
if (device.hasCase) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
@@ -199,13 +200,37 @@ fun DualPodsCard(
}
}
// Connection state
if (device is HasStateDetection) {
Spacer(modifier = Modifier.height(8.dp))
// Cached battery indicator
if (device.isBatteryCached && !device.isAapReady) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = device.state.getLabel(context),
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(8.dp))
val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E)
val cycleBits = mapOf(
AapSetting.AncMode.Value.OFF to 0x01,
AapSetting.AncMode.Value.ON to 0x02,
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
)
val visibleModes = ancMode.supported.filter { mode ->
val bit = cycleBits[mode] ?: return@filter true
(cycleMask and bit) != 0
}
AncModeSelector(
currentMode = ancMode.current,
supportedModes = visibleModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
@@ -250,12 +275,12 @@ private fun PodGauge(
// Ring with icon inside
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(80.dp),
modifier = Modifier.size(68.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(80.dp),
modifier = Modifier.size(68.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 6.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -266,7 +291,7 @@ private fun PodGauge(
if (clamped >= 0f) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(80.dp),
modifier = Modifier.size(68.dp),
color = ringColor,
strokeWidth = 6.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -314,7 +339,7 @@ private fun PodGauge(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CaseRow(
device: HasCase,
device: PodDevice,
) {
val context = LocalContext.current
@@ -331,13 +356,13 @@ private fun CaseRow(
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatBatteryPercent(context, device.batteryCasePercent),
text = formatBatteryPercent(context, device.batteryCase),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 8.dp),
)
BatteryCapsule(
percent = device.batteryCasePercent.toBatteryFloat(),
percent = device.batteryCase.toBatteryFloat(),
modifier = Modifier
.weight(1f)
.height(8.dp),
@@ -349,25 +374,23 @@ private fun CaseRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device.isCaseCharging) {
if (device.isCaseCharging == true) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
}
if (device is DualApplePods) {
val lidState = device.caseLidState
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
StatusChip(
icon = Icons.TwoTone.GridView,
label = when (lidState) {
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
else -> ""
},
)
}
val lidState = device.caseLidState
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
StatusChip(
icon = Icons.TwoTone.GridView,
label = when (lidState) {
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
else -> ""
},
)
}
}
}
@@ -375,42 +398,31 @@ private fun CaseRow(
@Preview2
@Composable
private fun DualPodsCardFullChargePreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.airPodsProFullCharge(), showDebug = false, now = Instant.now())
private fun DualPodsCardFullPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredWithAap(),
showDebug = false,
now = Instant.now(),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardMixedBatteryPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = false, now = Instant.now())
private fun DualPodsCardMinimalPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitored(),
showDebug = false,
now = Instant.now(),
)
}
@Preview2
@Composable
private fun DualPodsCardLowBatteryPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.airPodsProLowBattery(), showDebug = false, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardInCasePreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.airPodsProInCase(), showDebug = false, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardDebugPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.airPodsProMixed(), showDebug = true, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardNoCasePreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.powerBeatsPro(), showDebug = false, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardMicrophonePreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.airPodsGen1Wearing(), showDebug = false, now = Instant.now())
private fun DualPodsCardCachedPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodCachedOnly(),
showDebug = false,
now = Instant.now(),
)
}
@@ -1,9 +1,11 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,21 +22,42 @@ 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.outlined.Key
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Bluetooth
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.KeyboardVoice
import androidx.compose.material.icons.twotone.LinkOff
import androidx.compose.material.icons.twotone.Lock
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.monitor.core.BleKeyState
import eu.darken.capod.monitor.core.ConnectionState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
private val CapsuleShape = RoundedCornerShape(6.dp)
@@ -144,11 +167,89 @@ fun StatusChipRow(
}
}
@VisibleForTesting
internal enum class SignalLevel { DISCONNECTED, BARS_0, BARS_1, BARS_2, BARS_3, BARS_4 }
@VisibleForTesting
internal fun signalLevelOf(signalQuality: Float, isLive: Boolean): SignalLevel {
if (!isLive) return SignalLevel.DISCONNECTED
if (!signalQuality.isFinite()) return SignalLevel.BARS_0
val q = signalQuality.coerceIn(0f, 1f)
return when {
q >= 0.80f -> SignalLevel.BARS_4
q >= 0.60f -> SignalLevel.BARS_3
q >= 0.40f -> SignalLevel.BARS_2
q >= 0.20f -> SignalLevel.BARS_1
else -> SignalLevel.BARS_0
}
}
@Composable
fun SignalBadge(
signalText: String,
fun SignalIndicator(
signalQuality: Float,
isLive: Boolean,
modifier: Modifier = Modifier,
) {
val level = signalLevelOf(signalQuality, isLive)
val description = when (level) {
SignalLevel.DISCONNECTED -> stringResource(R.string.signal_indicator_disconnected_cd)
SignalLevel.BARS_0 -> stringResource(R.string.signal_indicator_bars_cd, 0)
SignalLevel.BARS_1 -> stringResource(R.string.signal_indicator_bars_cd, 1)
SignalLevel.BARS_2 -> stringResource(R.string.signal_indicator_bars_cd, 2)
SignalLevel.BARS_3 -> stringResource(R.string.signal_indicator_bars_cd, 3)
SignalLevel.BARS_4 -> stringResource(R.string.signal_indicator_bars_cd, 4)
}
val tint = MaterialTheme.colorScheme.onSurfaceVariant
val rootModifier = modifier
.size(12.dp)
.semantics(mergeDescendants = true) { contentDescription = description }
if (level == SignalLevel.DISCONNECTED) {
Icon(
imageVector = Icons.TwoTone.LinkOff,
contentDescription = null,
modifier = rootModifier,
tint = tint,
)
return
}
val activeBars = when (level) {
SignalLevel.BARS_0 -> 0
SignalLevel.BARS_1 -> 1
SignalLevel.BARS_2 -> 2
SignalLevel.BARS_3 -> 3
SignalLevel.BARS_4 -> 4
SignalLevel.DISCONNECTED -> 0 // unreachable; handled above
}
val inactiveColor = tint.copy(alpha = 0.3f)
Canvas(modifier = rootModifier) {
val barCount = 4
val gapWidth = size.width * 0.12f
val totalGap = gapWidth * (barCount - 1)
val barWidth = (size.width - totalGap) / barCount
val corner = CornerRadius(barWidth * 0.4f, barWidth * 0.4f)
for (i in 0 until barCount) {
val heightFraction = (i + 1).toFloat() / barCount
val barHeight = size.height * heightFraction
val x = i * (barWidth + gapWidth)
val y = size.height - barHeight
drawRoundRect(
color = if (i < activeBars) tint else inactiveColor,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight),
cornerRadius = corner,
)
}
}
}
@Composable
fun DeviceConnectionBadge(
state: ConnectionState,
modifier: Modifier = Modifier,
) {
if (!state.hasBleData && state.bleKeyState == BleKeyState.NONE && !state.isAapConnected) return
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
@@ -157,19 +258,40 @@ fun SignalBadge(
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = signalText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.hasBleData) {
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = stringResource(R.string.signal_badge_ble_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.bleKeyState != BleKeyState.NONE) {
Icon(
imageVector = Icons.Outlined.Key,
contentDescription = stringResource(R.string.signal_badge_key_irk_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) {
Icon(
imageVector = Icons.TwoTone.Lock,
contentDescription = stringResource(R.string.signal_badge_key_encrypted_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.isAapConnected) {
Icon(
imageVector = Icons.TwoTone.Bluetooth,
contentDescription = stringResource(R.string.signal_badge_aap_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -193,3 +315,149 @@ fun DebugSection(
)
}
}
@Composable
fun AncModeSelector(
currentMode: AapSetting.AncMode.Value,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
pendingMode: AapSetting.AncMode.Value? = null,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val displayMode = pendingMode ?: currentMode
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
supportedModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == displayMode,
onClick = { onModeSelected(mode) },
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
colors = if (pendingMode != null && mode == displayMode) {
SegmentedButtonDefaults.colors(
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
SegmentedButtonDefaults.colors()
},
label = {
Text(
text = when (mode) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
@Composable
fun ConversationAwarenessToggle(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.conversation_awareness_label),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
@Preview2
@Composable
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.IRK_AND_ENCRYPTED,
isAapConnected = true,
rssiQuality = 1f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeBleOnlyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.NONE,
isAapConnected = false,
rssiQuality = 0.7f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeBleIrkPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.IRK_ONLY,
isAapConnected = false,
rssiQuality = 0.5f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeAapOnlyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = false,
bleKeyState = BleKeyState.NONE,
isAapConnected = true,
rssiQuality = 0f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = false,
bleKeyState = BleKeyState.NONE,
isAapConnected = false,
rssiQuality = 0f,
),
)
}
@Preview2
@Composable
private fun SignalIndicatorPreviewsGroup() = PreviewWrapper {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SignalIndicator(signalQuality = 0.10f, isLive = true)
SignalIndicator(signalQuality = 0.35f, isLive = true)
SignalIndicator(signalQuality = 0.55f, isLive = true)
SignalIndicator(signalQuality = 0.75f, isLive = true)
SignalIndicator(signalQuality = 0.95f, isLive = true)
SignalIndicator(signalQuality = Float.NaN, isLive = true)
SignalIndicator(signalQuality = -0.3f, isLive = true)
SignalIndicator(signalQuality = 0.85f, isLive = false)
}
}
@@ -19,14 +19,14 @@ 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.outlined.Key
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material.icons.twotone.Tune
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -44,27 +45,24 @@ import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Duration
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Instant
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SinglePodsCard(
device: SinglePodDevice,
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onDeviceSettings: (() -> Unit)? = null,
) {
val context = LocalContext.current
val clamped = device.batteryHeadsetPercent?.coerceIn(0f, 1f)
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
@@ -96,64 +94,68 @@ fun SinglePodsCard(
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp),
modifier = Modifier.size(44.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = device.meta.profile?.label ?: "?",
text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
SignalIndicator(
signalQuality = device.rssiQuality,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
DeviceConnectionBadge(
state = device.connectionState,
modifier = Modifier.padding(start = 6.dp),
)
if (device is ApplePods && device.meta.isIRKMatch) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (device.payload.private != null) Icons.TwoTone.Key else Icons.Outlined.Key,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SignalBadge(signalText = device.getSignalQuality(context))
if (device.address != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
Icon(
imageVector = Icons.TwoTone.Tune,
contentDescription = stringResource(R.string.device_settings_open_cd),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
// Timestamps
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
// Central gauge
Surface(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier
@@ -163,12 +165,12 @@ fun SinglePodsCard(
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(100.dp),
modifier = Modifier.size(88.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(100.dp),
modifier = Modifier.size(88.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -179,7 +181,7 @@ fun SinglePodsCard(
if (clamped != null) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(100.dp),
modifier = Modifier.size(88.dp),
color = ringColor,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -189,9 +191,9 @@ fun SinglePodsCard(
// Battery text inside ring
Text(
text = formatBatteryPercent(context, device.batteryHeadsetPercent),
style = MaterialTheme.typography.headlineMedium,
color = if (device.batteryHeadsetPercent != null) {
text = formatBatteryPercent(context, device.batteryHeadset),
style = MaterialTheme.typography.headlineSmall,
color = if (device.batteryHeadset != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -207,13 +209,13 @@ fun SinglePodsCard(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device is HasChargeDetection && device.isHeadsetBeingCharged) {
if (device.isHeadsetBeingCharged == true) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
}
if (device is HasEarDetection && device.isBeingWorn) {
if (device.isBeingWorn == true) {
StatusChip(
icon = Icons.TwoTone.Hearing,
label = stringResource(R.string.pods_inear_label),
@@ -223,6 +225,40 @@ fun SinglePodsCard(
}
}
// Cached battery indicator
if (device.isBatteryCached && !device.isAapReady) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E)
val cycleBits = mapOf(
AapSetting.AncMode.Value.OFF to 0x01,
AapSetting.AncMode.Value.ON to 0x02,
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
)
val visibleModes = ancMode.supported.filter { mode ->
val bit = cycleBits[mode] ?: return@filter true
(cycleMask and bit) != 0
}
AncModeSelector(
currentMode = ancMode.current,
supportedModes = visibleModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
@@ -233,18 +269,31 @@ fun SinglePodsCard(
@Preview2
@Composable
private fun SinglePodsCardWearingPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.airPodsMax(), showDebug = false, now = Instant.now())
private fun SinglePodsCardFullPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitoredWithAap(),
showDebug = false,
now = Instant.now(),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun SinglePodsCardChargingPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.airPodsMaxCharging(), showDebug = false, now = Instant.now())
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
showDebug = false,
now = Instant.now(),
)
}
@Preview2
@Composable
private fun SinglePodsCardDebugPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.beatsSolo3(), showDebug = true, now = Instant.now())
private fun SinglePodsCardCachedPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodCachedOnly(),
showDebug = false,
now = Instant.now(),
)
}
@@ -6,12 +6,7 @@ import androidx.compose.foundation.layout.Spacer
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.material.icons.Icons
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -19,22 +14,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import java.time.Instant
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
@Composable
fun UnknownPodDeviceCard(
device: PodDevice,
showDebug: Boolean,
now: Instant,
) {
val context = LocalContext.current
@@ -46,7 +38,7 @@ fun UnknownPodDeviceCard(
Column(
modifier = Modifier.padding(16.dp),
) {
// Header with signal in top-right
// Header: label + signal indicator (sits right after the text)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
@@ -54,37 +46,24 @@ fun UnknownPodDeviceCard(
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
text = device.getSignalQuality(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
SignalIndicator(
signalQuality = device.rssiQuality,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when (device) {
is ApplePods -> stringResource(R.string.pods_unknown_contact_dev)
else -> stringResource(R.string.pods_unknown_label)
text = if (device.ble is ApplePods) {
stringResource(R.string.pods_unknown_contact_dev)
} else {
stringResource(R.string.pods_unknown_label)
},
style = MaterialTheme.typography.bodyMedium,
)
@@ -110,11 +89,11 @@ fun UnknownPodDeviceCard(
@Preview2
@Composable
private fun UnknownPodDeviceCardPreview() = PreviewWrapper {
UnknownPodDeviceCard(device = MockPodDataProvider.unknownDevice(), showDebug = false, now = Instant.now())
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = false)
}
@Preview2
@Composable
private fun UnknownPodDeviceCardDebugPreview() = PreviewWrapper {
UnknownPodDeviceCard(device = MockPodDataProvider.unknownDevice(), showDebug = true, now = Instant.now())
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = true)
}
@@ -86,6 +86,13 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/steam3d/MagicPods-Windows") },
)
}
item {
SettingsBaseItem(
title = "LibrePods project",
subtitle = "Thanks to Kavish Devar for all the reverse engineering work.",
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
)
}
item {
SettingsBaseItem(
title = "FuriousMAC",
@@ -124,6 +131,20 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://iconduck.com/sets/zwicon-icon-set") },
)
}
item {
SettingsBaseItem(
title = "MagicPodsCore",
subtitle = "Backend service for managing AirPods on Linux. (GPL-3.0)",
onClick = { onOpenUrl("https://github.com/steam3d/MagicPodsCore") },
)
}
item {
SettingsBaseItem(
title = "LibrePods",
subtitle = "Unlocking AirPods features on non-Apple devices. (GPL-3.0)",
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
)
}
item {
SettingsBaseItem(
title = "Kotlin",
@@ -0,0 +1,28 @@
package eu.darken.capod.main.ui.stemactions
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavigationEntry
import javax.inject.Inject
class StemActionConfigNavigation @Inject constructor() : NavigationEntry {
override fun EntryProviderScope<NavKey>.setup() {
entry<Nav.Main.StemActionConfig> {
StemActionConfigScreenHost()
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class Mod {
@Binds
@IntoSet
abstract fun bind(entry: StemActionConfigNavigation): NavigationEntry
}
}
@@ -0,0 +1,289 @@
package eu.darken.capod.main.ui.stemactions
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.R
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.reaction.core.stem.StemAction
@Composable
fun StemActionConfigScreenHost(
vm: StemActionConfigViewModel = hiltViewModel(),
) {
NavigationEventHandler(vm)
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val currentState = state ?: return
StemActionConfigScreen(
state = currentState,
onNavigateUp = { vm.navUp() },
onReset = { vm.resetAll() },
onLeftSingle = { vm.setLeftSingle(it) },
onLeftDouble = { vm.setLeftDouble(it) },
onLeftTriple = { vm.setLeftTriple(it) },
onLeftLong = { vm.setLeftLong(it) },
onRightSingle = { vm.setRightSingle(it) },
onRightDouble = { vm.setRightDouble(it) },
onRightTriple = { vm.setRightTriple(it) },
onRightLong = { vm.setRightLong(it) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StemActionConfigScreen(
state: StemActionConfigViewModel.State,
onNavigateUp: () -> Unit,
onReset: () -> Unit = {},
onLeftSingle: (StemAction) -> Unit = {},
onLeftDouble: (StemAction) -> Unit = {},
onLeftTriple: (StemAction) -> Unit = {},
onLeftLong: (StemAction) -> Unit = {},
onRightSingle: (StemAction) -> Unit = {},
onRightDouble: (StemAction) -> Unit = {},
onRightTriple: (StemAction) -> Unit = {},
onRightLong: (StemAction) -> Unit = {},
) {
var showResetDialog by remember { mutableStateOf(false) }
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
confirmButton = {
TextButton(onClick = {
onReset()
showResetDialog = false
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
text = { Text(stringResource(R.string.stem_actions_reset_confirm_message)) },
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.stem_actions_title)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { showResetDialog = true }) {
Icon(
imageVector = Icons.TwoTone.RestartAlt,
contentDescription = stringResource(R.string.stem_actions_reset_label),
)
}
},
)
},
) { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
item("description") {
Text(
text = stringResource(R.string.stem_actions_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
// Press type rows with left/right dropdowns
item("single_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_single_press))
}
item("single_row") {
StemActionRow(
leftAction = state.leftSingle,
rightAction = state.rightSingle,
onLeftChange = onLeftSingle,
onRightChange = onRightSingle,
)
}
item("double_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_double_press))
}
item("double_row") {
StemActionRow(
leftAction = state.leftDouble,
rightAction = state.rightDouble,
onLeftChange = onLeftDouble,
onRightChange = onRightDouble,
)
}
item("triple_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_triple_press))
}
item("triple_row") {
StemActionRow(
leftAction = state.leftTriple,
rightAction = state.rightTriple,
onLeftChange = onLeftTriple,
onRightChange = onRightTriple,
)
}
item("long_header") {
SettingsCategoryHeader(text = stringResource(R.string.stem_actions_long_press))
}
item("long_row") {
StemActionRow(
leftAction = state.leftLong,
rightAction = state.rightLong,
onLeftChange = onLeftLong,
onRightChange = onRightLong,
)
}
item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@Composable
private fun StemActionRow(
leftAction: StemAction,
rightAction: StemAction,
onLeftChange: (StemAction) -> Unit,
onRightChange: (StemAction) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.stem_actions_left),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StemActionDropdown(
selected = leftAction,
onSelected = onLeftChange,
otherSideAction = rightAction,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.stem_actions_right),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StemActionDropdown(
selected = rightAction,
onSelected = onRightChange,
otherSideAction = leftAction,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StemActionDropdown(
selected: StemAction,
onSelected: (StemAction) -> Unit,
otherSideAction: StemAction,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val options = if (otherSideAction == StemAction.NONE) {
StemAction.entries.filter { it != StemAction.NO_ACTION }
} else {
StemAction.entries.toList()
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier,
) {
OutlinedTextField(
value = selected.label(),
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
for (action in options) {
DropdownMenuItem(
text = { Text(action.label()) },
onClick = {
onSelected(action)
expanded = false
},
)
}
}
}
}
@Composable
private fun StemAction.label(): String = when (this) {
StemAction.NONE -> stringResource(R.string.stem_action_none)
StemAction.NO_ACTION -> stringResource(R.string.stem_action_no_action)
StemAction.PLAY_PAUSE -> stringResource(R.string.stem_action_play_pause)
StemAction.NEXT_TRACK -> stringResource(R.string.stem_action_next_track)
StemAction.PREVIOUS_TRACK -> stringResource(R.string.stem_action_previous_track)
StemAction.VOLUME_UP -> stringResource(R.string.stem_action_volume_up)
StemAction.VOLUME_DOWN -> stringResource(R.string.stem_action_volume_down)
}
@@ -0,0 +1,107 @@
package eu.darken.capod.main.ui.stemactions
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import javax.inject.Inject
@HiltViewModel
class StemActionConfigViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider,
private val stemActionSettings: StemActionSettings,
) : ViewModel4(dispatcherProvider) {
val state = combine(
stemActionSettings.leftSingle.flow,
stemActionSettings.leftDouble.flow,
stemActionSettings.leftTriple.flow,
stemActionSettings.leftLong.flow,
stemActionSettings.rightSingle.flow,
stemActionSettings.rightDouble.flow,
stemActionSettings.rightTriple.flow,
stemActionSettings.rightLong.flow,
) { values ->
State(
leftSingle = values[0],
leftDouble = values[1],
leftTriple = values[2],
leftLong = values[3],
rightSingle = values[4],
rightDouble = values[5],
rightTriple = values[6],
rightLong = values[7],
)
}.asLiveState()
data class State(
val leftSingle: StemAction = StemAction.NONE,
val leftDouble: StemAction = StemAction.NONE,
val leftTriple: StemAction = StemAction.NONE,
val leftLong: StemAction = StemAction.NONE,
val rightSingle: StemAction = StemAction.NONE,
val rightDouble: StemAction = StemAction.NONE,
val rightTriple: StemAction = StemAction.NONE,
val rightLong: StemAction = StemAction.NONE,
)
fun setLeftSingle(action: StemAction) = launch {
stemActionSettings.leftSingle.update { action }
applyCrossSideEffect(action, stemActionSettings.rightSingle)
}
fun setLeftDouble(action: StemAction) = launch {
stemActionSettings.leftDouble.update { action }
applyCrossSideEffect(action, stemActionSettings.rightDouble)
}
fun setLeftTriple(action: StemAction) = launch {
stemActionSettings.leftTriple.update { action }
applyCrossSideEffect(action, stemActionSettings.rightTriple)
}
fun setLeftLong(action: StemAction) = launch {
stemActionSettings.leftLong.update { action }
applyCrossSideEffect(action, stemActionSettings.rightLong)
}
fun setRightSingle(action: StemAction) = launch {
stemActionSettings.rightSingle.update { action }
applyCrossSideEffect(action, stemActionSettings.leftSingle)
}
fun setRightDouble(action: StemAction) = launch {
stemActionSettings.rightDouble.update { action }
applyCrossSideEffect(action, stemActionSettings.leftDouble)
}
fun setRightTriple(action: StemAction) = launch {
stemActionSettings.rightTriple.update { action }
applyCrossSideEffect(action, stemActionSettings.leftTriple)
}
fun setRightLong(action: StemAction) = launch {
stemActionSettings.rightLong.update { action }
applyCrossSideEffect(action, stemActionSettings.leftLong)
}
private suspend fun applyCrossSideEffect(
selected: StemAction,
otherSide: eu.darken.capod.common.datastore.DataStoreValue<StemAction>,
) {
when (selected) {
StemAction.NONE -> otherSide.update { StemAction.NONE }
StemAction.NO_ACTION -> {} // No side-effect
else -> {
if (otherSide.flow.first() == StemAction.NONE) {
otherSide.update { StemAction.NO_ACTION }
}
}
}
}
fun resetAll() = launch { stemActionSettings.resetAll() }
}
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.annotation.Keep
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.glance.GlanceId
@@ -10,7 +11,6 @@ import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import androidx.annotation.Keep
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -22,8 +22,8 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.common.upgrade.isPro
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.profiles.core.DeviceProfilesRepo
class BatteryGlanceWidget : GlanceAppWidget() {
@@ -34,7 +34,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun podMonitor(): PodMonitor
fun deviceMonitor(): DeviceMonitor
fun upgradeRepo(): UpgradeRepo
fun widgetSettings(): WidgetSettings
fun deviceProfilesRepo(): DeviceProfilesRepo
@@ -53,7 +53,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
cachedDevice = initialProfileId?.let { ep.podMonitor().getDeviceForProfile(it) }
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
provideContent {
@@ -73,7 +73,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
provideContent {
// Composable reads — must be outside try-catch
val devices by ep.podMonitor().devices.collectAsState(initial = emptyList())
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
@@ -86,8 +86,8 @@ class BatteryGlanceWidget : GlanceAppWidget() {
val isPro = upgradeInfo?.isPro ?: initialIsPro
val liveDevice = devices.firstOrNull { it.meta.profile?.id == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.meta.profile?.id == profileId }
val liveDevice = devices.firstOrNull { it.profileId == profileId }
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
val profileLabel = profileId?.let { pid ->
profiles.firstOrNull { it.id == pid }?.label
@@ -139,7 +139,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
var n = 2
while (70 * n - 30 < size) {
++n
}
}
return n - 1
}
@@ -30,7 +30,7 @@ import androidx.compose.ui.unit.sp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.pods.core.toBatteryOrNull
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -26,7 +26,7 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import eu.darken.capod.R
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.pods.core.toBatteryOrNull
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -24,10 +24,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
@@ -77,7 +77,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.DeviceProfile
@OptIn(ExperimentalLayoutApi::class)
@@ -413,7 +413,7 @@ private fun ProfileSelectionItem(
style = MaterialTheme.typography.bodyLarge,
)
val modelText = when (profile.model) {
PodDevice.Model.UNKNOWN -> stringResource(R.string.pods_unknown_label)
PodModel.UNKNOWN -> stringResource(R.string.pods_unknown_label)
else -> profile.model.label
}
Text(
@@ -456,7 +456,9 @@ private fun WidgetConfigPreview(
tonalElevation = 2.dp,
) {
Box(
modifier = Modifier.fillMaxWidth().padding(24.dp),
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
if (hasTransparency) {
@@ -691,8 +693,8 @@ private fun HexColorInput(
@Composable
private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
val profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodDevice.Model.AIRPODS_MAX),
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
)
WidgetConfigurationScreen(
state = WidgetConfigurationViewModel.State(
@@ -720,7 +722,7 @@ private fun WidgetConfigurationScreenPreview() = PreviewWrapper {
@Composable
private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
val profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
)
WidgetConfigurationScreen(
state = WidgetConfigurationViewModel.State(
@@ -753,7 +755,7 @@ private fun WidgetConfigurationScreenCustomPreview() = PreviewWrapper {
@Composable
private fun WidgetConfigurationScreenNonProPreview() = PreviewWrapper {
val profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodDevice.Model.AIRPODS_PRO2),
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
)
WidgetConfigurationScreen(
state = WidgetConfigurationViewModel.State(
@@ -4,15 +4,10 @@ import android.content.Context
import androidx.annotation.ColorInt
import androidx.appcompat.view.ContextThemeWrapper
import eu.darken.capod.R
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.pods.core.toBatteryFloat
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
object WidgetRenderStateMapper {
@@ -39,7 +34,7 @@ object WidgetRenderStateMapper {
secondaryText = context.getString(R.string.upgrade_capod_description),
)
device is DualPodDevice -> WidgetRenderState.DualPod(
device != null && device.hasDualPods -> WidgetRenderState.DualPod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
@@ -47,32 +42,32 @@ object WidgetRenderStateMapper {
isWide = isWide,
deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeftPodPercent.toBatteryFloat(),
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
leftPercent = device.batteryLeft.toBatteryFloat(),
leftCharging = device.isLeftPodCharging == true,
leftInEar = device.isLeftInEar == true,
rightIcon = device.rightPodIcon,
rightPercent = device.batteryRightPodPercent.toBatteryFloat(),
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
casePercent = (device as? HasCase)?.batteryCasePercent.toBatteryFloat(),
caseCharging = device is HasCase && device.isCaseCharging,
rightPercent = device.batteryRight.toBatteryFloat(),
rightCharging = device.isRightPodCharging == true,
rightInEar = device.isRightInEar == true,
caseIcon = device.caseIcon,
casePercent = device.batteryCase.toBatteryFloat(),
caseCharging = device.isCaseCharging == true,
)
device is SinglePodDevice -> WidgetRenderState.SinglePod(
device != null && device.model != PodModel.UNKNOWN -> WidgetRenderState.SinglePod(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
resolvedIconColor = iconColor,
deviceLabel = profileLabel ?: device.getLabel(context),
headsetIcon = device.iconRes,
percent = device.batteryHeadsetPercent.toBatteryFloat(),
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
worn = device is HasEarDetection && device.isBeingWorn,
percent = device.batteryHeadset.toBatteryFloat(),
batteryIcon = getBatteryDrawable(device.batteryHeadset),
charging = device.isHeadsetBeingCharged == true,
worn = device.isBeingWorn == true,
)
device is PodDevice -> WidgetRenderState.Message(
device != null -> WidgetRenderState.Message(
theme = theme,
resolvedBgColor = bgColor,
resolvedTextColor = textColor,
@@ -0,0 +1,186 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.cache.DeviceStateCache
import eu.darken.capod.monitor.core.cache.toCachedState
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
/**
* Single merge point: combines BLE scan data ([eu.darken.capod.monitor.core.ble.BlePodMonitor]) with AAP connection data
* ([AapConnectionManager]) and cached device state ([eu.darken.capod.monitor.core.cache.DeviceStateCache]) into unified [PodDevice] objects.
*
* Includes cached-only devices for profiles that have cached state but no live BLE data.
* Persists live device state to [eu.darken.capod.monitor.core.cache.DeviceStateCache] as a side effect.
* ViewModels should observe [devices] instead of accessing BlePodMonitor directly.
*/
@Singleton
class DeviceMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val blePodMonitor: BlePodMonitor,
private val aapManager: AapConnectionManager,
private val deviceStateCache: DeviceStateCache,
private val profilesRepo: DeviceProfilesRepo,
private val aapLifecycleManager: AapLifecycleManager,
) {
init {
aapLifecycleManager.start()
}
val devices: Flow<List<PodDevice>> = combine(
blePodMonitor.devices,
aapManager.allStates,
deviceStateCache.cachedStates,
profilesRepo.profiles,
) { pods, aapStates, cachedStates, profiles ->
// Live devices — BLE + AAP + cached fallback for missing fields
val liveDevices = pods.map { pod ->
val profile = pod.meta.profile
PodDevice(
profileId = profile?.id,
label = profile?.label,
ble = pod,
aap = aapStates.forProfile(profile),
cached = profile?.id?.let { cachedStates[it] },
profileAddress = profile?.address,
profileModel = profile?.model,
profileKeyState = profile.toBleKeyState(),
)
}
// Non-live devices — profiles with no live BLE detection. Synthesize from cache
// and/or AAP, whichever is available:
// - cache only → "cached-only" case (PR #484 fixed AAP attach here)
// - AAP only → cold-start case: AAP connected but no battery message
// received yet, so no cache write has happened
// - cache + AAP → mid-session BLE staleness with AAP still alive
val liveProfileIds = liveDevices.mapNotNull { it.profileId }.toSet()
val nonLiveDevices = profiles
.filter { it.id !in liveProfileIds }
.mapNotNull { profile ->
val cached = cachedStates[profile.id]
val aap = aapStates.forProfile(profile)
if (cached == null && aap == null) return@mapNotNull null
PodDevice(
profileId = profile.id,
label = profile.label,
ble = null,
aap = aap,
cached = cached,
profileAddress = profile.address,
profileModel = profile.model,
profileKeyState = profile.toBleKeyState(),
)
}
// De-dupe: if a synthesized non-live device with active AAP shares its model with
// an anonymous (profileId == null) BLE pod, the anonymous pod is most likely the
// same physical AirPods — the IRK match failed (e.g. wrong identity key, or keys
// haven't propagated yet). Hide the anonymous BLE pod to avoid two cards for the
// same device. Best-effort: model match only — BLE addresses are RPAs and can't
// be matched directly.
val nonLiveAapModels = nonLiveDevices
.filter { it.aap != null }
.map { it.model }
.filter { it != PodModel.UNKNOWN }
.toSet()
val dedupedLiveDevices = if (nonLiveAapModels.isEmpty()) {
liveDevices
} else {
val seenAnonymousModels = mutableSetOf<PodModel>()
liveDevices.filter { device ->
val isAnonymous = device.profileId == null
val matchesNonLive = device.model in nonLiveAapModels
// Hide at most one anonymous BLE pod per non-live AAP model so a second
// physical device of the same model isn't accidentally hidden.
!(isAnonymous && matchesNonLive && seenAnonymousModels.add(device.model))
}
}
dedupedLiveDevices + nonLiveDevices
}
.onEach { devices -> persistLiveDevices(devices) }
.replayingShare(appScope)
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
for (device in devices) {
val profileId = device.profileId ?: continue
val existing = deviceStateCache.cachedStates.value[profileId]
val newState = device.toCachedState(existing) ?: continue
log(TAG, VERBOSE) { "Persisting state for $profileId" }
deviceStateCache.save(profileId, newState)
}
}
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val liveDevice = devices.firstOrNull()?.firstOrNull { it.profileId == profileId }
if (liveDevice != null) {
log(TAG) { "Found live device for profile $profileId" }
return liveDevice
}
val profile = profilesRepo.profiles.firstOrNull()?.firstOrNull { it.id == profileId }
val cached = deviceStateCache.load(profileId)
val aap = profile?.let { aapManager.allStates.value.forProfile(it) }
if (cached == null && aap == null) {
log(TAG) { "No device found for profile $profileId" }
return null
}
log(TAG) { "Found fallback device for profile $profileId (cached=${cached != null}, aap=${aap != null})" }
return PodDevice(
profileId = profileId,
label = profile?.label,
ble = null,
aap = aap,
cached = cached,
profileAddress = profile?.address,
profileModel = profile?.model,
profileKeyState = profile.toBleKeyState(),
)
}
/**
* Single source of truth for "find the AAP state that belongs to this profile".
* AAP is keyed by the bonded BR/EDR address, which lives on [DeviceProfile.address].
* Used by both the live and cached-only branches of [devices] so they cannot drift apart.
*/
private fun Map<BluetoothAddress, AapPodState>.forProfile(profile: DeviceProfile?): AapPodState? =
profile?.address?.let { this[it] }
/**
* Derives the stable badge key state from the profile's stored IRK/ENC. Used so the badge
* icons don't evaporate every time the BLE scanner misses a scan batch.
*/
private fun DeviceProfile?.toBleKeyState(): BleKeyState {
val apple = this as? AppleDeviceProfile ?: return BleKeyState.NONE
if (apple.identityKey == null) return BleKeyState.NONE
return if (apple.encryptionKey != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
}
companion object {
private val TAG = logTag("DeviceMonitor")
}
}
@@ -0,0 +1,66 @@
package eu.darken.capod.monitor.core
import android.icu.text.RelativeDateTimeFormatter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.Duration
import java.time.Instant
fun DeviceMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
.map { devices -> devices.filter { it.profileId != null } }
fun DeviceMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
fun PodDevice.lastSeenFormatted(now: Instant): String {
val lastAt = seenLastAt ?: return ""
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(lastAt, now)
return if (duration > Duration.ofMinutes(1)) {
formatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
} else {
formatter.format(
duration.seconds.toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.SECONDS
)
}
}
fun PodDevice.firstSeenFormatted(now: Instant): String {
val firstAt = seenFirstAt ?: return ""
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(firstAt, now)
return formatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
}
fun PodDevice.cachedBatteryFormatted(now: Instant): String {
val cachedAt = cachedBatteryAt ?: return ""
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(cachedAt, now)
return when {
duration > Duration.ofHours(1) -> formatter.format(
duration.toHours().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.HOURS
)
duration > Duration.ofMinutes(1) -> formatter.format(
duration.toMinutes().toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.MINUTES
)
else -> formatter.format(
duration.seconds.toDouble(),
RelativeDateTimeFormatter.Direction.LAST,
RelativeDateTimeFormatter.RelativeUnit.SECONDS
)
}
}
@@ -0,0 +1,351 @@
package eu.darken.capod.monitor.core
import android.content.Context
import androidx.compose.runtime.Stable
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.monitor.core.cache.CachedDeviceState
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.pods.core.apple.ble.devices.HasDualMicrophone
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual
import java.time.Duration
import java.time.Instant
/**
* Unified device facade combining BLE scan data and AAP connection data.
* All properties are dynamically resolved from the best available source.
* Consumers don't need to know whether data came from BLE or AAP.
*/
@Stable
data class PodDevice(
val profileId: String?,
val label: String? = null,
internal val ble: BlePodSnapshot?,
internal val aap: AapPodState?,
internal val cached: CachedDeviceState? = null,
/** Bonded BR/EDR address from the profile — current source of truth, preferred over cache snapshot. */
internal val profileAddress: BluetoothAddress? = null,
/** Pod model from the profile — current source of truth, preferred over cache snapshot. */
internal val profileModel: PodModel? = null,
/**
* Key state derived from the profile's stored IRK/ENC. Used as the source of truth for the
* badge icons when the device is live — per-scan IRK matching would otherwise evaporate
* every time the BLE scanner misses the next advertisement batch.
*/
internal val profileKeyState: BleKeyState = BleKeyState.NONE,
) {
val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
val address: BluetoothAddress? get() = ble?.meta?.profile?.address ?: profileAddress ?: cached?.address
/** BLE scan address (RPA, rotates). */
val bleAddress: BluetoothAddress? get() = ble?.address
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
/** True when at least one live data source (BLE or AAP) is present. */
val isLive: Boolean get() = ble != null || aap != null
// Capabilities from Model.Features
val hasCase: Boolean get() = model.features.hasCase
val hasDualPods: Boolean get() = model.features.hasDualPods
val hasEarDetection: Boolean get() = model.features.hasEarDetection
val hasAncControl: Boolean get() = model.features.hasAncControl
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
// Signal / timing
val seenLastAt: Instant?
get() = listOfNotNull(ble?.seenLastAt, aap?.lastMessageAt, cached?.lastSeenAt).maxOrNull()
val seenFirstAt: Instant? get() = ble?.seenFirstAt
val signalQuality: Float
get() {
val bleQuality = ble?.signalQuality ?: 0f
return (bleQuality + computeAapBoost(Instant.now())).coerceAtMost(1f)
}
/**
* Quality value for the signal bar display. Prefers live BLE RSSI; when BLE is absent
* (common for a device that's actively connected to us via classic BT — we stop receiving
* its BLE advertisements), falls back to a full-bars constant as long as AAP is READY.
*
* Note: we don't try to graduate this off AAP message age. The AirPods only push messages
* on state changes (battery %, ear detection, setting toggles, user taps). A user quietly
* listening to music can go minutes without any AAP traffic, which is still a perfectly
* healthy connection. A quiet channel is not a weak channel.
*/
val rssiQuality: Float
get() {
ble?.rssiQuality?.let { return it }
val state = aap ?: return 0f
return if (state.connectionState == AapPodState.ConnectionState.READY) 1.0f else 0f
}
val rssi: Int get() = ble?.rssi ?: 0
/** Snapshot of connection-related state for badges and bars. */
val connectionState: ConnectionState
get() = ConnectionState(
hasBleData = ble != null,
bleKeyState = bleKeyState,
isAapConnected = isAapConnected,
rssiQuality = rssiQuality,
)
internal fun computeAapBoost(now: Instant): Float {
val state = aap ?: return 0f
if (state.connectionState != AapPodState.ConnectionState.READY) return 0.05f
val lastMessage = state.lastMessageAt ?: return 0.05f
val ageSeconds = Duration.between(lastMessage, now).seconds
return when {
ageSeconds < 0 -> 0.05f // future timestamp (defensive)
ageSeconds < 10 -> 0.15f // fresh
ageSeconds < 30 -> 0.10f // warm
else -> 0.05f // stale
}
}
// Battery — AAP preferred, BLE fallback, then cached
val batteryLeft: Float?
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
val batteryRight: Float?
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
val batteryCase: Float?
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
val batteryHeadset: Float?
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
/** True when at least one displayed battery value was filled from cache (not live). */
val isBatteryCached: Boolean
get() {
if (cached == null) return false
val usedLeft = aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null && cached.left != null
val usedRight = aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null && cached.right != null
val usedCase = aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null && cached.case != null
val usedHeadset = aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null && cached.headset != null
return usedLeft || usedRight || usedCase || usedHeadset
}
/** Oldest per-slot timestamp among battery values that fell through to cache. Null if all live. */
val cachedBatteryAt: Instant?
get() {
if (cached == null) return null
return listOfNotNull(
cached.left?.updatedAt.takeIf { aap?.batteryLeft == null && (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent == null },
cached.right?.updatedAt.takeIf { aap?.batteryRight == null && (ble as? DualBlePodSnapshot)?.batteryRightPodPercent == null },
cached.case?.updatedAt.takeIf { aap?.batteryCase == null && (ble as? HasCase)?.batteryCasePercent == null },
cached.headset?.updatedAt.takeIf { aap?.batteryHeadset == null && (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent == null },
).minOrNull()
}
// Charging — AAP preferred, BLE fallback, then cached
val isLeftPodCharging: Boolean?
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
val isRightPodCharging: Boolean?
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
val isCaseCharging: Boolean?
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
val isHeadsetBeingCharged: Boolean?
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
get() = aap?.aapPrimaryPod?.pod?.let { aapPod ->
when (aapPod) {
AapSetting.PrimaryPod.Pod.LEFT -> DualBlePodSnapshot.Pod.LEFT
AapSetting.PrimaryPod.Pod.RIGHT -> DualBlePodSnapshot.Pod.RIGHT
}
} ?: (ble as? DualApplePods)?.primaryPod
// Ear detection — AAP preferred (lower latency), BLE fallback.
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
val isLeftInEar: Boolean?
get() {
val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
} else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
}
}
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
}
val isRightInEar: Boolean?
get() {
val earDetection = aap?.aapEarDetection
val primary = resolvedPrimaryPod
if (earDetection != null && primary != null) {
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
} else {
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
}
}
return (ble as? HasEarDetectionDual)?.isRightPodInEar
}
val isBeingWorn: Boolean?
get() {
val earDetection = aap?.aapEarDetection
if (earDetection != null) {
return earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
&& earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
}
return (ble as? HasEarDetection)?.isBeingWorn
}
val isEitherPodInEar: Boolean?
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState
// Microphone — AAP preferred (from primary pod identity), BLE fallback
val isLeftPodMicrophone: Boolean?
get() {
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.LEFT }
return (ble as? HasDualMicrophone)?.isLeftPodMicrophone
}
val isRightPodMicrophone: Boolean?
get() {
aap?.aapPrimaryPod?.let { return it.pod == AapSetting.PrimaryPod.Pod.RIGHT }
return (ble as? HasDualMicrophone)?.isRightPodMicrophone
}
// Icons / labels
val iconRes: Int get() = ble?.iconRes ?: model.iconRes
val leftPodIcon: Int
get() = (ble as? DualBlePodSnapshot)?.leftPodIcon
?: model.leftPodIconRes
?: R.drawable.device_airpods_gen1_left
val rightPodIcon: Int
get() = (ble as? DualBlePodSnapshot)?.rightPodIcon
?: model.rightPodIconRes
?: R.drawable.device_airpods_gen1_right
val caseIcon: Int
get() = (ble as? HasCase)?.caseIcon
?: model.caseIconRes
?: R.drawable.device_airpods_gen1_case
fun getLabel(context: Context): String = ble?.getLabel(context) ?: model.label
// Debug
val rawDataHex: List<String> get() = ble?.rawDataHex ?: emptyList()
// AAP controls
val ancMode: AapSetting.AncMode?
get() = aap?.setting()
val pendingAncMode: AapSetting.AncMode.Value?
get() = aap?.pendingAncMode
val conversationalAwareness: AapSetting.ConversationalAwareness?
get() = aap?.setting()
val toneVolume: AapSetting.ToneVolume?
get() = aap?.setting()
val personalizedVolume: AapSetting.PersonalizedVolume?
get() = aap?.setting()
val volumeSwipe: AapSetting.VolumeSwipe?
get() = aap?.setting()
val ncWithOneAirPod: AapSetting.NcWithOneAirPod?
get() = aap?.setting()
val adaptiveAudioNoise: AapSetting.AdaptiveAudioNoise?
get() = aap?.setting()
val pressSpeed: AapSetting.PressSpeed?
get() = aap?.setting()
val pressHoldDuration: AapSetting.PressHoldDuration?
get() = aap?.setting()
val volumeSwipeLength: AapSetting.VolumeSwipeLength?
get() = aap?.setting()
val endCallMuteMic: AapSetting.EndCallMuteMic?
get() = aap?.setting()
val microphoneMode: AapSetting.MicrophoneMode?
get() = aap?.setting()
val earDetectionEnabled: AapSetting.EarDetectionEnabled?
get() = aap?.setting()
val listeningModeCycle: AapSetting.ListeningModeCycle?
get() = aap?.setting()
val allowOffOption: AapSetting.AllowOffOption?
get() = aap?.setting()
val stemConfig: AapSetting.StemConfig?
get() = aap?.setting()
val sleepDetection: AapSetting.SleepDetection?
get() = aap?.setting()
val connectedDevices: AapSetting.ConnectedDevices?
get() = aap?.setting()
val audioSource: AapSetting.AudioSource?
get() = aap?.setting()
val eqBands: AapSetting.EqBands?
get() = aap?.setting()
val deviceInfo: AapDeviceInfo?
get() = aap?.deviceInfo ?: cached?.deviceInfo
val isAapConnected: Boolean get() = aap != null
val isAapReady: Boolean get() = aap?.connectionState == AapPodState.ConnectionState.READY
/**
* Badge key state for the overview card. Profile-stored keys are the source of truth while
* the device is live: a profile that has an IRK (and optionally ENC) keeps showing those
* icons whether the current scan happens to include a fresh BLE advertisement or not. For
* anonymous BLE pods (no profile), we still derive the state from the live scan result.
*/
val bleKeyState: BleKeyState
get() {
if (!isLive) return BleKeyState.NONE
if (profileKeyState != BleKeyState.NONE) return profileKeyState
val applePod = ble as? ApplePods ?: return BleKeyState.NONE
if (!applePod.meta.isIRKMatch) return BleKeyState.NONE
return if (applePod.payload.private != null) BleKeyState.IRK_AND_ENCRYPTED else BleKeyState.IRK_ONLY
}
}
enum class BleKeyState { NONE, IRK_ONLY, IRK_AND_ENCRYPTED }
data class ConnectionState(
val hasBleData: Boolean,
val bleKeyState: BleKeyState,
val isAapConnected: Boolean,
val rssiQuality: Float,
)
@@ -1,84 +0,0 @@
package eu.darken.capod.monitor.core
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodDeviceCache @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatcherProvider: DispatcherProvider,
@SerializationCapod private val json: Json,
) {
private val cacheDir by lazy {
File(context.cacheDir, "device_cache").apply { mkdirs() }
}
private val lock = Mutex()
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
suspend fun load(id: ProfileId): BleScanResult? = withContext(dispatcherProvider.IO) {
log(TAG, VERBOSE) { "load(id=$id)" }
val cacheFile = id.toCacheFile()
lock.withLock {
if (!cacheFile.exists()) return@withLock null
try {
val raw = cacheFile.readText()
json.decodeFromString<BleScanResult>(raw)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
cacheFile.delete()
null
}
}
}
suspend fun saveAll(data: Map<ProfileId, BleScanResult>) {
log(TAG, VERBOSE) { "saveAll(): ${data.size} entries" }
lock.withLock {
data.forEach { (id, device) ->
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
val cacheFile = id.toCacheFile()
try {
val encoded = json.encodeToString(BleScanResult.serializer(), device)
cacheFile.writeText(encoded)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
cacheFile.delete()
}
}
}
}
suspend fun delete(id: ProfileId) {
log(TAG, VERBOSE) { "delete(): profileId=$id" }
lock.withLock {
val cacheFile = id.toCacheFile()
try {
cacheFile.delete()
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to delete profile for $id: ${e.asLog()}" }
}
}
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor", "Cache")
}
}
@@ -1,11 +0,0 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.PodDevice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.collections.firstOrNull
fun PodMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
.map { devices -> devices.filter { it.meta.profile != null } }
fun PodMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
@@ -1,9 +0,0 @@
package eu.darken.capod.monitor.core
import dagger.Reusable
import javax.inject.Inject
@Reusable
class PodSorter @Inject constructor(
)
@@ -0,0 +1,236 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@Singleton
class AapAutoConnect @Inject constructor(
private val aapManager: AapConnectionManager,
private val profilesRepo: DeviceProfilesRepo,
private val bluetoothManager: BluetoothManager2,
private val blePodMonitor: BlePodMonitor,
) {
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
fun monitor(): Flow<Unit> = merge(
initialConnect(),
reconnectOnDisconnect(),
correctModelOnDeviceInfo(),
)
private fun initialConnect(): Flow<Unit> = combine(
profilesRepo.profiles,
bluetoothManager.connectedDevices,
) { profiles, connectedDevices ->
val connectedAddresses = connectedDevices.map { it.address }.toSet()
profiles.filter { it.address in connectedAddresses }
}
.mapLatest { profiles ->
val bondedDevices = bluetoothManager.bondedDevices().first()
coroutineScope {
for (profile in profiles) {
launch { connectWithRetries(profile, bondedDevices) }
}
}
}
.setupCommonEventHandlers(TAG) { "initialConnect" }
private suspend fun connectWithRetries(
profile: eu.darken.capod.profiles.core.DeviceProfile,
bondedDevices: Set<eu.darken.capod.common.bluetooth.BluetoothDevice2>,
) {
val address = profile.address ?: return
val bonded = bondedDevices.firstOrNull { it.address == address } ?: return
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG, VERBOSE) { "AAP already connected/connecting to $address" }
return
}
log(TAG) { "AAP connecting to $address (${profile.label})" }
try {
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
log(TAG) { "AAP connected to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Bail out if classic BT disconnected
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
break
}
val retryState = aapManager.allStates.value[address]
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
break
}
try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break
} catch (retryException: Exception) {
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
}
}
}
}
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
.onEach { address ->
if (!activeReconnects.add(address)) {
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
return@onEach
}
try {
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Check if still profiled
val profile = profilesRepo.profiles.first()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
break
}
// Check if still bonded
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded == null) {
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
break
}
// Check if still classically connected
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
if (address !in currentConnected) {
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
break
}
// Check if still visible in BLE
val bleDevices = blePodMonitor.devices.first()
if (bleDevices.none { it.meta?.profile?.address == address }) {
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
break
}
// Check if already reconnected (e.g., by initialConnect)
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP reconnect: $address already reconnected" }
break
}
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
log(TAG) { "AAP reconnected to $address" }
break
} catch (e: Exception) {
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
}
}
} finally {
activeReconnects.remove(address)
}
}
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
.setupCommonEventHandlers(TAG) { "reconnect" }
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
.distinctUntilChanged()
.map { modelNumbers ->
// Clean up tracking for addresses that disconnected
processedModelCorrections.retainAll(modelNumbers.keys)
for ((address, modelNumber) in modelNumbers) {
if (modelNumber == null) continue
if (address in processedModelCorrections) continue
processedModelCorrections.add(address)
try {
val detectedModel = PodModel.fromModelNumber(modelNumber) ?: continue
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
?: continue
if (profile.model == detectedModel) {
log(TAG, VERBOSE) { "AAP model confirmed for $address: $detectedModel" }
continue
}
log(TAG) { "AAP model mismatch for $address: profile=${profile.model}, detected=$detectedModel" }
// Allow key exchange (AapKeyPersister) to complete before disconnecting
delay(2.seconds)
aapManager.disconnect(address)
profilesRepo.updateProfile(profile.copy(model = detectedModel))
log(TAG) { "AAP model corrected for $address: ${profile.model} -> $detectedModel" }
// Reconnect explicitly
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded != null) {
aapManager.connect(address, bonded.internal, detectedModel)
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
}
} catch (e: Exception) {
log(TAG, WARN) { "AAP model correction failed for $address: ${e.message}" }
}
}
}
.setupCommonEventHandlers(TAG) { "correctModel" }
companion object {
private val TAG = logTag("Monitor", "AapAutoConnect")
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
private val CONNECT_TIMEOUT = 5.seconds
}
}
@@ -0,0 +1,52 @@
package eu.darken.capod.monitor.core.aap
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.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists IRK/ENC keys from AAP key exchange to [AppleDeviceProfile],
* enabling BLE encrypted battery decryption with 1% granularity.
*/
@Singleton
class AapKeyPersister @Inject constructor(
private val aapManager: AapConnectionManager,
private val profilesRepo: DeviceProfilesRepo,
) {
fun monitor(): Flow<Unit> = aapManager.keysReceived
.onEach { (address, keys) ->
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "No profile found for $address, skipping key persistence" }
return@onEach
}
val updated = profile.copy(
identityKey = keys.irk ?: profile.identityKey,
encryptionKey = keys.encKey ?: profile.encryptionKey,
)
if (updated != profile) {
profilesRepo.updateProfile(updated)
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
}
}
.map { }
.setupCommonEventHandlers(TAG) { "keyPersister" }
companion object {
private val TAG = logTag("Monitor", "AapKeyPersister")
}
}
@@ -0,0 +1,46 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.common.coroutine.AppScope
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 eu.darken.capod.common.flow.setupCommonEventHandlers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.merge
import javax.inject.Inject
import javax.inject.Singleton
/**
* Launches AAP auto-connect and key persistence in [appScope],
* independent of [eu.darken.capod.monitor.core.worker.MonitorService] lifecycle.
*
* Call [start] from [eu.darken.capod.monitor.core.DeviceMonitor] init to activate.
*/
@Singleton
class AapLifecycleManager @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val aapAutoConnect: AapAutoConnect,
private val aapKeyPersister: AapKeyPersister,
private val stemConfigSender: StemConfigSender,
private val stemPressReaction: StemPressReaction,
) {
fun start() {
log(TAG) { "start()" }
merge(
aapAutoConnect.monitor(),
aapKeyPersister.monitor(),
stemConfigSender.monitor(),
stemPressReaction.monitor(),
)
.catch { e -> log(TAG, WARN) { "AAP lifecycle error: ${e.asLog()}" } }
.setupCommonEventHandlers(TAG) { "aapActive" }
.launchIn(appScope)
}
companion object {
private val TAG = logTag("Monitor", "AapLifecycleManager")
}
}
@@ -0,0 +1,81 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StemConfigSender @Inject constructor(
private val aapManager: AapConnectionManager,
private val stemActionSettings: StemActionSettings,
private val profilesRepo: DeviceProfilesRepo,
) {
fun monitor(): Flow<Unit> = combine(
aapManager.allStates,
stemActionMask(),
) { states, mask ->
states.entries
.filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY }
.mapNotNull { (address, _) ->
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
if (profile != null && profile.model.features.hasStemConfig) {
address to mask
} else {
null
}
}
}
.distinctUntilChanged()
.onEach { commands ->
for ((address, mask) in commands) {
try {
aapManager.sendCommand(address, AapCommand.SetStemConfig(mask))
log(TAG) { "Sent stem config 0x${"%02X".format(mask)} to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "StemConfig send failed for $address: $e" }
}
}
}
.map { }
.setupCommonEventHandlers(TAG) { "stemConfig" }
private fun stemActionMask(): Flow<Int> = combine(
stemActionSettings.leftSingle.flow,
stemActionSettings.leftDouble.flow,
stemActionSettings.leftTriple.flow,
stemActionSettings.leftLong.flow,
stemActionSettings.rightSingle.flow,
stemActionSettings.rightDouble.flow,
stemActionSettings.rightTriple.flow,
stemActionSettings.rightLong.flow,
) { values ->
var mask = 0
if (values[0] != StemAction.NONE || values[4] != StemAction.NONE) mask = mask or 0x01 // single
if (values[1] != StemAction.NONE || values[5] != StemAction.NONE) mask = mask or 0x02 // double
if (values[2] != StemAction.NONE || values[6] != StemAction.NONE) mask = mask or 0x04 // triple
if (values[3] != StemAction.NONE || values[7] != StemAction.NONE) mask = mask or 0x08 // long
mask
}
companion object {
private val TAG = logTag("Monitor", "StemConfigSender")
}
}
@@ -0,0 +1,64 @@
package eu.darken.capod.monitor.core.aap
import android.view.KeyEvent
import eu.darken.capod.common.MediaControl
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.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class StemPressReaction @Inject constructor(
private val aapManager: AapConnectionManager,
private val stemActionSettings: StemActionSettings,
private val mediaControl: MediaControl,
) {
fun monitor(): Flow<Unit> = aapManager.stemPressEvents
.onEach { (_, event) ->
val action = resolveAction(event)
log(TAG) { "Stem ${event.bud} ${event.pressType} -> $action" }
executeAction(action)
}
.map { }
.setupCommonEventHandlers(TAG) { "stemReaction" }
private suspend fun resolveAction(event: StemPressEvent): StemAction {
val setting = when (event.bud) {
StemPressEvent.Bud.LEFT -> when (event.pressType) {
StemPressEvent.PressType.SINGLE -> stemActionSettings.leftSingle
StemPressEvent.PressType.DOUBLE -> stemActionSettings.leftDouble
StemPressEvent.PressType.TRIPLE -> stemActionSettings.leftTriple
StemPressEvent.PressType.LONG -> stemActionSettings.leftLong
}
StemPressEvent.Bud.RIGHT -> when (event.pressType) {
StemPressEvent.PressType.SINGLE -> stemActionSettings.rightSingle
StemPressEvent.PressType.DOUBLE -> stemActionSettings.rightDouble
StemPressEvent.PressType.TRIPLE -> stemActionSettings.rightTriple
StemPressEvent.PressType.LONG -> stemActionSettings.rightLong
}
}
return setting.flow.first()
}
private suspend fun executeAction(action: StemAction) = when (action) {
StemAction.NONE, StemAction.NO_ACTION -> Unit
StemAction.PLAY_PAUSE -> mediaControl.sendPlayPause()
StemAction.NEXT_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT)
StemAction.PREVIOUS_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
StemAction.VOLUME_UP -> mediaControl.adjustVolumeUp()
StemAction.VOLUME_DOWN -> mediaControl.adjustVolumeDown()
}
companion object {
private val TAG = logTag("Monitor", "StemPressReaction")
}
}
@@ -1,14 +1,14 @@
package eu.darken.capod.monitor.core
package eu.darken.capod.monitor.core.ble
import android.bluetooth.le.ScanFilter
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.DebugSettings
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
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -16,19 +16,20 @@ import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.PodFactory
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.currentProfiles
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.sync.Mutex
@@ -39,22 +40,21 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodMonitor @Inject constructor(
class BlePodMonitor @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val bleScanner: BleScanner,
private val podFactory: PodFactory,
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
private val podDeviceCache: PodDeviceCache,
permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
private val cacheLock = Mutex()
val devices: Flow<List<PodDevice>> = combine(
val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
) { missingScanPermissions, isBluetoothEnabled ->
@@ -63,10 +63,16 @@ class PodMonitor @Inject constructor(
}
.flatMapLatest { isReady ->
if (!isReady) {
log(TAG, WARN) { "Bluetooth is not ready" }
log(TAG, Logging.Priority.WARN) { "Bluetooth is not ready" }
flowOf(null)
} else {
createBleScanner()
val staleEvictionTicker: Flow<Collection<BleScanResult>> = flow {
while (true) {
delay(STALE_EVICTION_INTERVAL.toMillis())
emit(emptyList())
}
}
merge(createBleScanner(), staleEvictionTicker)
}
}
.map { results -> results?.mapNotNull { podFactory.createPod(it) } }
@@ -76,10 +82,13 @@ class PodMonitor @Inject constructor(
}
.retryWhen { cause, attempt ->
if (cause is SecurityException) {
log(TAG, WARN) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
log(
TAG,
Logging.Priority.WARN
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
false
} else {
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
}
@@ -87,12 +96,12 @@ class PodMonitor @Inject constructor(
.onStart { emit(emptyList()) }
.replayingShare(appScope)
private suspend fun sortPodsToInterest(devices: Collection<PodDevice>): List<PodDevice> {
private suspend fun sortPodsToInterest(devices: Collection<BlePodSnapshot>): List<BlePodSnapshot> {
val now = Instant.now()
val profiles = profilesRepo.currentProfiles() ?: emptyList()
return devices.sortedWith(
compareBy<PodDevice> { device ->
compareBy<BlePodSnapshot> { device ->
// Use profile position in list as priority (0 = highest priority)
device.meta.profile?.let { profile ->
profiles.indexOfFirst { it.id == profile.id }.takeIf { it >= 0 } ?: Int.MAX_VALUE
@@ -140,7 +149,7 @@ class PodMonitor @Inject constructor(
.flatMapLatest { options ->
val filters = when {
options.showUnfiltered -> {
log(TAG, WARN) { "Using unfiltered scan mode" }
log(TAG, Logging.Priority.WARN) { "Using unfiltered scan mode" }
setOf(ScanFilter.Builder().build())
}
@@ -159,7 +168,7 @@ class PodMonitor @Inject constructor(
private suspend fun processWithCache(
newPods: List<PodFactory.Result>?
): Map<PodDevice.Id, PodDevice> = cacheLock.withLock {
): Map<BlePodSnapshot.Id, BlePodSnapshot> = cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
@@ -168,13 +177,13 @@ class PodMonitor @Inject constructor(
val now = Instant.now()
deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
if (Duration.between(value.seenLastAt, now) > STALE_DEVICE_TIMEOUT) {
log(TAG, Logging.Priority.VERBOSE) { "Removing stale device from cache: $value" }
deviceCache.remove(key)
}
}
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
val pods = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
pods.putAll(deviceCache)
@@ -183,35 +192,12 @@ class PodMonitor @Inject constructor(
pods[it.identifier] = it
}
newPods
.mapNotNull {
val profileId = it.device.meta.profile?.id ?: return@mapNotNull null
profileId to it.device.scanResult
}
.toMap()
.run { podDeviceCache.saveAll(this) }
return pods
}
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val liveDevice = devices.firstOrNull()?.firstOrNull { device ->
device.meta.profile?.id == profileId
}
if (liveDevice != null) {
log(TAG) { "Found live device for profile $profileId: $liveDevice" }
return liveDevice
}
val cachedDevice = podDeviceCache.load(profileId)?.let {
podFactory.createPod(it)?.device
}
log(TAG) { "Cached device for profile $profileId: $cachedDevice" }
return cachedDevice
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
private val STALE_DEVICE_TIMEOUT = Duration.ofSeconds(20)
private val STALE_EVICTION_INTERVAL = Duration.ofSeconds(10)
}
}
@@ -0,0 +1,47 @@
package eu.darken.capod.monitor.core.cache
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
data class CachedDeviceState(
@SerialName("profileId") val profileId: String,
@SerialName("model") val model: PodModel,
@SerialName("address") val address: String? = null,
@SerialName("left") val left: CachedBatterySlot? = null,
@SerialName("right") val right: CachedBatterySlot? = null,
@SerialName("case") val case: CachedBatterySlot? = null,
@SerialName("headset") val headset: CachedBatterySlot? = null,
@SerialName("isLeftCharging") val isLeftCharging: Boolean? = null,
@SerialName("isRightCharging") val isRightCharging: Boolean? = null,
@SerialName("isCaseCharging") val isCaseCharging: Boolean? = null,
@SerialName("isHeadsetCharging") val isHeadsetCharging: Boolean? = null,
@SerialName("deviceName") val deviceName: String? = null,
@SerialName("serialNumber") val serialNumber: String? = null,
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("lastSeenAt") val lastSeenAt: Instant,
) {
val deviceInfo: AapDeviceInfo?
get() {
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
return AapDeviceInfo(
name = deviceName ?: "",
modelNumber = "",
manufacturer = "",
serialNumber = serialNumber ?: "",
firmwareVersion = firmwareVersion ?: "",
)
}
@Serializable
data class CachedBatterySlot(
@SerialName("percent") val percent: Float,
@Serializable(with = InstantEpochMillisSerializer::class)
@SerialName("updatedAt") val updatedAt: Instant,
)
}
@@ -0,0 +1,120 @@
package eu.darken.capod.monitor.core.cache
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceStateCache @Inject constructor(
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
@SerializationCapod private val json: Json,
) {
private val cacheDir by lazy {
File(context.filesDir, "device_state_cache").apply { mkdirs() }
}
private val lock = Mutex()
private val _cachedStates = MutableStateFlow<Map<ProfileId, CachedDeviceState>>(emptyMap())
val cachedStates: StateFlow<Map<ProfileId, CachedDeviceState>> = _cachedStates
init {
appScope.launch { loadAll() }
}
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
val dir = cacheDir
if (!dir.exists()) return@withLock
val loaded = mutableMapOf<ProfileId, CachedDeviceState>()
dir.listFiles()?.filter { it.name.startsWith("profile_") && it.name.endsWith(".json") }?.forEach { file ->
val profileId = file.name.removePrefix("profile_").removeSuffix(".json")
try {
val state = json.decodeFromString<CachedDeviceState>(file.readText())
loaded[profileId] = state
} catch (e: Exception) {
log(
TAG,
Logging.Priority.ERROR
) { "Failed to load cached state from ${file.name}: ${e.asLog()}, deleting" }
file.delete()
}
}
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
_cachedStates.value = loaded
}
}
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
suspend fun save(id: ProfileId, state: CachedDeviceState) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id)" }
val file = id.toCacheFile()
try {
file.writeText(json.encodeToString(CachedDeviceState.serializer(), state))
_cachedStates.value += (id to state)
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to save state for $id: ${e.asLog()}" }
file.delete()
}
}
}
suspend fun load(id: ProfileId): CachedDeviceState? = withContext(dispatcherProvider.IO) {
lock.withLock {
val cached = _cachedStates.value[id]
if (cached != null) return@withContext cached
val file = id.toCacheFile()
if (!file.exists()) return@withContext null
try {
json.decodeFromString<CachedDeviceState>(file.readText())
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to load state for $id: ${e.asLog()}, deleting" }
file.delete()
null
}
}
}
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
id.toCacheFile().delete()
_cachedStates.value -= id
}
}
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
lock.withLock {
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
cacheDir.listFiles()?.forEach { it.delete() }
_cachedStates.value = emptyMap()
}
}
companion object {
private val TAG = logTag("Monitor", "DeviceStateCache")
}
}
@@ -0,0 +1,71 @@
package eu.darken.capod.monitor.core.cache
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import java.time.Duration
import java.time.Instant
/**
* Creates a [CachedDeviceState] from this live device's raw BLE/AAP battery data.
* Returns null if:
* - The device is not live (cached-only)
* - The device has no profile
* - All live battery values are null
* - The state hasn't changed from [existing] (dedup)
*/
fun PodDevice.toCachedState(
existing: CachedDeviceState?,
now: Instant = Instant.now(),
): CachedDeviceState? {
if (!isLive) return null
val pid = profileId ?: return null
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
val liveDeviceInfo = aap?.deviceInfo
val newState = CachedDeviceState(
profileId = pid,
model = model,
address = address,
left = liveLeft?.let { CachedBatterySlot(it, now) } ?: existing?.left,
right = liveRight?.let { CachedBatterySlot(it, now) } ?: existing?.right,
case = liveCase?.let { CachedBatterySlot(it, now) } ?: existing?.case,
headset = liveHeadset?.let { CachedBatterySlot(it, now) } ?: existing?.headset,
isLeftCharging = isLeftPodCharging,
isRightCharging = isRightPodCharging,
isCaseCharging = isCaseCharging,
isHeadsetCharging = isHeadsetBeingCharged,
deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
lastSeenAt = seenLastAt ?: now,
)
if (existing != null && !hasStateChanged(existing, newState)) return null
return newState
}
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
return old.left?.percent != new.left?.percent
|| old.right?.percent != new.right?.percent
|| old.case?.percent != new.case?.percent
|| old.headset?.percent != new.headset?.percent
|| old.isLeftCharging != new.isLeftCharging
|| old.isRightCharging != new.isRightCharging
|| old.isCaseCharging != new.isCaseCharging
|| old.isHeadsetCharging != new.isHeadsetCharging
|| old.deviceName != new.deviceName
|| old.serialNumber != new.serialNumber
|| old.firmwareVersion != new.firmwareVersion
}
@@ -12,7 +12,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
import javax.inject.Inject
@AndroidEntryPoint
@@ -14,6 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -26,10 +27,12 @@ import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
@@ -53,7 +56,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import eu.darken.capod.common.datastore.valueBlocking
@AndroidEntryPoint
class MonitorService : Service() {
@@ -63,13 +65,15 @@ class MonitorService : Service() {
@Inject lateinit var notificationManager: NotificationManager
@Inject lateinit var generalSettings: GeneralSettings
@Inject lateinit var permissionTool: PermissionTool
@Inject lateinit var podMonitor: PodMonitor
@Inject lateinit var blePodMonitor: BlePodMonitor
@Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var bluetoothManager: BluetoothManager2
@Inject lateinit var playPause: PlayPause
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapConnectionManager: AapConnectionManager
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@@ -176,8 +180,8 @@ class MonitorService : Service() {
return
}
val monitorJob = podMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "PodMonitor" }
val monitorJob = deviceMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChanged()
.throttleLatest(1000)
.onEach { currentDevice ->
@@ -209,8 +213,9 @@ class MonitorService : Service() {
generalSettings.monitorMode.flow,
profilesRepo.profiles,
bluetoothManager.connectedDevices,
) { monitorMode, profiles, connectedDevices ->
listOf(monitorMode, profiles, connectedDevices)
aapConnectionManager.allStates,
) { monitorMode, profiles, connectedDevices, aapStates ->
listOf(monitorMode, profiles, connectedDevices, aapStates)
}
}
}
@@ -220,9 +225,13 @@ class MonitorService : Service() {
@Suppress("UNCHECKED_CAST")
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" }
@@ -241,7 +250,7 @@ class MonitorService : Service() {
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
}
knownAddresses.any { it in connectedAddresses } -> {
knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> {
log(TAG) { "A device is connected, aborting any timeout." }
}
@@ -5,15 +5,10 @@ import android.view.View
import android.widget.RemoteViews
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
import javax.inject.Inject
import kotlin.math.roundToInt
@@ -22,58 +17,61 @@ class MonitorNotificationViewFactory @Inject constructor(
@ApplicationContext private val context: Context
) {
fun createContentView(device: PodDevice): RemoteViews = when (device) {
is DualPodDevice -> createDualPods(device)
is SinglePodDevice -> createSinglePod(device)
fun createContentView(device: PodDevice): RemoteViews = when {
device.hasDualPods -> createDualPods(device)
device.model != PodModel.UNKNOWN -> createSinglePod(device)
else -> createUnknownDevice(device)
}
private fun createDualPods(device: DualPodDevice): RemoteViews = RemoteViews(
private fun createDualPods(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_small
).apply {
// Left
val leftPercent = device.batteryLeftPodPercent
val leftPercent = device.batteryLeft
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
val isLeftPodCharging = device.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
val isLeftPodInEar = device.isLeftInEar ?: false
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
// Case
setViewVisibility(R.id.pod_case_charging, if (device is HasCase) View.VISIBLE else View.GONE)
(device as? HasCase)?.let { case ->
setViewVisibility(R.id.pod_case_charging, if (device.hasCase) View.VISIBLE else View.GONE)
if (device.hasCase) {
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
val casePercent = case.batteryCasePercent
val casePercent = device.batteryCase
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE)
}
// Right
val rightPercent = device.batteryRightPodPercent
val rightPercent = device.batteryRight
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
val isRightPodCharging = device.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
val isRightPodInEar = device.isRightInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePod(device: SinglePodDevice): RemoteViews = RemoteViews(
private fun createSinglePod(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_small
).apply {
val headsetPercent = device.batteryHeadsetPercent
val headsetPercent = device.batteryHeadset
setTextViewText(R.id.headphones_label, device.getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
if (device is HasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
if (device.hasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
}
@@ -84,61 +82,64 @@ class MonitorNotificationViewFactory @Inject constructor(
setTextViewText(R.id.device, device.getLabel(context))
}
fun createBigContentView(device: PodDevice): RemoteViews = when (device) {
is DualPodDevice -> createDualPodsBig(device)
is SinglePodDevice -> createSinglePodBig(device)
fun createBigContentView(device: PodDevice): RemoteViews = when {
device.hasDualPods -> createDualPodsBig(device)
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
else -> createUnknownDeviceBig(device)
}
private fun createDualPodsBig(device: DualPodDevice): RemoteViews = RemoteViews(
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_dual_pods_big
).apply {
// Left
val leftPercent = device.batteryLeftPodPercent
val leftPercent = device.batteryLeft
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
val isLeftPodCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false
val isLeftPodCharging = device.isLeftPodCharging ?: false
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
val isLeftPodInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false
val isLeftPodInEar = device.isLeftInEar ?: false
setViewVisibility(R.id.pod_left_ear, if (isLeftPodInEar) View.VISIBLE else View.GONE)
// Case
setViewVisibility(R.id.pod_case_container, if (device is HasCase) View.VISIBLE else View.GONE)
(device as? HasCase)?.let { case ->
setViewVisibility(R.id.pod_case_container, if (device.hasCase) View.VISIBLE else View.GONE)
if (device.hasCase) {
setImageViewResource(R.id.pod_case_icon, device.caseIcon)
val casePercent = case.batteryCasePercent
val casePercent = device.batteryCase
setProgressBar(R.id.pod_case_progress, 100, percentToInt(casePercent), false)
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
setViewVisibility(R.id.pod_case_charging, if (case.isCaseCharging) View.VISIBLE else View.GONE)
setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE)
}
// Right
val rightPercent = device.batteryRightPodPercent
val rightPercent = device.batteryRight
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
val isRightPodCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false
val isRightPodCharging = device.isRightPodCharging ?: false
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
val isRightPodInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false
val isRightPodInEar = device.isRightInEar ?: false
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
}
private fun createSinglePodBig(device: SinglePodDevice): RemoteViews = RemoteViews(
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
context.packageName,
R.layout.monitor_notification_single_pods_big
).apply {
val headsetPercent = device.batteryHeadsetPercent
val headsetPercent = device.batteryHeadset
setTextViewText(R.id.headphones_label, device.getLabel(context))
setImageViewResource(R.id.headphones_icon, device.iconRes)
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
if (device is HasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn) View.VISIBLE else View.GONE)
if (device.hasEarDetection) {
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
}
if (device is HasChargeDetectionDual) {
setViewVisibility(R.id.headphones_charging, if (device.isHeadsetBeingCharged) View.VISIBLE else View.GONE)
if (device.isHeadsetBeingCharged != null) {
setViewVisibility(
R.id.headphones_charging,
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
)
}
}
@@ -154,4 +155,4 @@ class MonitorNotificationViewFactory @Inject constructor(
return (percent * 100).roundToInt().coerceIn(0, 100)
}
}
}
@@ -15,13 +15,9 @@ 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 eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import javax.inject.Inject
@@ -57,7 +53,11 @@ class MonitorNotifications @Inject constructor(
setOngoing(true)
}
private fun getBuilder(device: PodDevice?, channelId: String, showHint: Boolean = false): NotificationCompat.Builder {
private fun getBuilder(
device: PodDevice?,
channelId: String,
showHint: Boolean = false
): NotificationCompat.Builder {
if (device == null) {
return baseBuilder(channelId).apply {
if (showHint) {
@@ -75,59 +75,42 @@ class MonitorNotifications @Inject constructor(
return baseBuilder(channelId).apply {
// Options here should be mutually exclusive, and are prioritized by their order of importance
// Some options are omitted here, as they will conflict with other options
// TODO: Implement a settings pane to allow user to customize this
val stateText = when {
// Pods charging state
// This goes first as pods should not be worn if it is still charging
device is HasChargeDetection && device.isHeadsetBeingCharged -> {
device.isHeadsetBeingCharged == true -> {
context.getString(R.string.pods_charging_label)
}
// Pods wear state
device is HasEarDetection -> {
if (device.isBeingWorn) context.getString(R.string.headset_being_worn_label)
device.hasEarDetection -> {
if (device.isBeingWorn == true) context.getString(R.string.headset_being_worn_label)
else context.getString(R.string.headset_not_being_worn_label)
}
// Case charge state
// This is under pods wear state as we don't want it conflicting with it
device is HasCase && device.isCaseCharging -> {
device.hasCase && device.isCaseCharging == true -> {
context.getString(R.string.pods_charging_label)
}
else -> context.getString(R.string.pods_case_unknown_state)
}
val batteryText = when (device) {
is DualPodDevice -> {
val leftPercent = device.batteryLeftPodPercent
val rightPercent = device.batteryRightPodPercent
val left = formatBatteryPercent(context, leftPercent)
val right = formatBatteryPercent(context, rightPercent)
when {
device is HasCase -> {
val casePercent = device.batteryCasePercent
val case = formatBatteryPercent(context, casePercent)
"$left $case $right"
}
else -> "$left $right"
val batteryText = when {
device.hasDualPods -> {
val left = formatBatteryPercent(context, device.batteryLeft)
val right = formatBatteryPercent(context, device.batteryRight)
if (device.hasCase) {
val case = formatBatteryPercent(context, device.batteryCase)
"$left $case $right"
} else {
"$left $right"
}
}
is SinglePodDevice -> {
val headsetPercent = device.batteryHeadsetPercent
val headset = formatBatteryPercent(context, headsetPercent)
when {
device is HasCase -> {
val casePercent = device.batteryCasePercent
val case = formatBatteryPercent(context, casePercent)
"$headset $case"
}
else -> headset
device.model != PodModel.UNKNOWN -> {
val headset = formatBatteryPercent(context, device.batteryHeadset)
if (device.hasCase) {
val case = formatBatteryPercent(context, device.batteryCase)
"$headset $case"
} else {
headset
}
}
@@ -1,190 +0,0 @@
package eu.darken.capod.pods.core
import android.content.Context
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.profiles.core.DeviceProfile
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Duration
import java.time.Instant
import java.util.UUID
import kotlin.math.abs
import kotlin.math.max
interface PodDevice {
val identifier: Id
val model: Model
val address: BluetoothAddress
get() = scanResult.address
val seenLastAt: Instant
val seenFirstAt: Instant
val seenCounter: Int
val scanResult: BleScanResult
val rssi: Int
get() = scanResult.rssi
val reliability: Float
val signalQuality: Float
get() {
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val sqRssi = ((100 - abs(rssi)) / 100f)
val sqReliability = max(BASE_CONFIDENCE, reliability)
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
return (sqRssi + sqReliability + sqAge) / 2f
}
val rawData: Map<Int, ByteArray>
get() = scanResult.manufacturerSpecificData
val rawDataHex: List<String>
get() = rawData.entries.map { entry ->
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
}
interface Meta {
val profile: DeviceProfile?
}
val meta: Meta
fun getLabel(context: Context): String = model.label
@get:DrawableRes
val iconRes: Int
get() = model.iconRes
@JvmInline
value class Id(private val id: UUID = UUID.randomUUID())
@Serializable
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
) {
@SerialName("airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.device_airpods_gen4anc_both,
),
@SerialName("airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro3") AIRPODS_PRO3(
"AirPods Pro 3",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.device_airpods_max
),
@SerialName("airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.device_airpods_max
),
@SerialName("beats.flex") BEATS_FLEX(
"Beats Flex",
R.drawable.device_beats_earbuds,
),
@SerialName("beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3",
R.drawable.device_beats_headphones,
),
@SerialName("beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3",
R.drawable.device_beats_studio3,
),
@SerialName("beats.x") BEATS_X(
"Beats X",
R.drawable.device_beats_x,
),
@SerialName("beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3",
R.drawable.device_powerbeats_3,
),
@SerialName("beats.powerbeats.4") POWERBEATS_4(
"Power Beats 4",
R.drawable.device_powerbeats_4,
),
@SerialName("beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro",
R.drawable.device_powerbeats_pro_both,
),
@SerialName("beats.powerbeats.pro2") POWERBEATS_PRO2(
"Power Beats Pro 2",
R.drawable.device_powerbeats_pro2_both,
),
@SerialName("beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro",
R.drawable.device_beats_fitpro_both,
),
@SerialName("fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.device_airpods_gen3_both,
),
@SerialName("fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("unknown") UNKNOWN(
"Unknown"
);
}
companion object {
const val BASE_CONFIDENCE = 0.0f
}
}
@@ -1,6 +0,0 @@
package eu.darken.capod.pods.core
interface SinglePodDevice : PodDevice {
val batteryHeadsetPercent: Float?
}
@@ -1,66 +0,0 @@
package eu.darken.capod.pods.core.apple
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen2
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen3
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4Anc
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro3
import eu.darken.capod.pods.core.apple.beats.BeatsFitPro
import eu.darken.capod.pods.core.apple.beats.BeatsFlex
import eu.darken.capod.pods.core.apple.beats.BeatsSolo3
import eu.darken.capod.pods.core.apple.beats.BeatsStudio3
import eu.darken.capod.pods.core.apple.beats.BeatsX
import eu.darken.capod.pods.core.apple.beats.PowerBeats3
import eu.darken.capod.pods.core.apple.beats.PowerBeats4
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro2
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
@InstallIn(SingletonComponent::class)
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro2(factory: PowerBeatsPro2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
}
@@ -0,0 +1,532 @@
package eu.darken.capod.pods.core.apple
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class PodModel(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
val features: Features = Features(),
val modelNumbers: Set<String> = emptySet(),
@DrawableRes val leftPodIconRes: Int? = null,
@DrawableRes val rightPodIconRes: Int? = null,
@DrawableRes val caseIconRes: Int? = null,
) {
@SerialName("airpods.gen1")
AIRPODS_GEN1(
"AirPods (Gen 1)",
R.drawable.device_airpods_gen1_both,
Features(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("airpods.gen2")
AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.device_airpods_gen1_both,
Features(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("airpods.gen3")
AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.device_airpods_gen3_both,
Features(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
@SerialName("airpods.gen4")
AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.device_airpods_gen3_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
@SerialName("airpods.gen4.anc")
AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.device_airpods_gen4anc_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasAdaptiveAnc = true,
hasConversationAwareness = true,
hasNcOneAirpod = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasPersonalizedVolume = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasAdaptiveAudioNoise = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasListeningModeCycle = true,
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
caseIconRes = R.drawable.device_airpods_gen4anc_case,
),
@SerialName("airpods.pro")
AIRPODS_PRO(
"AirPods Pro",
R.drawable.device_airpods_pro2_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasNcOneAirpod = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasListeningModeCycle = true,
hasAllowOffOption = true,
),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.pro2")
AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.device_airpods_pro2_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasAdaptiveAnc = true,
hasConversationAwareness = true,
hasNcOneAirpod = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasVolumeSwipe = true,
hasVolumeSwipeLength = true,
hasPersonalizedVolume = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasAdaptiveAudioNoise = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasListeningModeCycle = true,
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.pro2.usbc")
AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.device_airpods_pro2_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasAdaptiveAnc = true,
hasConversationAwareness = true,
hasNcOneAirpod = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasVolumeSwipe = true,
hasVolumeSwipeLength = true,
hasPersonalizedVolume = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasAdaptiveAudioNoise = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasListeningModeCycle = true,
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.pro3")
AIRPODS_PRO3(
"AirPods Pro 3",
R.drawable.device_airpods_pro2_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasAdaptiveAnc = true,
hasConversationAwareness = true,
hasNcOneAirpod = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasVolumeSwipe = true,
hasVolumeSwipeLength = true,
hasPersonalizedVolume = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasAdaptiveAudioNoise = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasListeningModeCycle = true,
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.max")
AIRPODS_MAX(
"AirPods Max",
R.drawable.device_airpods_max,
Features(
hasEarDetection = true,
hasAncControl = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2096"), // headphones
),
@SerialName("airpods.max.usbc")
AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.device_airpods_max,
Features(
hasEarDetection = true,
hasAncControl = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A3184"), // headphones
),
@SerialName("beats.flex")
BEATS_FLEX(
"Beats Flex",
R.drawable.device_beats_earbuds,
modelNumbers = setOf("A2295"),
),
@SerialName("beats.solo.3")
BEATS_SOLO_3(
"Beats Solo 3",
R.drawable.device_beats_headphones,
modelNumbers = setOf("A1796"), // headphones
),
@SerialName("beats.solo.pro")
BEATS_SOLO_PRO(
"Beats Solo Pro",
R.drawable.device_beats_headphones,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1881"), // headphones
),
@SerialName("beats.solo.4")
BEATS_SOLO_4(
"Beats Solo 4",
R.drawable.device_beats_headphones,
modelNumbers = setOf("A3140"), // headphones
),
@SerialName("beats.solo.buds")
BEATS_SOLO_BUDS(
"Beats Solo Buds",
R.drawable.device_beats_earbuds,
Features(
hasDualPods = true,
hasCase = true,
),
modelNumbers = setOf("A3150", "A3151", "A3153"), // L/R earbuds + case
),
@SerialName("beats.studio.3")
BEATS_STUDIO_3(
"Beats Studio 3",
R.drawable.device_beats_studio3,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1914"), // headphones
),
@SerialName("beats.studio.buds")
BEATS_STUDIO_BUDS(
"Beats Studio Buds",
R.drawable.device_beats_earbuds,
Features(
hasDualPods = true,
hasCase = true,
hasAncControl = true,
),
modelNumbers = setOf("A2512", "A2513", "A2514"), // L/R earbuds + case
),
@SerialName("beats.studio.buds.plus")
BEATS_STUDIO_BUDS_PLUS(
"Beats Studio Buds+",
R.drawable.device_beats_earbuds,
Features(
hasDualPods = true,
hasCase = true,
hasAncControl = true,
),
modelNumbers = setOf("A2871", "A2872", "A2952"), // L/R earbuds + case
),
@SerialName("beats.studio.pro")
BEATS_STUDIO_PRO(
"Beats Studio Pro",
R.drawable.device_beats_headphones,
Features(hasAncControl = true),
modelNumbers = setOf("A2924"), // headphones
),
@SerialName("beats.x")
BEATS_X(
"Beats X",
R.drawable.device_beats_x,
modelNumbers = setOf("A1763"),
),
@SerialName("beats.powerbeats.3")
POWERBEATS_3(
"Power Beats 3",
R.drawable.device_powerbeats_3,
modelNumbers = setOf("A1747"),
),
@SerialName("beats.powerbeats.4")
POWERBEATS_4(
"Power Beats 4",
R.drawable.device_powerbeats_4,
modelNumbers = setOf("A2015"),
),
@SerialName("beats.powerbeats.pro")
POWERBEATS_PRO(
"Power Beats Pro",
R.drawable.device_powerbeats_pro_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
rightPodIconRes = R.drawable.device_powerbeats_pro_right,
caseIconRes = R.drawable.device_powerbeats_pro_case,
),
@SerialName("beats.powerbeats.pro2")
POWERBEATS_PRO2(
"Power Beats Pro 2",
R.drawable.device_powerbeats_pro2_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_powerbeats_pro2_left,
rightPodIconRes = R.drawable.device_powerbeats_pro2_right,
caseIconRes = R.drawable.device_powerbeats_pro2_case,
),
@SerialName("beats.fit.pro")
BEATS_FIT_PRO(
"Beats Fit Pro",
R.drawable.device_beats_fitpro_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_beats_fitpro_left,
rightPodIconRes = R.drawable.device_beats_fitpro_right,
caseIconRes = R.drawable.device_beats_fitpro_case,
),
@SerialName("fakes.tws.i99999")
FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
Features(
hasDualPods = true,
hasCase = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("fakes.generic.airpods.gen2")
FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
Features(
hasDualPods = true,
hasCase = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("fakes.generic.airpods.gen3")
FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.device_airpods_gen3_both,
Features(
hasDualPods = true,
hasCase = true,
),
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
@SerialName("fakes.varunr.airpodspro")
FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
),
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("fakes.generic.airpods.pro2")
FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
),
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("unknown")
UNKNOWN("Unknown"),
;
companion object {
fun fromModelNumber(modelNumber: String): PodModel? {
val normalized = modelNumber.trim().uppercase()
if (normalized.isBlank()) return null
return entries.firstOrNull { entry ->
entry != UNKNOWN && !entry.name.startsWith("FAKE_") && normalized in entry.modelNumbers
}
}
}
data class Features(
// Physical form
val hasDualPods: Boolean = false,
val hasCase: Boolean = false,
val hasEarDetection: Boolean = false,
// ANC
val hasAncControl: Boolean = false,
val hasAdaptiveAnc: Boolean = false,
// AAP settings
val hasConversationAwareness: Boolean = false,
val hasNcOneAirpod: Boolean = false,
val hasPressSpeed: Boolean = false,
val hasPressHoldDuration: Boolean = false,
val hasVolumeSwipe: Boolean = false,
val hasVolumeSwipeLength: Boolean = false,
val hasPersonalizedVolume: Boolean = false,
val hasToneVolume: Boolean = false,
val hasEndCallMuteMic: Boolean = false,
val hasAdaptiveAudioNoise: Boolean = false,
val hasMicrophoneMode: Boolean = false,
val hasEarDetectionToggle: Boolean = false,
val hasListeningModeCycle: Boolean = false,
val hasAllowOffOption: Boolean = false,
val hasStemConfig: Boolean = false,
val hasSleepDetection: Boolean = false,
// Protocol
val needsInitExt: Boolean = false,
)
}
@@ -0,0 +1,539 @@
package eu.darken.capod.pods.core.apple.aap
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlin.reflect.KClass
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.AapFramer
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.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.channels.BufferOverflow
import java.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.charset.CharacterCodingException
import java.nio.charset.CodingErrorAction
/**
* Manages a single AAP L2CAP connection to a device.
* Internal — not exposed outside [AapConnectionManager].
*/
@SuppressLint("MissingPermission")
internal class AapConnection(
private val device: BluetoothDevice,
private val profile: AapDeviceProfile,
private val socketFactory: L2capSocketFactory,
private val psm: Int = 0x1001,
) {
private val _state = MutableStateFlow(AapPodState())
val state: StateFlow<AapPodState> = _state.asStateFlow()
private val _keysReceived = MutableSharedFlow<KeyExchangeResult>(extraBufferCapacity = 1)
val keysReceived: SharedFlow<KeyExchangeResult> = _keysReceived.asSharedFlow()
private val _stemPressEvents = MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
private val writeMutex = Mutex()
private val framer = AapFramer()
private var pendingAncMode: AapSetting.AncMode.Value? = null
private var ancDebounceJob: Job? = null
private var connectionScope: CoroutineScope? = null
private var lastAncCommandSentAt: Long = 0L
private var lastCommandedAncMode: AapSetting.AncMode.Value? = null
private var ancResendJob: Job? = null
/**
* Opens the L2CAP socket, sends the handshake, and launches the read loop.
* Returns after the handshake is sent — the read loop runs in [scope] independently.
*/
suspend fun connect(scope: CoroutineScope) = withContext(Dispatchers.IO) {
if (_state.value.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
throw IllegalStateException("connect() called in state ${_state.value.connectionState}")
}
connectionScope = scope
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
try {
val sock = socketFactory.createSocket(device, psm)
sock.connect()
socket = sock
log(TAG) { "Connected to ${device.address}" }
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING)
// Send handshake
val handshake = profile.encodeHandshake()
sock.outputStream.write(handshake)
sock.outputStream.flush()
log(TAG) { "Handshake sent" }
// Send notification enable packets — tells device to push battery/settings updates
for (packet in profile.encodeNotificationEnable()) {
sock.outputStream.write(packet)
sock.outputStream.flush()
}
log(TAG) { "Notification enable sent" }
// Send InitExt for models that need it (Pro 2/3/USB-C, AP4 ANC)
profile.encodeInitExt()?.let { initExt ->
sock.outputStream.write(initExt)
sock.outputStream.flush()
log(TAG) { "InitExt sent" }
}
// Request private keys (IRK + ENC) for BLE encrypted battery
profile.encodePrivateKeyRequest()?.let { keyReq ->
sock.outputStream.write(keyReq)
sock.outputStream.flush()
log(TAG) { "Private key request sent" }
}
// Launch read loop in the provided scope — connect() returns immediately
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
} catch (e: Exception) {
log(TAG, ERROR) { "Connection failed: $e" }
cleanupSocket()
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
throw e
}
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
log(TAG) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
ancDebounceJob?.cancel()
ancDebounceJob = null
ancResendJob?.cancel()
ancResendJob = null
pendingAncMode = null
lastCommandedAncMode = null
connectionScope = null
cleanupSocket()
framer.reset()
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
}
suspend fun send(command: AapCommand) {
val currentState = _state.value
if (currentState.connectionState != AapPodState.ConnectionState.READY) {
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}")
}
if (command is AapCommand.SetAncMode) {
lastCommandedAncMode = command.mode
ancResendJob?.cancel()
ancResendJob = null
val earDetection = currentState.setting<AapSetting.EarDetection>()
if (earDetection != null && !earDetection.isEitherPodInEar) {
log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" }
pendingAncMode = command.mode
_state.value = currentState.copy(pendingAncMode = command.mode)
return
}
pendingAncMode = null
// Optimistically update UI — don't wait for device echo
val currentAnc = currentState.setting<AapSetting.AncMode>()
if (currentAnc != null) {
_state.value = currentState
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode))
.copy(pendingAncMode = null, lastMessageAt = Instant.now())
} else {
_state.value = currentState.copy(pendingAncMode = null)
}
}
val preSendDeviceInfo = currentState.deviceInfo
applyOptimisticUpdate(currentState, command)
try {
sendRaw(command)
} catch (e: Exception) {
// Rollback is scoped to rename — the name is the one user-visible writable field
// that has no device echo to correct an incorrect optimistic update, so a failed
// send would otherwise leave the UI permanently lying about the device name.
if (command is AapCommand.SetDeviceName && preSendDeviceInfo != null) {
_state.value = _state.value.copy(deviceInfo = preSendDeviceInfo)
}
throw e
}
}
/**
* Optimistically update state for non-ANC settings so the UI reflects the change immediately.
* ANC is handled separately in [send] due to ear-detection gating.
* If the setting hasn't been reported by the device yet, skip (UI wouldn't show the toggle).
*/
private fun applyOptimisticUpdate(baseState: AapPodState, command: AapCommand) {
val updated: Pair<KClass<out AapSetting>, AapSetting> = when (command) {
is AapCommand.SetAncMode -> return // Handled in send()
is AapCommand.SetConversationalAwareness -> {
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetNcWithOneAirPod -> {
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetVolumeSwipe -> {
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetPersonalizedVolume -> {
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetToneVolume -> {
baseState.setting<AapSetting.ToneVolume>() ?: return
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
}
is AapCommand.SetAdaptiveAudioNoise -> {
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
}
is AapCommand.SetPressSpeed -> {
baseState.setting<AapSetting.PressSpeed>() ?: return
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
}
is AapCommand.SetPressHoldDuration -> {
baseState.setting<AapSetting.PressHoldDuration>() ?: return
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
}
is AapCommand.SetVolumeSwipeLength -> {
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
}
is AapCommand.SetEndCallMuteMic -> {
baseState.setting<AapSetting.EndCallMuteMic>() ?: return
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall)
}
is AapCommand.SetMicrophoneMode -> {
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
}
is AapCommand.SetEarDetectionEnabled -> {
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
}
is AapCommand.SetListeningModeCycle -> {
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
}
is AapCommand.SetAllowOffOption -> {
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
}
is AapCommand.SetStemConfig -> {
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
}
is AapCommand.SetSleepDetection -> {
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
}
is AapCommand.SetDeviceName -> {
val currentInfo = baseState.deviceInfo ?: return
_state.value = baseState
.copy(deviceInfo = currentInfo.copy(name = command.name), lastMessageAt = Instant.now())
return
}
}
_state.value = baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = Instant.now())
}
private suspend fun sendRaw(command: AapCommand) {
val bytes = profile.encodeCommand(command)
writeMutex.withLock {
withContext(Dispatchers.IO) {
val sock = socket ?: throw IOException("Socket is null")
sock.outputStream.write(bytes)
sock.outputStream.flush()
if (command is AapCommand.SetAncMode) lastAncCommandSentAt = System.currentTimeMillis()
log(TAG) { "Sent command: $command (${bytes.size} bytes)" }
}
}
}
private suspend fun readLoop(sock: BluetoothSocket) = withContext(Dispatchers.IO) {
val buf = ByteArray(2048)
var handshakeResponseReceived = false
try {
while (isActive) {
val len = sock.inputStream.read(buf)
if (len == -1) {
log(TAG) { "Stream closed by remote" }
break
}
// L2CAP SEQPACKET: each read() returns exactly one complete message
val raw = buf.copyOfRange(0, len)
val message = AapMessage.Companion.parse(raw)
if (message != null) {
processMessage(message)
if (!handshakeResponseReceived && message.commandType != 0x0009) {
handshakeResponseReceived = true
}
}
// Transition to READY after processing first batch of messages
if (handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
}
} catch (e: IOException) {
if (isActive) log(TAG, ERROR) { "Read error: $e" }
} finally {
ancDebounceJob?.cancel()
ancResendJob?.cancel()
pendingAncMode = null
lastCommandedAncMode = null
cleanupSocket()
_state.value = _state.value.copy(
connectionState = AapPodState.ConnectionState.DISCONNECTED,
pendingAncMode = null,
)
}
}
private fun processMessage(message: AapMessage) {
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
// Issue #173: diagnostic dump of the 0x1D INFORMATION packet so testers with engraved AirPods
// can share a debug recording that reveals where (or whether) the engraving message lives.
// Runs unconditionally for 0x1D — not gated on decodeDeviceInfo success, since an engraving-
// shaped packet may not match the strict production decoder's expectations.
if (message.commandType == 0x001D) {
val segments = describeDeviceInfoSegments(message.payload)
log(TAG, INFO) { "DeviceInfoDump #173: payload=${message.payload.size} bytes, segments=${segments.size}" }
segments.forEach { seg ->
val label = when (seg.index) {
0 -> "name"
1 -> "modelNumber"
2 -> "manufacturer"
3 -> "serialNumber"
4 -> "firmwareVersion"
else -> "unknown"
}
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
log(TAG, INFO) { "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" }
}
}
// Try stem press event (transient — emitted via SharedFlow, not stored in state)
profile.decodeStemPress(message)?.let { event ->
_stemPressEvents.tryEmit(event)
log(TAG) { "Stem press: ${event.pressType} ${event.bud}" }
return
}
// Try battery
profile.decodeBattery(message)?.let { batteries ->
// Filter DISCONNECTED entries (e.g. case reports 0% DISCONNECTED when pods are removed).
// Merge with existing state so previously-known values are preserved for absent slots.
val valid = batteries.filterValues { it.charging != AapPodState.ChargingState.DISCONNECTED }
_state.value = _state.value.copy(
batteries = _state.value.batteries + valid,
lastMessageAt = Instant.now(),
)
log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" }
return
}
// Try private key response
profile.decodePrivateKeyResponse(message)?.let { keys ->
log(TAG) { "Private keys received: IRK=${keys.irk != null}, ENC=${keys.encKey != null}" }
_state.value = _state.value.copy(lastMessageAt = Instant.now())
_keysReceived.tryEmit(keys)
return
}
// Try device info
profile.decodeDeviceInfo(message)?.let { info ->
_state.value = _state.value.copy(deviceInfo = info, lastMessageAt = Instant.now())
log(TAG) { "Device info: ${info.name} (${info.modelNumber})" }
return
}
// Try setting update (merge into existing state)
profile.decodeSetting(message)?.let { (key, value) ->
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
// Skip debounce for: first ANC mode (initial setup), echoes after our own command.
if (value is AapSetting.AncMode) {
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
val sinceLastCommand = System.currentTimeMillis() - lastAncCommandSentAt
if (isFirstAncMode || sinceLastCommand <= 3000L) {
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
log(TAG) { "Setting: ${key.simpleName} = $value" }
// After our command, firmware may cycle through modes before settling.
// Schedule a verification: if settled mode != commanded mode, re-send once.
lastCommandedAncMode?.let { commanded ->
ancResendJob?.cancel()
ancResendJob = connectionScope?.launch {
delay(1000L)
val current = _state.value.setting<AapSetting.AncMode>()?.current
if (current != null && current != commanded) {
log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" }
lastCommandedAncMode = null
sendRaw(AapCommand.SetAncMode(commanded))
} else {
lastCommandedAncMode = null
}
}
}
} else {
ancDebounceJob?.cancel()
ancDebounceJob = connectionScope?.launch {
delay(1500L)
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
log(TAG) { "Setting (debounced): ${key.simpleName} = $value" }
}
}
return
}
// Detect ear detection role swap — clear stale PrimaryPod until 0x0008 refreshes it
val clearPrimaryPod = value is AapSetting.EarDetection && run {
val prev = _state.value.setting<AapSetting.EarDetection>()
prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod
}
var newState = _state.value.withSetting(key, value).copy(lastMessageAt = Instant.now())
if (clearPrimaryPod) {
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
}
_state.value = newState
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""}" }
// Flush queued ANC command when a pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
pendingAncMode?.let { mode ->
pendingAncMode = null
// Optimistic update — show target mode immediately, don't wait for device echo
val currentAnc = _state.value.setting<AapSetting.AncMode>()
if (currentAnc != null) {
_state.value = _state.value
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = mode))
.copy(pendingAncMode = null, lastMessageAt = Instant.now())
} else {
_state.value = _state.value.copy(pendingAncMode = null)
}
log(TAG) { "Pod in ear, sending queued ANC mode: $mode" }
connectionScope?.launch { sendRaw(AapCommand.SetAncMode(mode)) }
}
}
return
}
log(TAG) { "Unhandled message: cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B" }
}
private fun cleanupSocket() {
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
}
companion object {
private val TAG = logTag("AapConnection")
/**
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the
* issue #173 engraving discovery logging. Not part of the production decode path — the
* production parser [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile]
* intentionally stays ASCII-only to preserve existing wire-format assumptions.
*
* Skips binary header bytes until the first printable ASCII byte (mirroring the production
* decoder's start condition — all known captures begin the real data with the device name,
* which is always ASCII like "AirPods Pro"), then splits on NUL bytes. Each non-empty chunk
* becomes one [DeviceInfoSegment]. UTF-8 decode is attempted strictly; if a chunk contains
* invalid UTF-8 (e.g. the post-serials encrypted blob), [DeviceInfoSegment.utf8] is null
* and the caller falls back to [DeviceInfoSegment.hex].
*
* Offsets are relative to the supplied payload (post message-header), matching what the
* caller holds in [AapMessage.payload].
*/
internal fun describeDeviceInfoSegments(payload: ByteArray): List<DeviceInfoSegment> {
var start = 0
while (start < payload.size) {
val b = payload[start].toInt() and 0xFF
if (b in 0x20..0x7E) break
start++
}
if (start >= payload.size) return emptyList()
val segments = mutableListOf<DeviceInfoSegment>()
var segIndex = 0
var i = start
while (i < payload.size) {
while (i < payload.size && payload[i] == 0x00.toByte()) i++
if (i >= payload.size) break
val segStart = i
while (i < payload.size && payload[i] != 0x00.toByte()) i++
val segBytes = payload.copyOfRange(segStart, i)
val utf8: String? = try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(segBytes))
.toString()
} catch (_: CharacterCodingException) {
null
}
val hex = segBytes.joinToString("") { "%02X".format(it) }
segments.add(
DeviceInfoSegment(
index = segIndex,
offset = segStart,
length = segBytes.size,
utf8 = utf8,
hex = hex,
)
)
segIndex++
}
return segments
}
internal data class DeviceInfoSegment(
val index: Int,
val offset: Int,
val length: Int,
val utf8: String?,
val hex: String,
)
}
}
@@ -0,0 +1,150 @@
package eu.darken.capod.pods.core.apple.aap
import android.bluetooth.BluetoothDevice
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.pods.core.apple.PodModel
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.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton managing all AAP L2CAP connections.
* Keyed by [BluetoothAddress] (stable across scan gaps and app restarts for bonded devices).
* Connections are owned internally — consumers interact via [sendCommand] and observe via [allStates].
*/
@Singleton
class AapConnectionManager @Inject constructor(
private val socketFactory: L2capSocketFactory,
@AppScope private val scope: CoroutineScope,
) {
companion object {
private val TAG = logTag("AapConnectionMgr")
}
private val mutex = Mutex()
private val connections = mutableMapOf<BluetoothAddress, AapConnection>()
private val connectionJobs = mutableMapOf<BluetoothAddress, Job>()
private val intentionalDisconnects = mutableSetOf<BluetoothAddress>()
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
val keysReceived: SharedFlow<Pair<BluetoothAddress, KeyExchangeResult>> = _keysReceived.asSharedFlow()
/** Emits transient stem press events from any connected device. */
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
suspend fun connect(
address: BluetoothAddress,
device: BluetoothDevice,
model: PodModel,
) = mutex.withLock {
if (connections.containsKey(address)) {
log(TAG) { "Already connected to $address" }
return
}
// Clear stale intentional-disconnect flag from previous connection lifecycle
intentionalDisconnects.remove(address)
val profile = AapDeviceProfile.Companion.forModel(model)
val connection = AapConnection(device, profile, socketFactory)
connections[address] = connection
try {
connection.connect(scope)
} catch (e: Exception) {
connections.remove(address)
throw e
}
// Collect connection state and propagate live updates to allStates.
// The coroutine cancels itself after handling DISCONNECTED,
// which also cancels the child key-forwarding coroutine.
connectionJobs[address] = scope.launch {
// Forward private keys from this connection (child coroutine)
launch {
connection.keysReceived.collect { keys ->
_keysReceived.tryEmit(address to keys)
}
}
// Forward stem press events from this connection (child coroutine)
launch {
connection.stemPressEvents.collect { event ->
_stemPressEvents.tryEmit(address to event)
}
}
connection.state.collect { podState ->
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "Connection to $address disconnected" }
val wasIntentional = mutex.withLock {
connections.remove(address)
connectionJobs.remove(address)
connection.disconnect()
_allStates.update { it - address }
val intentional = address in intentionalDisconnects
intentionalDisconnects.remove(address)
intentional
}
if (!wasIntentional) {
_disconnectEvents.tryEmit(address)
}
// End this collector coroutine (also cancels child key-forwarding coroutine)
cancel()
} else {
_allStates.update { it + (address to podState) }
}
}
}
}
suspend fun disconnect(address: BluetoothAddress) = mutex.withLock {
intentionalDisconnects.add(address)
val connection = connections.remove(address) ?: run {
intentionalDisconnects.remove(address)
return
}
connectionJobs.remove(address)?.cancel()
connection.disconnect()
_allStates.update { it - address }
}
suspend fun sendCommand(address: BluetoothAddress, command: AapCommand) {
val connection = mutex.withLock { connections[address] }
?: throw IllegalStateException("No connection for $address")
connection.send(command)
}
}
@@ -0,0 +1,93 @@
package eu.darken.capod.pods.core.apple.aap
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
import kotlin.reflect.KClass
/**
* Pure data representing the current state of an AAP connection. No connection handle.
*/
data class AapPodState(
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
val deviceInfo: AapDeviceInfo? = null,
val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(),
val batteries: Map<BatteryType, Battery> = emptyMap(),
val lastMessageAt: Instant? = null,
val pendingAncMode: AapSetting.AncMode.Value? = null,
) {
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
fun withSetting(key: KClass<out AapSetting>, value: AapSetting): AapPodState =
copy(settings = settings + (key to value))
// Ear detection — from AAP command 0x06
val aapEarDetection: AapSetting.EarDetection?
get() = setting<AapSetting.EarDetection>()
// Primary pod identity — from AAP command 0x08
val aapPrimaryPod: AapSetting.PrimaryPod?
get() = setting<AapSetting.PrimaryPod>()
val isEitherPodInEar: Boolean?
get() = aapEarDetection?.isEitherPodInEar
// Battery — from AAP command 0x04, 1% granularity
val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent
val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent
val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent
val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent
// Charging state from AAP battery
val isLeftCharging: Boolean?
get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
val isRightCharging: Boolean?
get() = batteries[BatteryType.RIGHT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
val isCaseCharging: Boolean?
get() = batteries[BatteryType.CASE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
val isHeadsetCharging: Boolean?
get() = batteries[BatteryType.SINGLE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
enum class ConnectionState {
DISCONNECTED,
CONNECTING,
HANDSHAKING,
READY,
}
/** Battery entry from an AAP battery notification (command 0x04). */
data class Battery(
val type: BatteryType,
val percent: Float,
val charging: ChargingState,
)
enum class BatteryType(val wireValue: Int) {
SINGLE(0x01),
RIGHT(0x02),
LEFT(0x04),
CASE(0x08),
;
companion object {
fun fromWire(value: Int): BatteryType? = entries.firstOrNull { it.wireValue == value }
}
}
enum class ChargingState(val wireValue: Int) {
// Pro 1 sends 0x00 transiently when a pod is first removed from the case (reverts to NOT_CHARGING within seconds).
// Pro 2 USB-C skips this and reports NOT_CHARGING immediately. Likely a firmware timing difference.
UNDEFINED(0x00),
CHARGING(0x01),
NOT_CHARGING(0x02),
DISCONNECTED(0x04),
// Observed on Pro 3 and Pro 2 USB-C in charging case. Apple's "Optimized Battery Charging" feature.
// Not observed on Pro 1 (firmware 51.9.6) — may require newer firmware or hardware.
CHARGING_OPTIMIZED(0x05),
;
companion object {
fun fromWire(value: Int): ChargingState = entries.firstOrNull { it.wireValue == value } ?: UNDEFINED
}
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Outbound commands to change device settings. Pure domain — no wire protocol bytes.
* The [AapDeviceProfile] encodes these into the device-specific wire format.
*/
sealed class AapCommand {
data class SetAncMode(val mode: AapSetting.AncMode.Value) : AapCommand()
data class SetConversationalAwareness(val enabled: Boolean) : AapCommand()
data class SetPressSpeed(val value: AapSetting.PressSpeed.Value) : AapCommand()
data class SetPressHoldDuration(val value: AapSetting.PressHoldDuration.Value) : AapCommand()
data class SetNcWithOneAirPod(val enabled: Boolean) : AapCommand()
data class SetToneVolume(val level: Int) : AapCommand()
data class SetVolumeSwipeLength(val value: AapSetting.VolumeSwipeLength.Value) : AapCommand()
data class SetEndCallMuteMic(val muteMic: AapSetting.EndCallMuteMic.MuteMicMode, val endCall: AapSetting.EndCallMuteMic.EndCallMode) : AapCommand()
data class SetVolumeSwipe(val enabled: Boolean) : AapCommand()
data class SetPersonalizedVolume(val enabled: Boolean) : AapCommand()
data class SetAdaptiveAudioNoise(val level: Int) : AapCommand()
data class SetMicrophoneMode(val mode: AapSetting.MicrophoneMode.Mode) : AapCommand()
data class SetEarDetectionEnabled(val enabled: Boolean) : AapCommand()
data class SetListeningModeCycle(val modeMask: Int) : AapCommand()
data class SetAllowOffOption(val enabled: Boolean) : AapCommand()
data class SetStemConfig(val claimedPressMask: Int) : AapCommand()
data class SetSleepDetection(val enabled: Boolean) : AapCommand()
data class SetDeviceName(val name: String) : AapCommand()
}
@@ -0,0 +1,12 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Device identity parsed from the AAP handshake response (message type 0x1D).
*/
data class AapDeviceInfo(
val name: String,
val modelNumber: String,
val manufacturer: String,
val serialNumber: String,
val firmwareVersion: String,
)
@@ -0,0 +1,79 @@
package eu.darken.capod.pods.core.apple.aap.protocol
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import kotlin.reflect.KClass
/**
* Per-model mapping between AAP wire format and domain types.
* All wire protocol bytes are encapsulated here — sealed classes [AapSetting] and [AapCommand]
* contain zero protocol knowledge.
*
* If Apple changes a setting ID or value encoding for a new model, only the profile changes.
*/
interface AapDeviceProfile {
/**
* Decode a single message into a setting update.
* Returns null if the message is not a recognized setting.
* Callers merge the returned pair into existing state (incremental update).
*/
fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>?
/**
* Decode a battery notification (command 0x04).
* Returns null if the message is not a battery update.
*/
fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>?
/**
* Decode a device info message (typically command type 0x001D).
*/
fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo?
/**
* Encode a domain command into wire bytes ready to send.
* @throws UnsupportedOperationException if the command is not supported by this profile.
*/
fun encodeCommand(command: AapCommand): ByteArray
/**
* Encode the handshake message that initiates the AAP session.
*/
fun encodeHandshake(): ByteArray
/**
* Encode notification enable packets. These tell the device to push
* battery, settings, and other event notifications.
* Sent after the handshake, before the read loop starts.
*/
fun encodeNotificationEnable(): List<ByteArray>
/**
* Encode the extended init packet (0x4D) for models that require it
* (e.g., Pro 2/3/USB-C, AirPods 4 ANC). Returns null if not needed.
*/
fun encodeInitExt(): ByteArray?
/**
* Encode a private key request (command 0x30).
* Returns null if the model doesn't support key exchange.
*/
fun encodePrivateKeyRequest(): ByteArray?
/**
* Decode a private key response (command 0x31).
* Returns null if the message is not a key response.
*/
fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult?
/**
* Decode a stem press event (command 0x19).
* Returns null if the message is not a stem press event.
*/
fun decodeStemPress(message: AapMessage): StemPressEvent?
companion object {
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model)
}
}
@@ -0,0 +1,79 @@
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.
*
* Raw L2CAP reads can return partial or multiple messages in a single read.
* The framer buffers partial data and splits multi-message reads.
*
* AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian)
* indicate total message length (excluding the first 4 header bytes).
*/
class AapFramer {
private val buffer = mutableListOf<Byte>()
/**
* Feed raw bytes from a socket read. Returns any complete messages found.
*/
fun consume(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size): List<AapMessage> {
for (i in offset until offset + length) {
buffer.add(bytes[i])
}
val messages = mutableListOf<AapMessage>()
while (buffer.size >= 4) {
// Bytes 2-3 (little-endian) = payload length after the 4-byte header
val payloadLength = (buffer[2].toInt() and 0xFF) or ((buffer[3].toInt() and 0xFF) shl 8)
val totalLength = 4 + payloadLength
if (buffer.size < totalLength) break // Need more data
val messageBytes = ByteArray(totalLength)
for (i in 0 until totalLength) {
messageBytes[i] = buffer[i]
}
buffer.subList(0, totalLength).clear()
AapMessage.parse(messageBytes)?.let { messages.add(it) }
}
return messages
}
fun reset() {
buffer.clear()
}
}
@@ -0,0 +1,187 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Device-reported settings. Pure domain — no wire protocol bytes.
* Each subclass represents a capability with its current state and supported values.
* The [AapDeviceProfile] handles all wire ↔ domain translation.
*/
sealed class AapSetting {
data class AncMode(
val current: Value,
val supported: List<Value>,
) : AapSetting() {
enum class Value {
OFF, ON, TRANSPARENCY, ADAPTIVE,
}
}
data class ConversationalAwareness(
val enabled: Boolean,
) : AapSetting()
data class PressSpeed(
val value: Value,
) : AapSetting() {
enum class Value(val wireValue: Int) {
DEFAULT(0x00), SLOWER(0x01), SLOWEST(0x02);
companion object {
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class PressHoldDuration(
val value: Value,
) : AapSetting() {
enum class Value(val wireValue: Int) {
DEFAULT(0x00), SHORTER(0x01), SHORTEST(0x02);
companion object {
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class NcWithOneAirPod(
val enabled: Boolean,
) : AapSetting()
data class ToneVolume(
val level: Int,
) : AapSetting()
data class VolumeSwipeLength(
val value: Value,
) : AapSetting() {
enum class Value(val wireValue: Int) {
DEFAULT(0x00), LONGER(0x01), LONGEST(0x02);
companion object {
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class EndCallMuteMic(
val muteMic: MuteMicMode,
val endCall: EndCallMode,
) : AapSetting() {
enum class MuteMicMode(val wireValue: Int) {
SINGLE_PRESS(0x23), DOUBLE_PRESS(0x22);
companion object {
fun fromWire(value: Int): MuteMicMode? = entries.firstOrNull { it.wireValue == value }
}
}
enum class EndCallMode(val wireValue: Int) {
DOUBLE_PRESS(0x02), SINGLE_PRESS(0x03);
companion object {
fun fromWire(value: Int): EndCallMode? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class VolumeSwipe(
val enabled: Boolean,
) : AapSetting()
data class PersonalizedVolume(
val enabled: Boolean,
) : AapSetting()
data class AdaptiveAudioNoise(
val level: Int,
) : AapSetting()
/** Push-only from device — reports speaking detection state (command 0x4B). */
data class ConversationalAwarenessState(
val speaking: Boolean,
) : AapSetting()
data class MicrophoneMode(
val mode: Mode,
) : AapSetting() {
enum class Mode(val wireValue: Int) {
AUTO(0x00), ALWAYS_RIGHT(0x01), ALWAYS_LEFT(0x02);
companion object {
fun fromWire(value: Int): Mode? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class EarDetectionEnabled(
val enabled: Boolean,
) : AapSetting()
data class ListeningModeCycle(
val modeMask: Int,
) : AapSetting() {
val includesOff: Boolean get() = (modeMask and 0x01) != 0
val includesAnc: Boolean get() = (modeMask and 0x02) != 0
val includesTransparency: Boolean get() = (modeMask and 0x04) != 0
val includesAdaptive: Boolean get() = (modeMask and 0x08) != 0
}
data class AllowOffOption(
val enabled: Boolean,
) : AapSetting()
data class StemConfig(
val claimedPressMask: Int,
) : AapSetting() {
val claimsSinglePress: Boolean get() = (claimedPressMask and 0x01) != 0
val claimsDoublePress: Boolean get() = (claimedPressMask and 0x02) != 0
val claimsTriplePress: Boolean get() = (claimedPressMask and 0x04) != 0
val claimsLongPress: Boolean get() = (claimedPressMask and 0x08) != 0
}
data class SleepDetection(
val enabled: Boolean,
) : AapSetting()
data class InCaseTone(
val enabled: Boolean,
) : AapSetting()
data class ConnectedDevices(
val devices: List<ConnectedDevice>,
) : AapSetting() {
data class ConnectedDevice(val mac: String, val type: Int)
}
data class AudioSource(
val sourceMac: String?,
val type: AudioSourceType,
) : AapSetting() {
enum class AudioSourceType { NONE, CALL, MEDIA }
}
data class EqBands(
val sets: List<List<Float>>,
) : AapSetting()
/** Per-pod placement reported by the device (command 0x06). */
data class EarDetection(
val primaryPod: PodPlacement,
val secondaryPod: PodPlacement,
) : AapSetting() {
enum class PodPlacement {
IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED,
}
val isEitherPodInEar: Boolean
get() = primaryPod == PodPlacement.IN_EAR || secondaryPod == PodPlacement.IN_EAR
}
/** Which physical pod currently holds the microphone (command 0x08). */
data class PrimaryPod(
val pod: Pod,
) : AapSetting() {
enum class Pod { LEFT, RIGHT }
}
}
@@ -0,0 +1,492 @@
package eu.darken.capod.pods.core.apple.aap.protocol
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import kotlin.reflect.KClass
/**
* Default AAP device profile covering the known protocol from MagicPodsCore + LibrePods research.
* Handles the common wire format used by all Apple/Beats devices over L2CAP PSM 0x1001.
*
* Model-specific behavior (supported ANC modes, InitExt, feature gating) is driven by
* [PodModel.features] — no subclassing needed.
*/
class DefaultAapDeviceProfile(
private val model: PodModel = PodModel.UNKNOWN,
) : AapDeviceProfile {
companion object {
// AAP command types (bytes 4-5 of the message, little-endian)
const val CMD_SETTINGS = 0x0009
const val CMD_BATTERY = 0x0004
const val CMD_DEVICE_INFO = 0x001D
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
const val CMD_EAR_DETECTION = 0x0006
const val CMD_PRIMARY_POD = 0x0008
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
// Setting IDs (first byte of settings command payload)
const val SETTING_ANC_MODE = 0x0D
const val SETTING_PRESS_SPEED = 0x17
const val SETTING_PRESS_HOLD_DURATION = 0x18
const val SETTING_NC_ONE_AIRPOD = 0x1B
const val SETTING_TONE_VOLUME = 0x1F
const val SETTING_VOLUME_SWIPE_LENGTH = 0x23
const val SETTING_END_CALL_MUTE_MIC = 0x24
const val SETTING_VOLUME_SWIPE = 0x25
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
// 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
const val ANC_WIRE_OFF = 0x01
const val ANC_WIRE_ON = 0x02
const val ANC_WIRE_TRANSPARENCY = 0x03
const val ANC_WIRE_ADAPTIVE = 0x04
}
private val supportedAncModes: List<AapSetting.AncMode.Value> by lazy {
val features = model.features
when {
!features.hasAncControl -> emptyList()
features.hasAdaptiveAnc -> listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE)
else -> listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY)
}
}
override fun encodeHandshake(): ByteArray = byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
override fun encodeNotificationEnable(): List<ByteArray> = listOf(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xef.toByte(), 0xff.toByte()),
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte()),
)
override fun encodeInitExt(): ByteArray? {
if (!model.features.needsInitExt) return null
return byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0x0e, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
)
}
override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode))
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled))
is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue)
is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue)
is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled))
is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64))
is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue)
is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled))
is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled))
is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, command.level.coerceIn(0, 100))
is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall)
is AapCommand.SetMicrophoneMode -> buildSettingsMessage(SETTING_MICROPHONE_MODE, command.mode.wireValue)
is AapCommand.SetEarDetectionEnabled -> buildSettingsMessage(SETTING_EAR_DETECTION_ENABLED, encodeAppleBool(command.enabled))
is AapCommand.SetListeningModeCycle -> buildSettingsMessage(SETTING_LISTENING_MODE_CYCLE, command.modeMask and 0x0F)
is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled))
is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F)
is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled))
is AapCommand.SetDeviceName -> buildRenameMessage(command.name)
}
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
// Primary pod identity (push-only, fires on mic/primary swap)
if (message.commandType == CMD_PRIMARY_POD) {
if (message.payload.size < 4) return null
val podId = message.payload[0].toInt() and 0xFF
// Validate known fixed bytes: [podId] 00 [00|01] [00|01]
// Pro 3: byte[2]=0x01 always. Pro 1: byte[2]=0x00 on initial, 0x01 on swap.
if ((message.payload[1].toInt() and 0xFF) != 0x00) return null
val byte2 = message.payload[2].toInt() and 0xFF
if (byte2 != 0x00 && byte2 != 0x01) return null
val pod = when (podId) {
0x01 -> AapSetting.PrimaryPod.Pod.LEFT
0x02 -> AapSetting.PrimaryPod.Pod.RIGHT
else -> return null
}
return AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(pod)
}
// Ear detection is a separate command type (push-only from device)
if (message.commandType == CMD_EAR_DETECTION) {
if (message.payload.size < 2) return null
return AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = decodePodPlacement(message.payload[0].toInt() and 0xFF),
secondaryPod = decodePodPlacement(message.payload[1].toInt() and 0xFF),
)
}
// Connected devices list (push-only from device)
if (message.commandType == CMD_CONNECTED_DEVICES) {
if (message.payload.size < 3) return null
val count = message.payload[2].toInt() and 0xFF
val devices = mutableListOf<AapSetting.ConnectedDevices.ConnectedDevice>()
var offset = 3
for (i in 0 until count) {
if (offset + 8 > message.payload.size) break
val mac = (0 until 6).map { "%02X".format(message.payload[offset + it]) }.joinToString(":")
val type = message.payload[offset + 6].toInt() and 0xFF
devices.add(AapSetting.ConnectedDevices.ConnectedDevice(mac, type))
offset += 8
}
return AapSetting.ConnectedDevices::class to AapSetting.ConnectedDevices(devices)
}
// Audio source tracking (push-only from device)
if (message.commandType == CMD_AUDIO_SOURCE) {
if (message.payload.size < 7) return null
val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":")
val typeValue = message.payload[6].toInt() and 0xFF
val type = when (typeValue) {
0x01 -> AapSetting.AudioSource.AudioSourceType.CALL
0x02 -> AapSetting.AudioSource.AudioSourceType.MEDIA
else -> AapSetting.AudioSource.AudioSourceType.NONE
}
return AapSetting.AudioSource::class to AapSetting.AudioSource(mac, type)
}
// EQ data (push-only from device)
if (message.commandType == CMD_EQ_DATA) {
if (message.payload.size < 6 + 128) return null
val sets = mutableListOf<List<Float>>()
var offset = 6 // skip header
for (s in 0 until 4) {
val bands = mutableListOf<Float>()
for (b in 0 until 8) {
val bits = (message.payload[offset].toInt() and 0xFF) or
((message.payload[offset + 1].toInt() and 0xFF) shl 8) or
((message.payload[offset + 2].toInt() and 0xFF) shl 16) or
((message.payload[offset + 3].toInt() and 0xFF) shl 24)
bands.add(Float.fromBits(bits))
offset += 4
}
sets.add(bands)
}
return AapSetting.EqBands::class to AapSetting.EqBands(sets)
}
// Conversation Awareness State is a separate command type (push-only)
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) {
if (message.payload.isEmpty()) return null
val value = message.payload[0].toInt() and 0xFF
val speaking = value == 0x01
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
}
if (message.commandType != CMD_SETTINGS) return null
if (message.payload.size < 2) return null
val settingId = message.payload[0].toInt() and 0xFF
val value = message.payload[1].toInt() and 0xFF
return when (settingId) {
SETTING_ANC_MODE -> {
val mode = decodeAncMode(value) ?: return null
AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes)
}
SETTING_CONVERSATIONAL_AWARENESS -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled)
}
SETTING_PRESS_SPEED -> {
val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null
AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed)
}
SETTING_PRESS_HOLD_DURATION -> {
val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration)
}
SETTING_NC_ONE_AIRPOD -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled)
}
SETTING_TONE_VOLUME -> {
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value)
}
SETTING_VOLUME_SWIPE_LENGTH -> {
val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length)
}
SETTING_END_CALL_MUTE_MIC -> {
decodeEndCallMuteMic(message.payload)
}
SETTING_VOLUME_SWIPE -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled)
}
SETTING_PERSONALIZED_VOLUME -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled)
}
SETTING_ADAPTIVE_AUDIO_NOISE -> {
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = value)
}
SETTING_MICROPHONE_MODE -> {
val mode = AapSetting.MicrophoneMode.Mode.fromWire(value) ?: return null
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode)
}
SETTING_EAR_DETECTION_ENABLED -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled)
}
SETTING_LISTENING_MODE_CYCLE -> {
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = value)
}
SETTING_ALLOW_OFF_OPTION -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled)
}
SETTING_STEM_CONFIG -> {
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = value)
}
SETTING_SLEEP_DETECTION -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled)
}
SETTING_IN_CASE_TONE -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
}
else -> null
}
}
override fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>? {
if (message.commandType != CMD_BATTERY) return null
val payload = message.payload
if (payload.isEmpty()) return null
val count = payload[0].toInt() and 0xFF
if (count == 0) return emptyMap()
// Each battery entry is 5 bytes starting at offset 1
val result = mutableMapOf<AapPodState.BatteryType, AapPodState.Battery>()
var offset = 1
for (i in 0 until count) {
if (offset + 5 > payload.size) break
val type = AapPodState.BatteryType.fromWire(payload[offset].toInt() and 0xFF) ?: run {
offset += 5
continue
}
// payload[offset+1] is unknown/reserved
val percent = payload[offset + 2].toInt() and 0xFF
val charging = AapPodState.ChargingState.fromWire(payload[offset + 3].toInt() and 0xFF)
// payload[offset+4] is unknown/reserved
offset += 5
// Values above 100 are not valid battery percentages.
// Known cases: 127 (0x7F) = fake reading after case close, 255 (0xFF) = disconnected.
if (percent > 100) continue
result[type] = AapPodState.Battery(
type = type,
percent = percent / 100f,
charging = charging,
)
}
return result
}
override fun encodePrivateKeyRequest(): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x30, 0x00, 0x05, 0x00
)
override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? {
if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null
val payload = message.payload
if (payload.isEmpty()) return null
val keyCount = payload[0].toInt() and 0xFF
var irk: ByteArray? = null
var encKey: ByteArray? = null
var offset = 1
for (i in 0 until keyCount) {
// Each entry: keyType(1), unknown(1), keyLength(1), unknown(1), keyData(keyLength)
if (offset + 4 > payload.size) break
val keyType = payload[offset].toInt() and 0xFF
// offset+1 is unknown
val keyLength = payload[offset + 2].toInt() and 0xFF
// offset+3 is unknown
offset += 4
if (offset + keyLength > payload.size) break
val keyData = payload.copyOfRange(offset, offset + keyLength)
offset += keyLength
when (keyType) {
0x01 -> if (keyLength == 16) irk = keyData // IRK
0x04 -> if (keyLength == 16) encKey = keyData // ENC
}
}
return if (irk != null || encKey != null) KeyExchangeResult(irk, encKey) else null
}
override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? {
if (message.commandType != CMD_DEVICE_INFO) return null
if (message.payload.size < 10) return null
// Device info payload contains null-terminated ASCII strings
// Format: [length prefix] [strings...]
val strings = parseNullTerminatedStrings(message.payload)
if (strings.size < 4) return null
return AapDeviceInfo(
name = strings.getOrElse(0) { "" },
modelNumber = strings.getOrElse(1) { "" },
manufacturer = strings.getOrElse(2) { "" },
serialNumber = strings.getOrElse(3) { "" },
firmwareVersion = strings.getOrElse(4) { "" },
)
}
private fun decodePodPlacement(wireValue: Int): AapSetting.EarDetection.PodPlacement = when (wireValue) {
0x00 -> AapSetting.EarDetection.PodPlacement.IN_EAR
0x01 -> AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
0x02 -> AapSetting.EarDetection.PodPlacement.IN_CASE
else -> AapSetting.EarDetection.PodPlacement.DISCONNECTED
}
protected fun encodeAncMode(mode: AapSetting.AncMode.Value): Int = when (mode) {
AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF
AapSetting.AncMode.Value.ON -> ANC_WIRE_ON
AapSetting.AncMode.Value.TRANSPARENCY -> ANC_WIRE_TRANSPARENCY
AapSetting.AncMode.Value.ADAPTIVE -> ANC_WIRE_ADAPTIVE
}
protected fun decodeAncMode(wireValue: Int): AapSetting.AncMode.Value? = when (wireValue) {
ANC_WIRE_OFF -> AapSetting.AncMode.Value.OFF
ANC_WIRE_ON -> AapSetting.AncMode.Value.ON
ANC_WIRE_TRANSPARENCY -> AapSetting.AncMode.Value.TRANSPARENCY
ANC_WIRE_ADAPTIVE -> AapSetting.AncMode.Value.ADAPTIVE
else -> null
}
/** Standard Apple boolean encoding: 0x01=on, 0x02=off. */
private fun encodeAppleBool(enabled: Boolean): Int = if (enabled) 0x01 else 0x02
/** Decode Apple boolean: 0x01=true, 0x02=false, anything else=null (unknown). */
private fun decodeAppleBool(wireValue: Int): Boolean? = when (wireValue) {
0x01 -> true
0x02 -> false
else -> null
}
private fun buildSettingsMessage(settingId: Int, value: Int): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
settingId.toByte(), value.toByte(),
0x00, 0x00, 0x00,
)
/** EndCallMuteMic uses a special 2-byte format: [0x24] [0x21] [muteMic] [endCall] [0x00] */
private fun buildEndCallMuteMicMessage(muteMic: AapSetting.EndCallMuteMic.MuteMicMode, endCall: AapSetting.EndCallMuteMic.EndCallMode): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
SETTING_END_CALL_MUTE_MIC.toByte(), 0x21,
muteMic.wireValue.toByte(), endCall.wireValue.toByte(),
0x00,
)
private fun decodeEndCallMuteMic(payload: ByteArray): Pair<KClass<out AapSetting>, AapSetting>? {
if (payload.size < 4) return null
val subType = payload[1].toInt() and 0xFF
return when (subType) {
0x21 -> {
// Standard format: byte 2 = muteMic, byte 3 = endCall
val muteMic = AapSetting.EndCallMuteMic.MuteMicMode.fromWire(payload[2].toInt() and 0xFF) ?: return null
val endCall = AapSetting.EndCallMuteMic.EndCallMode.fromWire(payload[3].toInt() and 0xFF) ?: return null
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
}
0x00, 0x20 -> {
// Compact response format: byte 2 is a combined mode value.
// 0x00: observed on Pro 1 (fw 51.9.6, 2026-04-02) and Pro 2 USB-C (fw 81.x, 2026-04-02) and Pro 3.
// 0x20: observed on Pro 1 (fw 51.9.6, 2026-03-31). Same device can send either subtype across sessions.
val combined = payload[2].toInt() and 0xFF
val (muteMic, endCall) = when (combined) {
0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
else -> return null
}
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
}
else -> null
}
}
override fun decodeStemPress(message: AapMessage): StemPressEvent? {
if (message.commandType != CMD_STEM_PRESS) return null
if (message.payload.size < 2) 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
return StemPressEvent(pressType, bud)
}
private fun buildRenameMessage(name: String): ByteArray {
// 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
// the rename, persists it, and echoes the new name back via the next 0x001D device info
// message on reconnect.
//
// Note: the LibrePods Android code (AACPManager.createRenamePacket) uses a DIFFERENT
// format with opcode 0x1E and a trailing NUL, but on-device testing shows the device
// silently ignores that variant. The Linux / documented format is the one that works.
//
// Scope: this only changes the AirPods firmware's self-reported name (what 0x001D
// returns). It does NOT update the phone's Bluetooth alias — Android's system Bluetooth
// settings read from the bond database, which is separate and would require
// BluetoothDevice.setAlias(). That's intentionally out of scope here.
val nameBytes = name.toByteArray(Charsets.UTF_8)
require(nameBytes.size <= 127) { "Device name too long: ${nameBytes.size} bytes (max 127)" }
return byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x1A, 0x00, 0x01,
nameBytes.size.toByte(), 0x00,
) + nameBytes
}
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
val strings = mutableListOf<String>()
var start = 0
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes.
// Header bytes and non-ASCII bytes are skipped.
var i = 0
while (i < data.size) {
val b = data[i].toInt() and 0xFF
if (b in 0x20..0x7E) {
start = i
while (i < data.size && data[i] != 0x00.toByte()) i++
strings.add(String(data, start, i - start, Charsets.US_ASCII))
}
i++
}
return strings
}
}
@@ -0,0 +1,29 @@
package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Result of parsing an AAP private key response (command 0x31).
* Contains the Identity Resolving Key (IRK) for BLE RPA verification
* and the Encryption Key (ENC) for BLE encrypted battery decryption.
*/
data class KeyExchangeResult(
val irk: ByteArray?,
val encKey: ByteArray?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is KeyExchangeResult) return false
return irk.contentEquals(other.irk) && encKey.contentEquals(other.encKey)
}
override fun hashCode(): Int {
var result = irk?.contentHashCode() ?: 0
result = 31 * result + (encKey?.contentHashCode() ?: 0)
return result
}
}
private fun ByteArray?.contentEquals(other: ByteArray?): Boolean = when {
this == null && other == null -> true
this != null && other != null -> this.contentEquals(other)
else -> false
}
@@ -0,0 +1,22 @@
package eu.darken.capod.pods.core.apple.aap.protocol
data class StemPressEvent(
val pressType: PressType,
val bud: Bud,
) {
enum class PressType(val wireValue: Int) {
SINGLE(0x05), DOUBLE(0x06), TRIPLE(0x07), LONG(0x08);
companion object {
fun fromWire(value: Int): PressType? = entries.firstOrNull { it.wireValue == value }
}
}
enum class Bud(val wireValue: Int) {
LEFT(0x01), RIGHT(0x02);
companion object {
fun fromWire(value: Int): Bud? = entries.firstOrNull { it.wireValue == value }
}
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.apple
package eu.darken.capod.pods.core.apple.ble
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -6,13 +6,15 @@ 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 eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.devices.misc.UnknownAppleSnapshotBle
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import eu.darken.capod.pods.core.apple.ble.protocol.RPAChecker
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.currentProfiles
@@ -27,7 +29,7 @@ class AppleFactory @Inject constructor(
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
private val unknownAppleFactory: UnknownAppleSnapshotBle.Factory,
private val rpaChecker: RPAChecker,
private val profilesRepo: DeviceProfilesRepo,
) {
@@ -59,7 +61,7 @@ class AppleFactory @Inject constructor(
return proximityMessage
}
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
suspend fun create(scanResult: BleScanResult): BlePodSnapshot? = lock.withLock {
val proximityMessage = getMessage(scanResult) ?: return@withLock null
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
@@ -98,7 +100,7 @@ class AppleFactory @Inject constructor(
meta = ApplePods.AppleMeta(),
)
profile = profiles
.filter { it.model == PodDevice.Model.UNKNOWN || it.model == tempDevice.model }
.filter { it.model == PodModel.UNKNOWN || it.model == tempDevice.model }
.firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality }
}
@@ -0,0 +1,79 @@
package eu.darken.capod.pods.core.apple.ble
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import eu.darken.capod.pods.core.apple.ble.devices.ApplePodsFactory
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen1
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen2
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen3
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen4
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsGen4Anc
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsMax
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsMaxUsbc
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2Usbc
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro3
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsFitPro
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsFlex
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSolo3
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSolo4
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSoloBuds
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsSoloPro
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudio3
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudioBuds
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudioBudsPlus
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsStudioPro
import eu.darken.capod.pods.core.apple.ble.devices.beats.BeatsX
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeats3
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeats4
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeatsPro
import eu.darken.capod.pods.core.apple.ble.devices.beats.PowerBeatsPro2
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsGen1
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsGen2
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsGen3
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsPro
import eu.darken.capod.pods.core.apple.ble.devices.misc.FakeAirPodsPro2
@InstallIn(SingletonComponent::class)
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro3(factory: AirPodsPro3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro2(factory: PowerBeatsPro2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsSoloPro(factory: BeatsSoloPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsSolo4(factory: BeatsSolo4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsSoloBuds(factory: BeatsSoloBuds.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsStudioBuds(factory: BeatsStudioBuds.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsStudioBudsPlus(factory: BeatsStudioBudsPlus.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsStudioPro(factory: BeatsStudioPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
}
@@ -0,0 +1,87 @@
package eu.darken.capod.pods.core.apple.ble
import android.content.Context
import androidx.annotation.DrawableRes
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.DeviceProfile
import java.time.Duration
import java.time.Instant
import java.util.UUID
import kotlin.math.max
interface BlePodSnapshot {
val identifier: Id
val model: PodModel
val address: BluetoothAddress
get() = scanResult.address
val seenLastAt: Instant
val seenFirstAt: Instant
val seenCounter: Int
val scanResult: BleScanResult
val rssi: Int
get() = scanResult.rssi
val reliability: Float
/**
* Pure RSSI quality for display bars — linear map of -100..-30 dBm → 0..1.
* Does not include reliability/age. Use for SignalIndicator only, not for
* device matching or sorting (that's signalQuality's job).
*/
val rssiQuality: Float
get() = ((rssi + 100) / 70f).coerceIn(0f, 1f)
/**
* Composite quality for profile matching and sorting. Weighted blend of
* RSSI strength, detection reliability, and observation age.
* Used by AppleFactory (minimumSignalQuality filter) and TroubleShooter
* (closest-device selection). Not used for display bars.
*/
val signalQuality: Float
get() {
val sqRssi = ((rssi + 100) / 70f).coerceIn(0f, 1f)
val sqReliability = max(BASE_CONFIDENCE, reliability)
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
return (sqRssi + sqReliability + sqAge) / 2f
}
val rawData: Map<Int, ByteArray>
get() = scanResult.manufacturerSpecificData
val rawDataHex: List<String>
get() = rawData.entries.map { entry ->
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
}
interface Meta {
val profile: DeviceProfile?
}
val meta: Meta
fun getLabel(context: Context): String = model.label
@get:DrawableRes
val iconRes: Int
get() = model.iconRes
@JvmInline
value class Id(private val id: UUID = UUID.randomUUID())
companion object {
const val BASE_CONFIDENCE = 0.0f
}
}
@@ -1,9 +1,9 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble
import androidx.annotation.DrawableRes
import eu.darken.capod.R
interface DualPodDevice : PodDevice {
interface DualBlePodSnapshot : BlePodSnapshot {
enum class Pod {
LEFT,
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble
import android.content.Context
import android.icu.text.RelativeDateTimeFormatter
@@ -18,11 +18,6 @@ fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun PodDevice.getSignalQuality(context: Context): String {
val multiplier = 100 * signalQuality
return "${multiplier.roundToInt()}%"
}
@DrawableRes
fun getBatteryDrawable(percent: Float?): Int = when {
percent == null -> R.drawable.ic_baseline_battery_unknown_24
@@ -36,7 +31,7 @@ fun getBatteryDrawable(percent: Float?): Int = when {
else -> R.drawable.ic_baseline_battery_0_bar_24
}
fun PodDevice.lastSeenFormatted(now: Instant): String {
fun BlePodSnapshot.lastSeenFormatted(now: Instant): String {
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(seenLastAt, now)
return if (duration > Duration.ofMinutes(1)) {
@@ -54,7 +49,7 @@ fun PodDevice.lastSeenFormatted(now: Instant): String {
}
}
fun PodDevice.firstSeenFormatted(now: Instant): String {
fun BlePodSnapshot.firstSeenFormatted(now: Instant): String {
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(seenFirstAt, now)
return formatter.format(
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble
import dagger.Reusable
import eu.darken.capod.common.bluetooth.BleScanResult
@@ -6,7 +6,6 @@ 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.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.AppleFactory
import eu.darken.capod.pods.core.unknown.UnknownDeviceFactory
import javax.inject.Inject
@@ -36,7 +35,7 @@ class PodFactory @Inject constructor(
data class Result(
val scanResult: BleScanResult,
val device: PodDevice
val device: BlePodSnapshot
)
companion object {
@@ -0,0 +1,6 @@
package eu.darken.capod.pods.core.apple.ble
interface SingleBlePodSnapshot : BlePodSnapshot {
val batteryHeadsetPercent: Float?
}
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple
package eu.darken.capod.pods.core.apple.ble.devices
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
interface ApplePods : PodDevice {
interface ApplePods : BlePodSnapshot {
val payload: ProximityPayload
@@ -59,7 +59,7 @@ interface ApplePods : PodDevice {
data class AppleMeta(
val isIRKMatch: Boolean = false,
override val profile: AppleDeviceProfile? = null,
) : PodDevice.Meta
) : BlePodSnapshot.Meta
override val meta: AppleMeta
}
@@ -1,9 +1,9 @@
package eu.darken.capod.pods.core.apple
package eu.darken.capod.pods.core.apple.ble.devices
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.pods.core.apple.history.KnownDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
interface ApplePodsFactory {
fun isResponsible(message: ProximityMessage): Boolean
@@ -1,17 +1,13 @@
package eu.darken.capod.pods.core.apple
package eu.darken.capod.pods.core.apple.ble.devices
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.DualPodDevice.Pod
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot.Pod
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot, HasEarDetectionDual, HasCase,
HasDualMicrophone, HasAppleColor {
val primaryPod: Pod
@@ -1,8 +1,7 @@
package eu.darken.capod.pods.core.apple
package eu.darken.capod.pods.core.apple.ble.devices
import android.R
import android.content.Context
import eu.darken.capod.pods.core.HasPodStyle
import eu.darken.capod.R
interface HasAppleColor : ApplePods, HasPodStyle {
@@ -29,7 +28,7 @@ interface HasAppleColor : ApplePods, HasPodStyle {
override fun getLabel(context: Context): String = this.name
override fun getColor(context: Context): Int = android.R.color.white
override fun getColor(context: Context): Int = R.color.white
override val identifier: String
get() = name
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
import androidx.annotation.DrawableRes
import eu.darken.capod.R
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
interface HasChargeDetection {
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
interface HasChargeDetectionDual : HasChargeDetection {
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
interface HasDualMicrophone {
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
interface HasEarDetection {
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
interface HasEarDetectionDual : HasEarDetection {
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
import android.content.Context
import androidx.annotation.ColorRes
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core
package eu.darken.capod.pods.core.apple.ble.devices
import android.content.Context
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple
package eu.darken.capod.pods.core.apple.ble.devices
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
/**
* Devices that only present a single charge level, e.g. most Beats devices
*/
interface SingleApplePods : ApplePods, SinglePodDevice, HasAppleColor {
interface SingleApplePods : ApplePods, SingleBlePodSnapshot, HasAppleColor {
override val batteryHeadsetPercent: Float?
get() {

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