Compare commits

...
100 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
darken 9d6ec2b6bb Release: 4.0.3-rc0 2026-03-23 14:06:37 +01:00
darken 9a85b23d91 chore: Enable support-investigator plugin in .claude/settings.json 2026-03-23 14:01:34 +01:00
darken 217d9be177 fix: Show key icon for single-pod devices when encryption keys are set 2026-03-23 13:00:48 +00:00
darken 53447f3fb9 fix: Stop retrying auto-connect after SecurityException
BluetoothHeadset.connect() requires MODIFY_PHONE_STATE on modern Android, which is a system-only permission. Detect the SecurityException and stop further attempts instead of retrying every second.
2026-03-23 11:24:23 +00:00
darken db759bac7c Release: 4.0.2-rc1 2026-03-23 06:11:39 +01:00
darken 6837db5b84 fix: Remove mapping file param from Fastlane supply lanes
AGP 9.0.1 no longer generates a separate mapping.txt for bundle tasks when -dontobfuscate is active. The mapping param was causing supply to fail on a non-existent file.
2026-03-23 06:11:09 +01:00
darken 83b24cced2 Release: 4.0.2-rc0 2026-03-22 23:16:51 +01:00
darken d913e72c5c General: Update app translations from Crowdin (retranslated strings) 2026-03-22 21:45:33 +00:00
darken 5c16c08dee General: Update hy-AM and th fastlane translations from Crowdin 2026-03-22 21:45:33 +00:00
darken 20e22c0910 General: Update app translations from Crowdin 2026-03-22 21:45:33 +00:00
darken fa3e56b0f3 General: Update fastlane translations from Crowdin 2026-03-22 21:45:33 +00:00
darken f25718ab2c fix: Catch Hilt DI failure in MonitorService.onCreate()
Wrap super.onCreate() (Hilt injection) in try-catch to prevent

DI failures from crashing the process. The service is already

foreground at this point, so a graceful stopSelf() satisfies the

FGS timeout requirement without killing the app.
2026-03-20 19:17:56 +00:00
darken 6e9b275936 fix: Replace nullable Float composable params to avoid ART native crash
ComposerImpl.changed() boxes Float? and calls Float.equals() at the ART native level, triggering a known crash. Non-null Float params use the primitive overload with no boxing.

Introduces BATTERY_UNKNOWN sentinel, toBatteryFloat(), and toBatteryOrNull() extensions to eliminate Float? from all composable signatures and WidgetRenderState data classes.
2026-03-20 19:12:22 +00:00
darken c6c7cd7858 chore: Add Fastlane metadata length validation to CI 2026-03-20 18:11:30 +00:00
darken 4d7027a9b8 chore: Add R8 mapping file upload to Fastlane supply lanes 2026-03-20 18:04:18 +00:00
darken 992cb5e695 fix: Remove premature startForegroundService call from App.onCreate()
Calling startMonitor() in App.onCreate() started the foreground service timeout

clock before the BroadcastReceiver even ran, consuming timeout budget

with post-init work and receiver processing. The service is already

started by BluetoothEventReceiver, BootCompletedReceiver, and

OverviewViewModel, making this call redundant.
2026-03-20 17:49:15 +00:00
darken 039d20d7aa fix(billing): Use billing-ktx suspend APIs instead of manual callback bridging 2026-03-20 17:49:05 +00:00
darken 52433460be fix: Bump Compose BOM to fix ExposedDropdownMenuBox crash on detach 2026-03-20 17:48:55 +00:00
darken 95bb225e64 chore: Update GitHub Actions to Node.js 24-compatible versions 2026-03-12 15:37:07 +00:00
darken ac477113cf Release: 4.0.1-rc0 2026-03-12 13:42:29 +01:00
darken 5e21941765 General: Update app translations from Crowdin 2026-03-12 12:12:40 +00:00
darken e5032ee35a General: Update fastlane translations from Crowdin 2026-03-12 12:12:40 +00:00
darken 1ceac349ab chore: Add .claude/.gitignore for worktrees 2026-03-12 11:59:07 +00:00
darken 2db01b410b fix(billing): Prevent crash when billing service disconnects 2026-03-12 11:59:07 +00:00
darken 193a1030fb feat: Start BLE scanning with only core permissions granted
Allow the app to scan for AirPods when only BLE scan permissions are granted, without waiting for all optional permissions (notifications, overlay, etc.).

Add isScanBlocking flag to Permission enum. Gate monitor service and pod scanning on scan permissions only. Show scan-blocking permission cards with error color and sorted first. Wrap BLUETOOTH_CONNECT-dependent calls in try-catch for graceful degradation. Fix POST_NOTIFICATIONS minApiLevel from S (31) to TIRAMISU (33).
2026-03-10 16:37:43 +00:00
darken 12e35631d6 fix: Scope ViewModels to navigation entries via NavDisplay decorator 2026-03-10 15:11:10 +00:00
darken 67474a30da fix(debug): Rename recorder screen button from Close to Keep
The keep button's onClick handler was already named onKeep but the label said Close. Renamed to Keep for clarity alongside the Delete/Share buttons. Added dedicated string resource with translations for all 75 locales.
2026-03-10 07:50:36 +00:00
darken 44cd0eec2a test: Add missing AutoConnectCondition round-trip and Model encode tests 2026-03-09 20:29:19 +00:00
darken a79f6a0535 refactor: Replace Moshi with kotlinx.serialization
Remove Moshi dependency entirely, completing the migration to kotlinx.serialization. All JSON serialization now uses kotlinx with explicit @SerialName annotations for wire format stability.

- Migrate PodDeviceCache from Moshi to kotlinx Json injection

- Add MapIntByteArrayBase64Serializer for BleScanResult cache compat

- Strip @JsonClass/@Json annotations from all dual-annotated classes

- Delete Moshi adapters, ProGuard rules, and build config

- Convert compat tests to pure kotlinx round-trip tests
2026-03-09 20:29:19 +00:00
darken 8675d73a98 fix(debug): Persist recording session across app restarts
The debug recording trigger file now stores the session directory path and start timestamp. On app restart, the recorder resumes into the same session directory and log file instead of creating a new one, preserving the original start time so the short-recording guard doesn't reset.
2026-03-09 17:34:29 +00:00
darken 3423786f06 chore: Remove redundant package name from manifest 2026-03-09 17:41:08 +01:00
darken 00083707c6 fix(debug): Harden debug session recording and sharing 2026-03-09 16:39:56 +00:00
364 changed files with 24991 additions and 2626 deletions
+1
View File
@@ -0,0 +1 @@
worktrees/
+1 -1
View File
@@ -80,5 +80,5 @@ Code shared between phone and Wear OS apps is placed in `app-common`. When modif
- **Hilt**: Dependency injection framework
- **AndroidX Navigation**: Fragment navigation with SafeArgs
- **Moshi**: JSON serialization for configuration and debugging
- **kotlinx.serialization**: JSON serialization for configuration and caching
- **Material Design**: UI components following Material Design guidelines
+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
+12 -11
View File
@@ -1,16 +1,17 @@
{
"permissions": {
"allow": [
"mcp__ide__getDiagnostics",
"WebSearch"
],
"deny": []
"allow": [
"mcp__ide__getDiagnostics",
"WebSearch"
],
"deny": []
},
"enabledPlugins": {
"android-translation@claude-code-cafe": true,
"debugbadger@claude-code-cafe": true,
"jvm-tools@claude-code-cafe": true,
"frontend-design@claude-plugins-official": true,
"kotlin-lsp@claude-plugins-official": true
"android-translation@claude-code-cafe": true,
"debugbadger@claude-code-cafe": true,
"jvm-tools@claude-code-cafe": true,
"frontend-design@claude-plugins-official": true,
"kotlin-lsp@claude-plugins-official": true,
"support-investigator@claude-code-cafe": true
}
}
}
+4 -4
View File
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Set up JDK 17
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
with:
java-version: '17'
distribution: 'temurin'
@@ -14,7 +14,7 @@ runs:
run: chmod +x gradlew
- name: Cache Gradle Wrapper
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.gradle/wrapper
@@ -24,7 +24,7 @@ runs:
${{ runner.os }}-gradle-wrapper-
- name: Cache Gradle Dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.gradle/caches
@@ -33,7 +33,7 @@ runs:
${{ runner.os }}-gradle-caches-
- name: Cache Android Global Build-Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
with:
path: |
~/.android/build-cache
+24 -4
View File
@@ -6,6 +6,9 @@ on:
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
lint-vital:
name: Lint vitals
@@ -18,7 +21,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -36,7 +41,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
@@ -53,9 +60,22 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- name: Setup project and build environment
uses: ./.github/actions/common-setup
- name: Test modules
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
check-fastlane-metadata:
name: Fastlane metadata
runs-on: ubuntu-22.04
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@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
persist-credentials: false
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
+26 -31
View File
@@ -4,6 +4,15 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Build only, skip release/upload'
type: boolean
default: true
permissions:
contents: read
jobs:
release-github:
@@ -24,23 +33,16 @@ jobs:
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
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
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Assemble beta APK
if: contains(steps.tagger.outputs.tag, '-beta')
if: contains(github.ref_name, '-beta')
run: ./gradlew assembleFossBeta
env:
VERSION: ${{ github.ref }}
@@ -49,7 +51,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production APK
if: "!contains(steps.tagger.outputs.tag, '-beta')"
if: "!contains(github.ref_name, '-beta')"
run: ./gradlew assembleFossRelease
env:
VERSION: ${{ github.ref }}
@@ -58,24 +60,24 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Create pre-release
if: contains(steps.tagger.outputs.tag, '-beta')
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
with:
prerelease: true
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
if: "!contains(steps.tagger.outputs.tag, '-beta')"
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
with:
prerelease: false
tag_name: ${{ steps.tagger.outputs.tag }}
name: ${{ steps.tagger.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
env:
@@ -107,29 +109,22 @@ jobs:
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
- name: Checkout source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
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
- name: Get the version
id: tagger
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
with:
skip-unshallow: 'true'
abbrev: false
commit-ish: HEAD
- name: Set up ruby env
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
with:
ruby-version: 3.3.6
bundler-cache: true
- name: Assemble beta and upload to Google Play
if: contains(steps.tagger.outputs.tag, '-beta')
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
run: bundle exec fastlane beta
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
@@ -137,7 +132,7 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Assemble production and upload to Google Play
if: "!contains(steps.tagger.outputs.tag, '-beta')"
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
run: bundle exec fastlane production
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
+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.0-rc0 40000000
5.0.1-rc0 50001000
-2
View File
@@ -177,8 +177,6 @@ dependencies {
addDagger()
addMoshi()
addOkio()
addBaseAndroid()
-3
View File
@@ -1,3 +0,0 @@
-keepclassmembernames @com.squareup.moshi.JsonClass class * extends java.lang.Enum {
<fields>;
}
+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),
)
@@ -1,23 +1,19 @@
package eu.darken.capod.common.upgrade.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
@JsonClass(generateAdapter = true)
data class FossUpgrade(
@Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
val reason: Reason
@SerialName("upgradedAt") @Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
@SerialName("reason") val reason: Reason
) {
@Serializable
@JsonClass(generateAdapter = false)
enum class Reason {
@SerialName("foss.upgrade.reason.donated") @Json(name = "foss.upgrade.reason.donated") DONATED,
@SerialName("foss.upgrade.reason.alreadydonated") @Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@SerialName("foss.upgrade.reason.nomoney") @Json(name = "foss.upgrade.reason.nomoney") NO_MONEY;
@SerialName("foss.upgrade.reason.donated") DONATED,
@SerialName("foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@SerialName("foss.upgrade.reason.nomoney") NO_MONEY;
}
}
@@ -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,
)
}
}
}
}
+4 -4
View File
@@ -2,9 +2,9 @@
<resources>
<string name="foss_upgrade_donate_label">Faire un don</string>
<string name="foss_upgrade_alreadydonated_label">Jai déjà fait un don</string>
<string name="foss_upgrade_no_money_label">Jai dépensé tout mon argent sur les AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS est gratuit et open source. Si vous le trouvez utile, pensez à sponsoriser le développement pour aider à maintenir le projet en vie.</string>
<string name="foss_upgrade_no_money_label">Jai dépensé tout mon argent sur des AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS est gratuite et à code source ouvert. Si vous la trouvez utile, pensez à soutenir le développement pour contribuer à la pérennité du projet.</string>
<string name="upgrade_foss_sponsor_action">Soutenir le développement</string>
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi. Pas de dépendance à Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Déjà de retour ? Votre soutien maintient CAPod en vie.</string>
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi à la trace. Pas de dépendance à Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">Déjà de retour? Votre soutien maintient CAPod en vie.</string>
</resources>
+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>
@@ -9,6 +9,9 @@ import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -21,8 +24,6 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
data class BillingClientConnection(
private val client: BillingClient,
@@ -67,22 +68,16 @@ data class BillingClientConnection(
.setProductType(productType)
.build()
val (result: BillingResult, purchases) = suspendCoroutine<Pair<BillingResult, Collection<Purchase>?>> { continuation ->
client.queryPurchasesAsync(params) { result, purchases ->
continuation.resume(result to purchases)
}
}
val queryResult = client.queryPurchasesAsync(params)
log(TAG) { "queryPurchases($productType): code=${result.responseCode}, message=${result.debugMessage}, purchases=$purchases" }
log(TAG) { "queryPurchases($productType): code=${queryResult.billingResult.responseCode}, message=${queryResult.billingResult.debugMessage}, purchases=${queryResult.purchasesList}" }
if (!result.isSuccess) {
if (!queryResult.billingResult.isSuccess) {
log(TAG, WARN) { "queryPurchases($productType) failed" }
throw BillingResultException(result)
} else {
requireNotNull(purchases)
throw BillingResultException(queryResult.billingResult)
}
return purchases
return queryResult.purchasesList
}
suspend fun acknowledgePurchase(purchase: Purchase) {
@@ -90,9 +85,7 @@ data class BillingClientConnection(
setPurchaseToken(purchase.purchaseToken)
}.build()
val result = suspendCoroutine<BillingResult> { continuation ->
client.acknowledgePurchase(ack) { continuation.resume(it) }
}
val result = client.acknowledgePurchase(ack)
log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" }
@@ -118,17 +111,15 @@ data class BillingClientConnection(
val params = QueryProductDetailsParams.newBuilder().setProductList(products).build()
val (result, details) = suspendCoroutine<Pair<BillingResult, List<ProductDetails>>> { continuation ->
client.queryProductDetailsAsync(params) { billingResult, queryResult ->
continuation.resume(billingResult to queryResult.productDetailsList.orEmpty())
}
}
val queryResult = client.queryProductDetails(params)
val details = queryResult.productDetailsList.orEmpty()
log(TAG) {
"querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${result.responseCode}, debug=${result.debugMessage}, details=$details"
"querySkus(type=$type, skus=${typeSkus.map { it.id }}): code=${queryResult.billingResult.responseCode}, debug=${queryResult.billingResult.debugMessage}, details=$details"
}
if (!result.isSuccess) throw BillingResultException(result)
if (!queryResult.billingResult.isSuccess) throw BillingResultException(queryResult.billingResult)
for (detail in details) {
val matchingSku = typeSkus.firstOrNull { it.id == detail.productId }
@@ -24,9 +24,11 @@ class BillingDataRepo @Inject constructor(
) {
private val connectionProvider = billingClientConnectionProvider.connection
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
throw it
.retryWhen { cause, attempt ->
if (cause is CancellationException) return@retryWhen false
log(TAG, ERROR) { "Unable to provide client connection (attempt=$attempt):\n${cause.asLog()}" }
delay(60_000) // 60s between retries (upstream already did 5 quick retries)
true
}
.replayingShare(scope)
@@ -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,
)
}
}
}
+10 -5
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.darken.capod">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission-sdk-23 android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
@@ -9,10 +9,14 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
tools:ignore="BackgroundLocationPolicy" />
<uses-permission
android:name="android.permission.BLUETOOTH"
@@ -32,7 +36,8 @@
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
<uses-feature
android:name="android.hardware.bluetooth_le"
+5 -7
View File
@@ -12,9 +12,10 @@ 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 eu.darken.capod.monitor.core.worker.MonitorControl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
@@ -27,8 +28,7 @@ import javax.inject.Inject
open class App : Application() {
@Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var monitorControl: MonitorControl
@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
@@ -41,11 +41,9 @@ open class App : Application() {
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
monitorControl.startMonitor(forceStart = true)
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")
}
@@ -3,19 +3,21 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
import android.os.Parcelable
import androidx.core.util.forEach
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Parcelize
@JsonClass(generateAdapter = true)
@Serializable
data class BleScanResult(
@Json(name = "receivedAt") val receivedAt: Instant,
@Json(name = "address") val address: String,
@Json(name = "rssi") val rssi: Int,
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
@SerialName("address") val address: String,
@SerialName("rssi") val rssi: Int,
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
) : Parcelable {
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
@@ -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
@@ -304,6 +304,40 @@ class BluetoothManager2 @Inject constructor(
emit(wrappedDevices)
}
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" }
@@ -312,11 +346,16 @@ 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)
if (isSecurityException) {
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
_isNudgeAvailable = false
}
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
false
}
@@ -1,27 +1,24 @@
package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ScannerMode(
val identifier: String,
@StringRes val labelRes: Int
) {
@SerialName("scanner.mode.lowpower") @Json(name = "scanner.mode.lowpower") LOW_POWER(
@SerialName("scanner.mode.lowpower") LOW_POWER(
"scanner.mode.lowpower",
R.string.settings_scanner_mode_lowpower_label
),
@SerialName("scanner.mode.balanced") @Json(name = "scanner.mode.balanced") BALANCED(
@SerialName("scanner.mode.balanced") BALANCED(
"scanner.mode.balanced",
R.string.settings_scanner_mode_balanced_label
),
@SerialName("scanner.mode.lowlatency") @Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
"scanner.mode.lowlatency",
R.string.settings_scanner_mode_lowlatency_label
),
@@ -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,16 +4,23 @@ 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.profiles.core.DeviceProfile
import java.time.Instant
@@ -35,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,
@@ -44,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,
@@ -58,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,
@@ -71,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,
@@ -82,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,
@@ -96,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,
@@ -107,39 +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 = MockSinglePodDevice(
_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(
@@ -160,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?,
@@ -176,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
@@ -187,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
@@ -211,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
@@ -229,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)
}
@@ -244,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
@@ -261,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)
}
@@ -273,3 +402,102 @@ private class MockSinglePodDevice(
// HasEarDetection
override val isBeingWorn: Boolean = _isBeingWorn
}
@Suppress("PropertyName")
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,
) : 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
override val scanResult: BleScanResult = MockPodDataProvider.dummyScanResult(rssi)
override val reliability: Float = 1.0f
override val signalQuality: Float = 0.80f
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
// HasChargeDetection
override val isHeadsetBeingCharged: Boolean = _isHeadsetBeingCharged
// 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
}
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.CancellationException
@@ -57,21 +59,20 @@ class DebugSessionManager @Inject constructor(
activeDir = recorderState.currentLogDir,
recordingStartedAt = recorderState.recordingStartedAt,
)
val overlaid = applyOverlays(raw, zipping, failedZips)
applyOverlays(raw, zipping, failedZips)
}.replayingShare(appScope)
val orphans = findOrphans(overlaid, zipping)
if (orphans.isNotEmpty()) {
orphans.forEach { (id, _) -> pendingAutoZips.add(id) }
appScope.launch {
orphans.forEach { (id, dir) ->
init {
sessions.onEach { allSessions ->
val orphans = findOrphans(allSessions, zippingIds.value)
orphans.forEach { (id, dir) ->
if (pendingAutoZips.add(id)) {
log(TAG, INFO) { "Orphan session detected, auto-zipping: $id" }
zipSessionAsync(id, dir)
}
}
}
overlaid
}.replayingShare(appScope)
}.launchIn(appScope)
}
private fun applyOverlays(
sessions: List<DebugSession>,
@@ -226,6 +227,7 @@ class DebugSessionManager @Inject constructor(
}
}
failedZipIds.update { emptySet() }
pendingAutoZips.clear()
log(TAG) { "All stored logs deleted" }
refresh()
}
@@ -3,6 +3,7 @@ package eu.darken.capod.common.debug.recording.core
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.annotation.VisibleForTesting
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.InstallId
@@ -48,7 +49,12 @@ class RecorderModule @Inject constructor(
private val internalState = DynamicStateFlow(TAG, appScope + dispatcherProvider.IO) {
val triggerFileExists = triggerFile.exists()
State(shouldRecord = triggerFileExists)
val persistedInfo = if (triggerFileExists) readTriggerFile() else null
State(
shouldRecord = triggerFileExists,
persistedLogDir = persistedInfo?.first,
recordingStartedAt = persistedInfo?.second ?: 0L,
)
}
val state: Flow<State> = internalState.flow
@@ -59,24 +65,46 @@ class RecorderModule @Inject constructor(
internalState.updateBlocking {
if (!isRecording && shouldRecord) {
val sessionDir = createSessionDir()
val isResume = persistedLogDir != null && persistedLogDir.exists()
val sessionDir = if (isResume) {
log(TAG, INFO) { "Resuming recording into existing session: $persistedLogDir" }
persistedLogDir
} else {
createSessionDir()
}
val logFile = File(sessionDir, "core.log")
val newRecorder = Recorder()
newRecorder.start(logFile)
triggerFile.createNewFile()
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
if (!isResume) {
val startTime = System.currentTimeMillis()
writeTriggerFile(sessionDir, startTime)
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
this@RecorderModule.currentLogDir = sessionDir
this@RecorderModule.currentLogDir = sessionDir
copy(
recorder = newRecorder,
currentLogDir = sessionDir,
recordingStartedAt = System.currentTimeMillis(),
)
copy(
recorder = newRecorder,
currentLogDir = sessionDir,
recordingStartedAt = startTime,
persistedLogDir = null,
)
} else {
log(TAG, INFO) { "Build.Fingerprint: ${Build.FINGERPRINT}" }
log(TAG, INFO) { "BuildConfig.Versions: ${BuildConfigWrap.VERSION_DESCRIPTION}" }
this@RecorderModule.currentLogDir = sessionDir
copy(
recorder = newRecorder,
currentLogDir = sessionDir,
recordingStartedAt = if (recordingStartedAt > 0L) recordingStartedAt else System.currentTimeMillis(),
persistedLogDir = null,
)
}
} else if (!shouldRecord && isRecording) {
recorder!!.stop()
requireNotNull(recorder) { "Recorder is null despite isRecording" }.stop()
if (triggerFile.exists() && !triggerFile.delete()) {
log(TAG, ERROR) { "Failed to delete trigger file" }
@@ -133,7 +161,8 @@ class RecorderModule @Inject constructor(
internalState.updateBlocking {
copy(shouldRecord = true)
}
return internalState.flow.filter { it.isRecording }.first().currentLogDir!!
val state = internalState.flow.filter { it.isRecording }.first()
return requireNotNull(state.currentLogDir) { "Recording state has no logDir" }
}
suspend fun stopRecorder(): File? {
@@ -169,6 +198,7 @@ class RecorderModule @Inject constructor(
internal val recorder: Recorder? = null,
val currentLogDir: File? = null,
val recordingStartedAt: Long = 0L,
internal val persistedLogDir: File? = null,
) {
val isRecording: Boolean
get() = recorder != null
@@ -177,9 +207,59 @@ class RecorderModule @Inject constructor(
get() = recorder?.path
}
internal fun readTriggerFile(): Pair<File, Long>? = try {
parseTriggerContent(triggerFile.readText())
} catch (e: Exception) {
log(TAG, WARN) { "Failed to read trigger file: $e" }
null
}
private fun writeTriggerFile(sessionDir: File, startTime: Long) {
try {
triggerFile.writeText("${sessionDir.absolutePath}\n$startTime")
} catch (e: Exception) {
log(TAG, WARN) { "Failed to write trigger file: $e" }
try {
triggerFile.createNewFile()
} catch (e2: Exception) {
log(TAG, ERROR) { "Failed to create trigger file fallback: $e2" }
}
}
}
companion object {
internal val TAG = logTag("Debug", "Log", "Recorder", "Module")
private const val FORCE_FILE = "capod_force_debug_run"
private const val MIN_RECORDING_MS = 5_000L
@VisibleForTesting
internal fun parseTriggerContent(
content: String,
now: Long = System.currentTimeMillis(),
): Pair<File, Long>? {
val trimmed = content.trim()
if (trimmed.isEmpty()) return null
val lines = trimmed.lines()
if (lines.size < 2) {
log(TAG, WARN) { "Trigger file has unexpected format: $trimmed" }
return null
}
val dir = File(lines[0])
val timestamp = lines[1].toLongOrNull()
if (timestamp == null || timestamp !in 1..(now + 60_000L)) {
log(TAG, WARN) { "Trigger file has invalid timestamp: ts=$timestamp" }
return null
}
if (!dir.exists()) {
log(TAG, WARN) { "Trigger file references non-existent dir: ${lines[0]}" }
return null
}
return dir to timestamp
}
}
}
@@ -9,6 +9,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.PrivacyPolicy
import eu.darken.capod.common.WebpageTool
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.debug.recording.core.DebugSession
@@ -132,8 +133,6 @@ class RecorderActivityVM @Inject constructor(
}
fun share() = launch {
val currentState = stater.flow.first()
val dir = currentState.logDir ?: return@launch
val sid = sessionId ?: return@launch
stater.updateBlocking { copy(isWorking = true) }
@@ -141,17 +140,21 @@ class RecorderActivityVM @Inject constructor(
try {
val uri = sessionManager.getZipUri(sid)
val displayName = stater.flow.first().logDir?.name ?: sid
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
type = "application/zip"
addCategory(Intent.CATEGORY_DEFAULT)
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - ${dir.name}")
putExtra(Intent.EXTRA_SUBJECT, "CAPod DebugLog - $displayName")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooserIntent = Intent.createChooser(intent, context.getString(R.string.support_debuglog_label))
events.tryEmit(Event.ShareIntent(chooserIntent))
} catch (e: Exception) {
log(TAG, WARN) { "Failed to share session $sid: ${e.message}" }
} finally {
stater.updateBlocking { copy(isWorking = false) }
}
@@ -390,7 +390,7 @@ private fun BottomActionBar(
onClick = onKeep,
modifier = Modifier.weight(1f),
) {
Text(text = stringResource(R.string.general_close_action))
Text(text = stringResource(R.string.debug_debuglog_screen_keep_action))
}
androidx.compose.material3.Button(
onClick = onShare,
@@ -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 {
@@ -16,6 +16,7 @@ enum class Permission(
@StringRes val labelRes: Int,
@StringRes val descriptionRes: Int,
val permissionId: String,
val isScanBlocking: Boolean = false,
val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
},
@@ -26,6 +27,7 @@ enum class Permission(
labelRes = R.string.permission_bluetooth_label,
descriptionRes = R.string.permission_bluetooth_description,
permissionId = "android.permission.BLUETOOTH",
isScanBlocking = true,
),
BLUETOOTH_CONNECT(
minApiLevel = Build.VERSION_CODES.S,
@@ -38,6 +40,7 @@ enum class Permission(
labelRes = R.string.permission_bluetooth_scan_label,
descriptionRes = R.string.permission_bluetooth_scan_description,
permissionId = "android.permission.BLUETOOTH_SCAN",
isScanBlocking = true,
),
ACCESS_FINE_LOCATION(
minApiLevel = Build.VERSION_CODES.BASE,
@@ -45,6 +48,7 @@ enum class Permission(
labelRes = R.string.permission_access_fine_location_label,
descriptionRes = R.string.permission_access_fine_location_description,
permissionId = "android.permission.ACCESS_FINE_LOCATION",
isScanBlocking = true,
),
ACCESS_BACKGROUND_LOCATION(
minApiLevel = Build.VERSION_CODES.Q,
@@ -73,7 +77,7 @@ enum class Permission(
},
),
POST_NOTIFICATIONS(
minApiLevel = Build.VERSION_CODES.S,
minApiLevel = Build.VERSION_CODES.TIRAMISU,
labelRes = R.string.permission_post_notifications_label,
descriptionRes = R.string.permission_post_notifications_description,
permissionId = "android.permission.POST_NOTIFICATIONS",
@@ -1,16 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@Suppress("unused")
@OptIn(ExperimentalEncodingApi::class)
class ByteArrayAdapter {
@ToJson
fun toJson(obj: ByteArray): String = Base64.encode(obj)
@FromJson
fun fromJson(base64: String): ByteArray? = Base64.decode(base64)
}
@@ -1,13 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.Instant
class JavaInstantAdapter {
@ToJson
fun toJson(obj: Instant): Long = obj.toEpochMilli()
@FromJson
fun fromJson(epochMillis: Long): Instant = Instant.ofEpochMilli(epochMillis)
}
@@ -0,0 +1,23 @@
package eu.darken.capod.common.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object MapIntByteArrayBase64Serializer : KSerializer<Map<Int, ByteArray>> {
private val delegate = MapSerializer(Int.serializer(), ByteArrayBase64Serializer)
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: Map<Int, ByteArray>) {
delegate.serialize(encoder, value)
}
override fun deserialize(decoder: Decoder): Map<Int, ByteArray> {
return delegate.deserialize(decoder)
}
}
@@ -1,116 +0,0 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package eu.darken.capod.common.serialization
import com.squareup.moshi.*
import java.lang.reflect.Type
import java.util.*
import javax.annotation.CheckReturnValue
import kotlin.apply
import kotlin.collections.isNotEmpty
import kotlin.collections.plus
import kotlin.collections.toTypedArray
import kotlin.io.use
import kotlin.jvm.javaClass
class NameBasedPolyJsonAdapterFactory<T> internal constructor(
val baseType: Class<T>,
val keyLabels: List<String> = emptyList(),
val subtypes: List<Type> = emptyList(),
) : JsonAdapter.Factory {
fun withSubtype(subtype: Class<out T>, label: String): NameBasedPolyJsonAdapterFactory<T> {
require(!keyLabels.contains(label)) { "Labels must be unique." }
return NameBasedPolyJsonAdapterFactory(
baseType = baseType,
keyLabels = keyLabels + label,
subtypes = subtypes + subtype,
)
}
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (Types.getRawType(type) != baseType || annotations.isNotEmpty()) {
return null
}
val jsonAdapters = ArrayList<JsonAdapter<Any>>(subtypes.size)
var i = 0
val size = subtypes.size
while (i < size) {
jsonAdapters.add(moshi.adapter(subtypes[i]))
i++
}
return PolymorphicJsonAdapter(
nameLabels = keyLabels,
subTypes = subtypes,
jsonAdapters = jsonAdapters,
).nullSafe()
}
internal class PolymorphicJsonAdapter(
val nameLabels: List<String>,
val subTypes: List<Type>,
val jsonAdapters: List<JsonAdapter<Any>>,
) : JsonAdapter<Any>() {
private val nameOptions: JsonReader.Options = JsonReader.Options.of(*nameLabels.toTypedArray())
override fun fromJson(reader: JsonReader): Any? {
val peeked = reader.peekJson().apply {
setFailOnUnknown(false)
}
val labelIndex = peeked.use(::labelIndex)
if (labelIndex == -1) {
throw JsonDataException("No matching Field names for $nameLabels")
}
return jsonAdapters[labelIndex].fromJson(reader)
}
private fun labelIndex(reader: JsonReader): Int {
reader.beginObject()
while (reader.hasNext()) {
val labelIndex = reader.selectName(nameOptions)
if (labelIndex == -1) {
reader.skipName()
reader.skipValue()
continue
}
return labelIndex
}
return -1
// throw JsonDataException("Missing label for $labelKey")
}
override fun toJson(writer: JsonWriter, value: Any?) {
val type = value!!.javaClass
val typeIndex = subTypes.indexOf(type)
if (typeIndex == -1) {
throw JsonDataException("No matching name label for $value. Valid labels are $nameLabels")
}
val adapter = jsonAdapters[typeIndex]
writer.beginObject()
val flattenToken = writer.beginFlatten()
adapter.toJson(writer, value)
writer.endFlatten(flattenToken)
writer.endObject()
}
override fun toString(): String = "KeyBasedPolyJsonAdapterFactory($nameLabels)"
}
companion object {
@CheckReturnValue
fun <T> of(baseType: Class<T>): NameBasedPolyJsonAdapterFactory<T> = NameBasedPolyJsonAdapterFactory(
baseType
)
}
}
@@ -1,11 +1,9 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.profiles.core.DeviceProfile
import kotlinx.serialization.json.Json
import javax.inject.Qualifier
import javax.inject.Singleton
@@ -14,14 +12,6 @@ import javax.inject.Singleton
@Module
class SerializationModule {
@Provides
@Singleton
fun moshi(): Moshi = Moshi.Builder().apply {
add(JavaInstantAdapter())
add(ByteArrayAdapter())
add(DeviceProfile.MOSHI_FACTORY)
}.build()
@Provides
@Singleton
@SerializationCapod
@@ -29,6 +19,7 @@ class SerializationModule {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
}
@@ -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),
)
}
}
}
}
@@ -1,18 +1,15 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeColor(
@StringRes val labelRes: Int
) {
@SerialName("theme.color.blue") @Json(name = "theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
@SerialName("theme.color.green") @Json(name = "theme.color.green") GREEN(R.string.ui_theme_color_green_label),
@SerialName("theme.color.amber") @Json(name = "theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
@SerialName("theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
@SerialName("theme.color.green") GREEN(R.string.ui_theme_color_green_label),
@SerialName("theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
}
@@ -1,18 +1,15 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeMode(
@StringRes val labelRes: Int
) {
@SerialName("theme.mode.system") @Json(name = "theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
@SerialName("theme.mode.dark") @Json(name = "theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
@SerialName("theme.mode.light") @Json(name = "theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
@SerialName("theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
@SerialName("theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
@SerialName("theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
}
@@ -1,19 +1,16 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeStyle(
@StringRes val labelRes: Int
) {
@SerialName("theme.style.default") @Json(name = "theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
@SerialName("theme.style.materialyou") @Json(name = "theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
@SerialName("theme.style.mediumcontrast") @Json(name = "theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
@SerialName("theme.style.highcontrast") @Json(name = "theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
@SerialName("theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
@SerialName("theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
@SerialName("theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
@SerialName("theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
}
@@ -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",
@@ -1,24 +1,21 @@
package eu.darken.capod.main.core
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class MonitorMode(
@StringRes val labelRes: Int
) {
@SerialName("monitor.mode.manual") @Json(name = "monitor.mode.manual") MANUAL(
@SerialName("monitor.mode.manual") MANUAL(
R.string.settings_monitor_mode_manual_label
),
@SerialName("monitor.mode.automatic") @Json(name = "monitor.mode.automatic") AUTOMATIC(
@SerialName("monitor.mode.automatic") AUTOMATIC(
R.string.settings_monitor_mode_automatic_label
),
@SerialName("monitor.mode.always") @Json(name = "monitor.mode.always") ALWAYS(
@SerialName("monitor.mode.always") ALWAYS(
R.string.settings_monitor_mode_always_label
),
}
@@ -10,6 +10,7 @@ import eu.darken.capod.reaction.core.ReactionSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.util.UUID
import javax.inject.Inject
@@ -42,6 +43,9 @@ class PermissionTool @Inject constructor(
}
.onEach { log(TAG) { "Missing permission: $it" } }
val missingScanPermissions: Flow<Set<Permission>> = missingPermissions
.map { perms -> perms.filter { it.isScanBlocking }.toSet() }
companion object {
private val TAG = logTag("PermissionTool")
}
@@ -14,9 +14,11 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.debug.logging.log
@@ -78,6 +80,10 @@ class MainActivity : Activity2() {
finish()
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
navigationEntries.forEach { entry ->
entry.apply {
@@ -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 = {
@@ -195,7 +199,7 @@ fun OverviewScreen(
) {
// 1. Permission cards
items(
items = state.permissions.toList(),
items = state.permissions.sortedByDescending { it.isScanBlocking },
key = { it.permissionId },
) { permission ->
PermissionCard(
@@ -205,26 +209,43 @@ fun OverviewScreen(
}
// 2. Bluetooth disabled card
if (!state.isBluetoothEnabled && state.permissions.isEmpty()) {
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
item(key = "bluetooth_disabled") {
BluetoothDisabledCard()
}
}
// 3. No profiles card
if (state.profiles.isEmpty() && state.permissions.isEmpty() && state.isBluetoothEnabled) {
if (state.profiles.isEmpty() && !state.isScanBlocked && state.isBluetoothEnabled) {
item(key = "no_profiles") {
NoProfilesCard(onManageDevices = onManageDevices)
}
}
// 4. Profiled device cards
if (state.permissions.isEmpty() && state.isBluetoothEnabled) {
// 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,39 +35,46 @@ 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>()
private val showUnmatchedDevices = MutableStateFlow(false)
val workerAutolaunch = permissionTool.missingPermissions
val workerAutolaunch = permissionTool.missingScanPermissions
.onEach { permissions ->
if (permissions.isNotEmpty()) {
log(TAG) { "Missing permissions: $permissions" }
log(TAG) { "Missing scan permissions: $permissions" }
return@onEach
}
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
MonitorMode.MANUAL -> false
MonitorMode.AUTOMATIC -> {
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
devices?.isNotEmpty() == true
try {
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
devices?.isNotEmpty() == true
} catch (e: SecurityException) {
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
false
}
}
MonitorMode.ALWAYS -> true
}
if (shouldStart) {
@@ -82,12 +91,12 @@ class OverviewViewModel @Inject constructor(
}
}
private val pods = permissionTool.missingPermissions
private val pods = permissionTool.missingScanPermissions
.flatMapLatest { permissions ->
if (permissions.isNotEmpty()) {
return@flatMapLatest flowOf(emptyList())
}
podMonitor.devices
deviceMonitor.devices
}
.catch { errorEvents.emitBlocking(it) }
.throttleLatest(1000)
@@ -124,8 +133,12 @@ class OverviewViewModel @Inject constructor(
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
) {
val profiledDevices: List<PodDevice> get() = devices.filter { it.meta.profile != null }
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.meta.profile == null }
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
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) {
@@ -140,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)
}
@@ -152,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,28 +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.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
@@ -87,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),
@@ -164,31 +167,31 @@ fun DualPodsCard(
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
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,
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),
)
@@ -197,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,
)
}
@@ -218,7 +245,7 @@ fun DualPodsCard(
@Composable
private fun PodGauge(
iconRes: Int,
batteryPercent: Float?,
batteryPercent: Float,
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
@@ -227,15 +254,15 @@ private fun PodGauge(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clamped = batteryPercent?.coerceIn(0f, 1f)
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
@@ -248,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,
@@ -261,10 +288,10 @@ private fun PodGauge(
)
// Progress ring
if (clamped != null) {
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,
@@ -284,9 +311,9 @@ private fun PodGauge(
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent),
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent != null) {
color = if (batteryPercent >= 0f) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -312,7 +339,7 @@ private fun PodGauge(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CaseRow(
device: HasCase,
device: PodDevice,
) {
val context = LocalContext.current
@@ -329,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,
percent = device.batteryCase.toBatteryFloat(),
modifier = Modifier
.weight(1f)
.height(8.dp),
@@ -347,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 -> ""
},
)
}
}
}
@@ -373,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(),
)
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -24,10 +25,17 @@ fun PermissionCard(
permission: Permission,
onRequest: (Permission) -> Unit,
) {
val colors = if (permission.isScanBlocking) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
} else {
CardDefaults.cardColors()
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = colors,
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -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,38 +22,59 @@ 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)
@Composable
fun BatteryCapsule(
percent: Float?,
percent: Float,
modifier: Modifier = Modifier,
) {
val clamped = percent?.coerceIn(0f, 1f)
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
val animatedFraction by animateFloatAsState(
targetValue = clamped ?: 0f,
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "batteryFill",
)
val barColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
@@ -62,7 +85,7 @@ fun BatteryCapsule(
.clip(CapsuleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (clamped != null) {
if (clamped >= 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
@@ -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)
}
}
@@ -21,9 +21,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -31,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
@@ -41,26 +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.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),
@@ -92,53 +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)) {
Text(
text = device.meta.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
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),
)
}
}
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
@@ -148,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,
@@ -164,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,
@@ -174,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
@@ -192,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),
@@ -208,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)
@@ -218,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",
@@ -138,13 +159,6 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/google/dagger") },
)
}
item {
SettingsBaseItem(
title = "Moshi",
subtitle = "A modern JSON library for Kotlin and Java. (APACHE 2.0)",
onClick = { onOpenUrl("https://github.com/square/moshi") },
)
}
item {
SettingsBaseItem(
title = "Android",
@@ -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,6 +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.apple.ble.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -191,7 +192,7 @@ private fun SinglePodPreview(
colorFilter = iconTint,
)
Text(
text = formatPercent(state.percent),
text = formatPercent(state.percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp),
@@ -293,7 +294,7 @@ private fun WidgetContainer(
@Composable
private fun PodItemRow(
icon: Int,
percent: Float?,
percent: Float,
charging: Boolean,
inEar: Boolean,
textColor: Color,
@@ -313,7 +314,7 @@ private fun PodItemRow(
colorFilter = iconTint,
)
Text(
text = formatPercent(percent),
text = formatPercent(percent.toBatteryOrNull()),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(horizontal = 4.dp),
@@ -26,6 +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.apple.ble.toBatteryOrNull
import kotlin.math.roundToInt
@Composable
@@ -105,7 +106,7 @@ private fun GlanceSinglePod(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(state.percent),
text = formatGlancePercent(state.percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 8.dp),
)
@@ -203,7 +204,7 @@ private fun GlanceWidgetRoot(
@Composable
private fun GlancePodItem(
icon: Int,
percent: Float?,
percent: Float,
charging: Boolean,
inEar: Boolean,
textStyle: TextStyle,
@@ -222,7 +223,7 @@ private fun GlancePodItem(
colorFilter = iconTint,
)
Text(
text = formatGlancePercent(percent),
text = formatGlancePercent(percent.toBatteryOrNull()),
style = textStyle,
modifier = GlanceModifier.padding(horizontal = 4.dp),
)
@@ -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(
@@ -18,15 +18,15 @@ sealed class WidgetRenderState {
val isWide: Boolean,
val deviceLabel: String?,
@DrawableRes val leftIcon: Int,
val leftPercent: Float?,
val leftPercent: Float,
val leftCharging: Boolean,
val leftInEar: Boolean,
@DrawableRes val rightIcon: Int,
val rightPercent: Float?,
val rightPercent: Float,
val rightCharging: Boolean,
val rightInEar: Boolean,
@DrawableRes val caseIcon: Int,
val casePercent: Float?,
val casePercent: Float,
val caseCharging: Boolean,
) : WidgetRenderState()
@@ -37,7 +37,7 @@ sealed class WidgetRenderState {
@ColorInt override val resolvedIconColor: Int,
val deviceLabel: String?,
@DrawableRes val headsetIcon: Int,
val percent: Float?,
val percent: Float,
@DrawableRes val batteryIcon: Int,
val charging: Boolean,
val worn: Boolean,
@@ -4,14 +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.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 {
@@ -38,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,
@@ -46,32 +42,32 @@ object WidgetRenderStateMapper {
isWide = isWide,
deviceLabel = profileLabel ?: device.getLabel(context),
leftIcon = device.leftPodIcon,
leftPercent = device.batteryLeftPodPercent,
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,
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,
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,
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,85 +0,0 @@
package eu.darken.capod.monitor.core
import android.content.Context
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
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.profiles.core.ProfileId
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
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,
moshi: Moshi,
) {
private val cacheDir by lazy {
File(context.cacheDir, "device_cache").apply { mkdirs() }
}
private val jsonAdapter = moshi.adapter<BleScanResult>()
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()
jsonAdapter.fromJson(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 json = jsonAdapter.toJson(device)
cacheFile.writeText(json)
} 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,34 +40,39 @@ 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(
permissionTool.missingPermissions,
val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
) { missingPermissions, isBluetoothEnabled ->
log(TAG) { "devices: missingPermissions=$missingPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
missingPermissions.isEmpty() && isBluetoothEnabled
) { missingScanPermissions, isBluetoothEnabled ->
log(TAG) { "devices: missingScanPermissions=$missingScanPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
missingScanPermissions.isEmpty() && isBluetoothEnabled
}
.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
@@ -34,7 +34,12 @@ class BluetoothEventReceiver : BroadcastReceiver() {
} else {
log { "Event related to $bluetoothDevice" }
}
val supportedFeatures = ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
val supportedFeatures = try {
ContinuityProtocol.BLE_FEATURE_UUIDS.filter { bluetoothDevice.hasFeature(it) }
} catch (e: SecurityException) {
log(TAG, WARN) { "Missing BLUETOOTH_CONNECT, can't check device features: ${e.message}" }
return
}
if (supportedFeatures.isEmpty()) {
log(TAG) { "Device has no features we support." }
@@ -22,7 +22,7 @@ class MonitorControl @Inject constructor(
log(TAG, VERBOSE) { "startMonitor(forceStart=$forceStart)" }
val hasBluetoothPermission =
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_CONNECT.isGranted(context)
Permission.BLUETOOTH.isGranted(context) || Permission.BLUETOOTH_SCAN.isGranted(context)
if (!hasBluetoothPermission) {
log(TAG, WARN) { "Missing Bluetooth permission, not starting monitor service." }
return
@@ -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
@@ -114,7 +118,15 @@ class MonitorService : Service() {
return
}
super.onCreate()
try {
super.onCreate()
} catch (e: Exception) {
log(TAG, WARN) { "Hilt DI failed in onCreate(), stopping service: ${e.asLog()}" }
Bugs.report(tag = TAG, "Hilt DI failed in onCreate()", exception = e)
foregroundStartFailed = true
stopSelf()
return
}
log(TAG, VERBOSE) { "onCreate()" }
// Replace early notification with the full one from injected MonitorNotifications.
@@ -162,14 +174,14 @@ class MonitorService : Service() {
}
private suspend fun doMonitor() {
val permissionsMissingOnStart = permissionTool.missingPermissions.first()
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
if (permissionsMissingOnStart.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $permissionsMissingOnStart" }
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
return
}
val monitorJob = podMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "PodMonitor" }
val monitorJob = deviceMonitor.primaryDevice()
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChanged()
.throttleLatest(1000)
.onEach { currentDevice ->
@@ -190,10 +202,10 @@ class MonitorService : Service() {
}
.launchIn(monitorScope)
permissionTool.missingPermissions
permissionTool.missingScanPermissions
.flatMapLatest { missingPermsFlow ->
if (missingPermsFlow.isNotEmpty()) {
log(TAG, WARN) { "Aborting, permissions are missing: $missingPermsFlow" }
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
monitorScope.coroutineContext.cancelChildren()
emptyFlow()
} else {
@@ -201,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)
}
}
}
@@ -212,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" }
@@ -233,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,193 +0,0 @@
package eu.darken.capod.pods.core
import android.content.Context
import androidx.annotation.DrawableRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
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
@JsonClass(generateAdapter = false)
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
) {
@SerialName("airpods.gen1") @Json(name = "airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen2") @Json(name = "airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen3") @Json(name = "airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4") @Json(name = "airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4.anc") @Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.device_airpods_gen4anc_both,
),
@SerialName("airpods.pro") @Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2") @Json(name = "airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2.usbc") @Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro3") @Json(name = "airpods.pro3") AIRPODS_PRO3(
"AirPods Pro 3",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.max") @Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.device_airpods_max
),
@SerialName("airpods.max.usbc") @Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.device_airpods_max
),
@SerialName("beats.flex") @Json(name = "beats.flex") BEATS_FLEX(
"Beats Flex",
R.drawable.device_beats_earbuds,
),
@SerialName("beats.solo.3") @Json(name = "beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3",
R.drawable.device_beats_headphones,
),
@SerialName("beats.studio.3") @Json(name = "beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3",
R.drawable.device_beats_studio3,
),
@SerialName("beats.x") @Json(name = "beats.x") BEATS_X(
"Beats X",
R.drawable.device_beats_x,
),
@SerialName("beats.powerbeats.3") @Json(name = "beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3",
R.drawable.device_powerbeats_3,
),
@SerialName("beats.powerbeats.4") @Json(name = "beats.powerbeats.4") POWERBEATS_4(
"Power Beats 4",
R.drawable.device_powerbeats_4,
),
@SerialName("beats.powerbeats.pro") @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro",
R.drawable.device_powerbeats_pro_both,
),
@SerialName("beats.powerbeats.pro2") @Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2(
"Power Beats Pro 2",
R.drawable.device_powerbeats_pro2_both,
),
@SerialName("beats.fit.pro") @Json(name = "beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro",
R.drawable.device_beats_fitpro_both,
),
@SerialName("fakes.tws.i99999") @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen2") @Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen3") @Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.device_airpods_gen3_both,
),
@SerialName("fakes.varunr.airpodspro") @Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("fakes.generic.airpods.pro2") @Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("unknown") @Json(name = "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
}

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