mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 | ||
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d | ||
|
|
39922df792 | ||
|
|
5a8c4b94ef | ||
|
|
0ffa87deea | ||
|
|
fc98db09a2 | ||
|
|
e1c0a702dd | ||
|
|
3f310016b1 | ||
|
|
d399a6d908 | ||
|
|
fb104cbe61 | ||
|
|
97cbca7a6e | ||
|
|
02278dab17 | ||
|
|
2240bd7ae5 | ||
|
|
bc4096cb57 | ||
|
|
5a0ba1b0bd | ||
|
|
d04abdf80c | ||
|
|
0609352cc9 | ||
|
|
abde0b7d6a | ||
|
|
b952aff38d | ||
|
|
c8f5ceda34 | ||
|
|
e6dbd2d660 | ||
|
|
228d7d1f0c | ||
|
|
67fead3589 | ||
|
|
d0aab583d2 | ||
|
|
367c8d28ff | ||
|
|
b04e1fe628 | ||
|
|
ea3ef1ff3b | ||
|
|
ff8b9cf6ed | ||
|
|
96bc3f5df7 | ||
|
|
1891a6d73a | ||
|
|
698415237d | ||
|
|
7ef672a11f | ||
|
|
f67e8aa7d1 | ||
|
|
bcb4bdff08 | ||
|
|
70d9432b2b | ||
|
|
9d06839a1c | ||
|
|
97f294e5f7 | ||
|
|
a40599c1dc | ||
|
|
175aef9e2f | ||
|
|
36ae35918a | ||
|
|
60ad1943b6 | ||
|
|
5e727e907d | ||
|
|
5360f54ea8 | ||
|
|
486efe6e65 | ||
|
|
c2ff1f111e | ||
|
|
26b72726a7 | ||
|
|
f35fe968c6 | ||
|
|
498f040a57 | ||
|
|
59b3106c3b | ||
|
|
89e8b83be4 | ||
|
|
f0531796dd | ||
|
|
a701fdfba7 | ||
|
|
118eaa8d08 | ||
|
|
016914ec05 | ||
|
|
0aadec0b75 | ||
|
|
e6cf507937 |
+5
-8
@@ -4,10 +4,7 @@ Android app that detects and monitors AirPods via Bluetooth LE. Displays battery
|
||||
|
||||
## Project Structure
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `app/` | Main Android app (FOSS and Google Play flavors) |
|
||||
| `app-common/` | Shared code between phone and Wear OS apps |
|
||||
Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
|
||||
|
||||
## Build Flavors
|
||||
|
||||
@@ -20,10 +17,10 @@ Quick build check: `./gradlew assembleFossDebug`
|
||||
|
||||
| Path | Contains |
|
||||
|------|----------|
|
||||
| `app/src/main/java/` | Main app source (activities, fragments, services) |
|
||||
| `app-common/src/main/java/` | Shared logic (monitor, bluetooth, models) |
|
||||
| `app/src/main/java/` | Main app source (Compose screens, services, receivers, monitor, bluetooth, models) |
|
||||
| `app/src/foss/java/`, `app/src/gplay/java/` | Flavor-specific code (e.g. upgrade/billing) |
|
||||
| `app/src/main/res/` | Layouts, drawables, strings |
|
||||
| `app-common/src/test/` | Unit tests |
|
||||
| `app/src/test/`, `app/src/testFoss/`, `app/src/testGplay/` | Unit tests (shared + flavor-specific) |
|
||||
| `app/build.gradle.kts` | App build config, dependencies, flavors |
|
||||
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
|
||||
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
|
||||
@@ -31,7 +28,6 @@ Quick build check: `./gradlew assembleFossDebug`
|
||||
## Development Tips
|
||||
|
||||
- Use `assembleFossDebug` as the fastest build variant for iteration
|
||||
- Shared code goes in `app-common/`, app-specific code in `app/`
|
||||
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
|
||||
- Always use string resources for user-facing text (see localization rules)
|
||||
- Check `git log --oneline -20` for commit message style before committing
|
||||
@@ -47,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`:
|
||||
- `pull-requests.md` — PR title and description conventions
|
||||
- `agent-instructions.md` — Sub-agent delegation and critical thinking
|
||||
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
|
||||
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
description: Architecture overview, module structure, key components, data flow, and dependencies
|
||||
globs:
|
||||
- "app/**/*.kt"
|
||||
- "app-common/**/*.kt"
|
||||
- "**/*.gradle.kts"
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
## Multi-Module Structure
|
||||
## Single-Module Structure
|
||||
|
||||
- **app/**: Main Android application with FOSS and Google Play flavors
|
||||
- **app-common/**: Shared code between main app and Wear OS app
|
||||
One Gradle module: `app/`. Source sets:
|
||||
|
||||
- `main` — shared code (Compose UI, services, monitor, bluetooth, AAP protocol, widgets)
|
||||
- `foss` / `gplay` — flavor-specific code (e.g. upgrade/billing implementations)
|
||||
- `debug` — debug-only code including screenshot content composables
|
||||
- `test` / `testFoss` / `testGplay` — unit tests
|
||||
- `screenshotTest` — Compose Preview Screenshot tests for Play Store assets
|
||||
|
||||
A previous `app-common/` module was merged into `app/` (commit `be8f4919`).
|
||||
|
||||
## Core Patterns
|
||||
|
||||
@@ -22,18 +28,62 @@ globs:
|
||||
|
||||
## Key Components
|
||||
|
||||
### PodMonitor System
|
||||
### Device Monitoring
|
||||
|
||||
- `PodMonitor`: Core service that detects and tracks AirPods via Bluetooth LE
|
||||
- `MonitorControl`: Manages MonitorService lifecycle
|
||||
- `MonitorService`: Foreground service that continuously scans for AirPods
|
||||
- `BluetoothEventReceiver`: Handles system Bluetooth events
|
||||
`monitor/core/` is split into two data-source siblings that `DeviceMonitor` merges:
|
||||
|
||||
- `monitor/core/ble/BlePodMonitor` — passive BLE scanning; reads Apple advertisement beacons (battery, case state, in-ear, etc.). Works for any pod in range; no pairing required
|
||||
- `monitor/core/aap/` — AAP connection lifecycle layer on top of `AapConnectionManager`:
|
||||
- `AapLifecycleManager` — starts/stops the AAP subsystem
|
||||
- `AapAutoConnect` — auto-opens AAP sessions for bonded/known devices
|
||||
- `AapKeyPersister`, `AapLearnedSettingsPersister` — persist session keys and learned pod settings across app restarts
|
||||
- `StemConfigSender`, `StemPressReaction`, `AncGestureResolver` — push config and react to stem/HID events
|
||||
- `monitor/core/cache/DeviceStateCache` — persisted last-known state so profiles still show data when a device is out of range
|
||||
- `DeviceMonitor` — singleton that `combine`s `BlePodMonitor.devices + AapConnectionManager.allStates + DeviceStateCache + profiles` into unified `PodDevice` objects. ViewModels observe `DeviceMonitor.devices`; they do **not** reach into `BlePodMonitor` or the AAP layer directly
|
||||
- `MonitorControl` / `MonitorService` — foreground service lifecycle holding the scan awake
|
||||
- `BluetoothEventReceiver`, `BootCompletedReceiver` — system triggers that wake the service
|
||||
|
||||
**BLE vs AAP — what each path gives you:**
|
||||
|
||||
| | BLE (advertisements) | AAP (L2CAP session) |
|
||||
|---|---|---|
|
||||
| Direction | Read-only, passive | Bidirectional commands + events |
|
||||
| Prerequisite | Bluetooth on | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
|
||||
| Data | Battery, case open, in-ear, pod model | Settings, ANC mode control, press controls, stem events, device info |
|
||||
| Availability | Any pod in range | Only your own paired pods |
|
||||
|
||||
### Reaction System
|
||||
|
||||
- `ReactionSettingsFragment`: Configuration for popup notifications
|
||||
- `ReactionsCard`: Compose UI for reaction settings, embedded in the device settings screen
|
||||
- `PopUpWindow`: Displays AirPods status when case is opened
|
||||
- `PopUpPodViewFactory`: Creates UI components for different pod models
|
||||
- `PopUpContent`: Compose pod rendering — model-specific UI branches inline, no factory class
|
||||
|
||||
### Widget System (Glance)
|
||||
|
||||
- `BatteryGlanceWidget`, `AncGlanceWidget`: Jetpack Glance-based home-screen widgets
|
||||
- `WidgetConfigurationActivity`: Configuration UI launched on widget placement
|
||||
- Lives under `app/src/main/java/eu/darken/capod/main/ui/widget/`
|
||||
|
||||
### Upgrade / Pro Features
|
||||
|
||||
- `UpgradeRepo` interface with two flavor implementations:
|
||||
- `UpgradeRepoGplay` — billing-client backed, includes grace-period handling for interrupted purchases
|
||||
- `UpgradeControlFoss` — cache/sponsor-backed; users are `isPro = false` until they call `upgrade()`, after which the pro flag is persisted via DataStore
|
||||
- FOSS is **not** "always pro" — it's opt-in via a local sponsor flow
|
||||
|
||||
### AAP (Apple Accessory Protocol) Stack
|
||||
|
||||
Three-layer structure under `pods/core/apple/aap/`:
|
||||
|
||||
- **`protocol/`** — pure data: `AapMessage`, `AapCommand`, `AapSetting`, `AapDeviceProfile`, `AapDeviceInfo`, `StemPressEvent`, `KeyExchangeResult`. Plus `DefaultAapDeviceProfile` and `Model.Features` capturing per-model capability
|
||||
- **`engine/`** — session state machine for one connection:
|
||||
- `AapConnection` — the L2CAP socket wrapper
|
||||
- `AapSessionEngine` — drives the session lifecycle; tested in `AapSessionEngineTest`
|
||||
- `AapInboundInterpreter` / `AapOutboundController` — decode incoming messages, encode outgoing
|
||||
- `AapSettingsCoordinator`, `AapAncController`, `HidTracker`, `AapDeviceInfoDiagnostics` — feature-specific coordinators that sit on top of the session
|
||||
- **`AapConnectionManager`** (singleton) — owns all open AAP sessions keyed by `BluetoothAddress`, uses `L2capSocketFactory` to create sockets. Consumers don't touch `AapConnection` directly — they call `sendCommand(...)` and observe `allStates`
|
||||
|
||||
The monitor-layer glue (`monitor/core/aap/`) described above wires this stack into the foreground service and persists its learned state.
|
||||
|
||||
### Common Utilities
|
||||
|
||||
@@ -54,31 +104,20 @@ globs:
|
||||
|
||||
## Data Flow
|
||||
|
||||
The app follows a unidirectional data flow:
|
||||
|
||||
1. `BluetoothEventReceiver` detects Bluetooth events
|
||||
2. `MonitorService` scans for AirPods beacon data
|
||||
3. `PodMonitor` processes and stores device information
|
||||
4. ViewModels observe monitor data via repositories
|
||||
5. UI components react to ViewModel state changes
|
||||
6. `ReactionSystem` triggers popups and notifications
|
||||
|
||||
## Bluetooth LE Implementation
|
||||
|
||||
The app uses Android's Bluetooth LE APIs to scan for Apple device advertisements. The core scanning logic is in `MonitorService` which runs as a foreground service.
|
||||
|
||||
## Multi-Platform Considerations
|
||||
|
||||
Code shared between phone and Wear OS apps is placed in `app-common`. When modifying shared functionality, ensure compatibility across both platforms.
|
||||
1. `BluetoothEventReceiver` / `BootCompletedReceiver` wake `MonitorService` (foreground)
|
||||
2. `MonitorService` keeps `BlePodMonitor` scanning (passive advertisements) and `AapLifecycleManager` running (active L2CAP sessions via `AapConnectionManager`)
|
||||
3. `DeviceMonitor` merges BLE + AAP + cached state + profiles into `PodDevice` objects
|
||||
4. ViewModels (`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel`, widget view models) observe `DeviceMonitor.devices`; settings/command changes are sent back through `AapConnectionManager.sendCommand(...)`
|
||||
5. Reaction triggers (case-open popup, auto-play, notifications) and widget state updates react to the merged flow
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests**: Located in `app-common/src/test/` for shared logic
|
||||
- **Test Flavors**: Separate test configurations for FOSS and Google Play variants
|
||||
- **Unit Tests**: `app/src/test/` (shared), `app/src/testFoss/`, `app/src/testGplay/` (flavor-specific — e.g. `UpgradeRepoGplayTest`, `FossUpgradeSerializationTest`)
|
||||
- **Screenshot Tests**: `app/src/screenshotTest/` — Compose Preview Screenshot Testing, powers the Play Store screenshot pipeline
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **Hilt**: Dependency injection framework
|
||||
- **AndroidX Navigation**: Fragment navigation with SafeArgs
|
||||
- **Navigation**: Navigation3 (`addNavigation3()`) drives current Compose screen routing. Some legacy `androidx.navigation` helpers still exist (`NavDirectionsExtensions`, `ViewModel3`) — don't assume SafeArgs is fully gone
|
||||
- **kotlinx.serialization**: JSON serialization for configuration and caching
|
||||
- **Material Design**: UI components following Material Design guidelines
|
||||
- **Material Design 3**: Compose Material3 UI components
|
||||
|
||||
@@ -21,6 +21,7 @@ Use the existing commit history as reference. Common prefixes:
|
||||
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
|
||||
- **feat**: New features
|
||||
- **refactor**: Code restructuring without behavior change
|
||||
- **ui**: Visual/layout-only tweaks that aren't a full feature or refactor (e.g., `ui(overview): ...`)
|
||||
- **chore**: Maintenance, dependency updates, build config
|
||||
- **docs**: Documentation changes
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ globs:
|
||||
When adding new user-facing strings:
|
||||
|
||||
- **Always use string resources**: Never hardcode user-facing text in layouts or code
|
||||
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_bluetooth_enabled`)
|
||||
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_monitor_mode_label`)
|
||||
- **Provide context**: String names should indicate usage and location
|
||||
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
|
||||
|
||||
@@ -17,5 +17,7 @@ When adding new user-facing strings:
|
||||
|
||||
- `profiles_create_title` (screen title)
|
||||
- `profiles_name_label` (form field label)
|
||||
- `profiles_delete_confirmation` (dialog message)
|
||||
- `error_network_unavailable` (error message)
|
||||
- `profiles_name_default` (default value)
|
||||
- `troubleshooter_ble_result_failure_title` (status/error title)
|
||||
|
||||
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Release Process
|
||||
|
||||
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
|
||||
|
||||
## Dispatch
|
||||
|
||||
```bash
|
||||
# Plan only — no commit, no tag, no push.
|
||||
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
|
||||
|
||||
# Real cut.
|
||||
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
|
||||
```
|
||||
|
||||
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Default | Notes |
|
||||
|---|---|---|
|
||||
| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` |
|
||||
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
|
||||
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
|
||||
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
|
||||
| `dry_run` | `true` | Default is plan-only. |
|
||||
|
||||
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
|
||||
|
||||
## Local
|
||||
|
||||
```bash
|
||||
./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current
|
||||
./tools/release/bump.sh --mode=check
|
||||
bats tools/release/bump.bats
|
||||
```
|
||||
|
||||
## Channel mapping
|
||||
|
||||
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|
||||
|---|---|---|---|---|---|
|
||||
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% |
|
||||
| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
|
||||
|
||||
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console.
|
||||
|
||||
## Rollback
|
||||
|
||||
| Stage reached | Steps |
|
||||
|---|---|
|
||||
| Bump on `main`, downstream not started | `git push origin :refs/tags/v<bad>`, `git revert <bump-sha>`, push |
|
||||
| GitHub release created | Above + `gh release delete v<bad> --yes --cleanup-tag` |
|
||||
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
|
||||
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
|
||||
|
||||
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
|
||||
|
||||
## Auth setup
|
||||
|
||||
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
|
||||
|
||||
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
|
||||
|
||||
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
|
||||
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
|
||||
|
||||
The App is installed on this repo and added as a bypass actor to:
|
||||
- The main-branch ruleset (PR + status check requirements)
|
||||
- The tag ruleset (creation restriction on `v*`)
|
||||
|
||||
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
|
||||
|
||||
## Defense in depth
|
||||
|
||||
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
|
||||
|
||||
## Stuck-dispatch recovery
|
||||
|
||||
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
|
||||
@@ -19,25 +19,33 @@ ScreenshotContent.kt (mock data + composables)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (8 screens) |
|
||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions that wire content to locale annotations |
|
||||
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (7 screens) |
|
||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
|
||||
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
|
||||
| `fastlane/generate_screenshots.sh` | Batched generation across 68 locales |
|
||||
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
||||
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
||||
|
||||
## Commit policy
|
||||
|
||||
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
|
||||
|
||||
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Full run — all 68 locales, ~12 batches, ~7 minutes
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Smoke test — 6 locales (en, de, ja, ar, zh-CN, pt-BR), single batch
|
||||
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||
# Use this for local iteration and PRs that touch screenshot content.
|
||||
./fastlane/generate_screenshots.sh --smoke
|
||||
|
||||
# Full run — all 68 locales. Use only when intending to upload to Play Store
|
||||
# (the non-smoke output is .gitignored and should not be committed).
|
||||
./fastlane/generate_screenshots.sh
|
||||
|
||||
# Copy into fastlane directories (run after generate)
|
||||
./fastlane/copy_screenshots.sh
|
||||
|
||||
# Clean copy (removes old screenshots first)
|
||||
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
|
||||
./fastlane/copy_screenshots.sh --clean
|
||||
```
|
||||
|
||||
@@ -47,20 +55,39 @@ ScreenshotContent.kt (mock data + composables)
|
||||
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
||||
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||
5. Run the full pipeline: `generate_screenshots.sh` then `copy_screenshots.sh`
|
||||
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
|
||||
|
||||
## Removing or Renaming a Screenshot
|
||||
|
||||
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||
2. Run `generate_screenshots.sh --smoke`
|
||||
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||
|
||||
## After UI Changes
|
||||
|
||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate:
|
||||
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
|
||||
|
||||
```bash
|
||||
./fastlane/generate_screenshots.sh
|
||||
./fastlane/generate_screenshots.sh --smoke
|
||||
./fastlane/copy_screenshots.sh --clean
|
||||
```
|
||||
|
||||
## Refreshing all locales on Play Store
|
||||
|
||||
Periodic, manual operation — not per-PR:
|
||||
|
||||
```bash
|
||||
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
|
||||
./fastlane/copy_screenshots.sh --clean
|
||||
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
|
||||
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
|
||||
```
|
||||
|
||||
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Batch size defaults to 2 locales (16 renders) to avoid layoutlib memory leak (~10MB/image)
|
||||
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
||||
- Gradle daemon is stopped between batches to release memory
|
||||
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
|
||||
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
|
||||
|
||||
@@ -30,13 +30,4 @@ runs:
|
||||
~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-caches-
|
||||
|
||||
- name: Cache Android Global Build-Cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.android/build-cache
|
||||
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-build-cache-
|
||||
${{ runner.os }}-gradle-caches-
|
||||
@@ -3,6 +3,9 @@ name: Code tests & eval
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- VERSION
|
||||
- version.properties
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
@@ -78,4 +81,27 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate metadata lengths
|
||||
run: bash fastlane/check_metadata_length.sh
|
||||
run: bash fastlane/check_metadata_length.sh
|
||||
|
||||
check-release-tooling:
|
||||
name: Release tooling
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install bats and shellcheck
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bats shellcheck
|
||||
|
||||
- name: Shellcheck bump.sh
|
||||
run: shellcheck tools/release/bump.sh
|
||||
|
||||
- name: Run bump.sh unit tests
|
||||
run: bats tools/release/bump.bats
|
||||
|
||||
- name: Verify version.properties + VERSION are consistent
|
||||
run: ./tools/release/bump.sh --mode=check
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- VERSION
|
||||
- version.properties
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Deploy GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- _config.yml
|
||||
- _layouts/**
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
- PRIVACY_POLICY.md
|
||||
- CNAME
|
||||
- .github/workflows/pages.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Jekyll site
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
|
||||
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 #v1.0.13
|
||||
with:
|
||||
source: ./
|
||||
destination: ./_site
|
||||
env:
|
||||
JEKYLL_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Sanity-check Jekyll output
|
||||
run: test -f _site/index.html && test -f _site/CNAME
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #v5.0.0
|
||||
with:
|
||||
path: ./_site
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #v5.0.0
|
||||
@@ -0,0 +1,235 @@
|
||||
name: Release prepare
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_kind:
|
||||
description: 'How to bump the version (ignored if version_override is set)'
|
||||
type: choice
|
||||
options: [build, patch, minor, major]
|
||||
default: build
|
||||
version_type:
|
||||
description: 'Channel for the new version (keep-current preserves current type)'
|
||||
type: choice
|
||||
options: [keep-current, rc, beta]
|
||||
default: keep-current
|
||||
version_override:
|
||||
description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)'
|
||||
type: string
|
||||
default: ''
|
||||
expected_current:
|
||||
description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)'
|
||||
type: string
|
||||
default: ''
|
||||
dry_run:
|
||||
description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-prepare-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
compute-and-validate:
|
||||
name: Compute and validate
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
new_name: ${{ steps.plan.outputs.new_name }}
|
||||
new_code: ${{ steps.plan.outputs.new_code }}
|
||||
current_name: ${{ steps.plan.outputs.current_name }}
|
||||
env:
|
||||
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
|
||||
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
|
||||
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
|
||||
INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }}
|
||||
steps:
|
||||
- name: Guard ref must be main
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Must dispatch from main, got ${GITHUB_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Compute and validate
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=("--mode=plan")
|
||||
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
|
||||
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
|
||||
else
|
||||
args+=("--bump-kind=${INPUT_BUMP_KIND}")
|
||||
args+=("--version-type=${INPUT_VERSION_TYPE}")
|
||||
fi
|
||||
if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then
|
||||
args+=("--expected-current=${INPUT_EXPECTED_CURRENT}")
|
||||
fi
|
||||
./tools/release/bump.sh "${args[@]}" | tee plan.txt
|
||||
{
|
||||
grep -E '^current_name=' plan.txt
|
||||
grep -E '^new_name=' plan.txt
|
||||
grep -E '^new_code=' plan.txt
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Tag collision check (local + remote)
|
||||
env:
|
||||
NEW_NAME: ${{ steps.plan.outputs.new_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||
echo "Local tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
|
||||
echo "Remote tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Write step summary
|
||||
env:
|
||||
CURRENT_NAME: ${{ steps.plan.outputs.current_name }}
|
||||
NEW_NAME: ${{ steps.plan.outputs.new_name }}
|
||||
NEW_CODE: ${{ steps.plan.outputs.new_code }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Release plan"
|
||||
echo
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Current | \`${CURRENT_NAME}\` |"
|
||||
echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |"
|
||||
echo "| Tag | \`v${NEW_NAME}\` |"
|
||||
echo "| Dry run | \`${DRY_RUN}\` |"
|
||||
echo
|
||||
echo "### bump.sh output"
|
||||
echo
|
||||
echo '```'
|
||||
cat plan.txt
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
push-and-dispatch:
|
||||
name: Push and dispatch
|
||||
needs: compute-and-validate
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
|
||||
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
|
||||
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
|
||||
NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }}
|
||||
NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }}
|
||||
CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }}
|
||||
steps:
|
||||
- name: Mint App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
|
||||
with:
|
||||
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Resolve bot identity
|
||||
id: bot
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
|
||||
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
|
||||
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout main with credentials
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Verify state still matches plan from Job 1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
|
||||
|
||||
- name: Re-check tag collision (state may have moved between jobs)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||
echo "Local tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
|
||||
echo "Remote tag v${NEW_NAME} already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Apply bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}")
|
||||
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
|
||||
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
|
||||
else
|
||||
args+=("--bump-kind=${INPUT_BUMP_KIND}")
|
||||
args+=("--version-type=${INPUT_VERSION_TYPE}")
|
||||
fi
|
||||
./tools/release/bump.sh "${args[@]}"
|
||||
|
||||
- name: Verify post-write state matches plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}"
|
||||
|
||||
- name: Configure git identity
|
||||
env:
|
||||
BOT_USER_NAME: ${{ steps.bot.outputs.user_name }}
|
||||
BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
|
||||
run: |
|
||||
git config user.name "${BOT_USER_NAME}"
|
||||
git config user.email "${BOT_USER_EMAIL}"
|
||||
|
||||
- name: Commit and tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add version.properties VERSION
|
||||
git commit -m "Release: ${NEW_NAME}"
|
||||
git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}"
|
||||
|
||||
- name: Atomic push (commit + tag)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "## Released"
|
||||
echo
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Tag | \`v${NEW_NAME}\` |"
|
||||
echo "| Version code | \`${NEW_CODE}\` |"
|
||||
echo "| Bump commit | on \`main\` |"
|
||||
echo "| Downstream | dispatched \`release-tag.yml\` |"
|
||||
echo
|
||||
echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -14,11 +14,54 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate-tag:
|
||||
name: Validate tag
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check tag-name format
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
|
||||
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
|
||||
echo "Releases must be cut via the 'Release prepare' workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify version.properties matches tag
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Strip leading 'v' to get the bare version name.
|
||||
tag_name="${REF_NAME#v}"
|
||||
# bump.sh in check mode emits current_name=...
|
||||
parsed=$(./tools/release/bump.sh --mode=check)
|
||||
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
|
||||
if [[ "$current_name" != "$tag_name" ]]; then
|
||||
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
|
||||
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release-github:
|
||||
needs: validate-tag
|
||||
name: Create GitHub release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
runs-on: ubuntu-22.04
|
||||
environment: foss-production
|
||||
steps:
|
||||
@@ -61,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Create pre-release
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -73,7 +116,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b #v2.5.0
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -83,10 +126,19 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger GitHub Pages deployment
|
||||
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run pages.yml --ref main
|
||||
|
||||
release-gplay:
|
||||
needs: validate-tag
|
||||
name: Create Google Play release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: gplay-production
|
||||
env:
|
||||
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
|
||||
steps:
|
||||
- name: Decode Keystore
|
||||
env:
|
||||
@@ -122,6 +174,10 @@ jobs:
|
||||
with:
|
||||
ruby-version: 3.3.6
|
||||
bundler-cache: true
|
||||
working-directory: fastlane
|
||||
|
||||
- name: Verify fastlane Bundler wiring
|
||||
run: bundle exec fastlane --version
|
||||
|
||||
- name: Assemble beta and upload to Google Play
|
||||
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
|
||||
+14
-1
@@ -17,5 +17,18 @@
|
||||
.kotlin
|
||||
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||
app/src/screenshotTest*/reference/
|
||||
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
|
||||
# Full localization is uploaded by occasional manual regen + screenshots_only;
|
||||
# Play Store retains previously-uploaded screenshots for locales not pushed.
|
||||
fastlane/metadata/android/*/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
|
||||
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
|
||||
.codex
|
||||
protocol-research/
|
||||
protocol-research/
|
||||
_site/
|
||||
.jekyll-cache/
|
||||
vendor/bundle/
|
||||
@@ -20,9 +20,6 @@ exclude:
|
||||
- buildSrc
|
||||
- gradle/wrapper
|
||||
- fastlane
|
||||
- Gemfile
|
||||
- Gemfile.lock
|
||||
- crowdin*
|
||||
- app
|
||||
- app-common
|
||||
- CONTRIBUTING.md
|
||||
|
||||
@@ -130,6 +130,7 @@ android {
|
||||
//noinspection WrongGradleMethod
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "4g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MOCK_NOW
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
@@ -37,16 +38,25 @@ import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
internal const val DS = "spec:width=1080px,height=2400px,dpi=428"
|
||||
|
||||
@Composable
|
||||
internal fun DashboardContent() = PreviewWrapper {
|
||||
internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
||||
val devices = if (showAap) {
|
||||
listOf(
|
||||
MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||
MockPodDataProvider.singlePodMonitoredWithAap(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
)
|
||||
}
|
||||
OverviewScreen(
|
||||
state = OverviewViewModel.State(
|
||||
now = MOCK_NOW,
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
devices = devices,
|
||||
isDebugMode = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
@@ -83,19 +93,38 @@ internal fun DeviceProfilesContent() = PreviewWrapper {
|
||||
|
||||
@Composable
|
||||
internal fun AddProfileContent() = PreviewWrapper {
|
||||
val pairedAirPods = BluetoothDevice2(
|
||||
address = "AA:BB:CC:DD:EE:FF",
|
||||
name = "AirPods Pro",
|
||||
seenFirstAt = MOCK_NOW,
|
||||
)
|
||||
val bondedItems = listOf(
|
||||
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||
device = pairedAirPods,
|
||||
claimedByProfile = null,
|
||||
),
|
||||
DeviceProfileCreationViewModel.BondedDeviceItem(
|
||||
device = BluetoothDevice2(
|
||||
address = "11:22:33:44:55:66",
|
||||
name = "Living Room TV",
|
||||
seenFirstAt = MOCK_NOW,
|
||||
),
|
||||
claimedByProfile = null,
|
||||
),
|
||||
)
|
||||
DeviceProfileCreationScreen(
|
||||
state = DeviceProfileCreationViewModel.State(
|
||||
isEditMode = false,
|
||||
name = "",
|
||||
name = "My AirPods Pro",
|
||||
nameError = null,
|
||||
selectedModel = null,
|
||||
selectedModel = PodModel.AIRPODS_PRO2,
|
||||
availableModels = PodModel.entries.filter { it != PodModel.UNKNOWN },
|
||||
identityKey = null,
|
||||
encryptionKey = null,
|
||||
selectedDevice = null,
|
||||
bondedDeviceItems = emptyList(),
|
||||
selectedDevice = pairedAirPods,
|
||||
bondedDeviceItems = bondedItems,
|
||||
minimumSignalQuality = 0.15f,
|
||||
canSave = false,
|
||||
canSave = true,
|
||||
),
|
||||
onBack = {},
|
||||
onSave = {},
|
||||
@@ -118,6 +147,7 @@ internal fun DeviceSettingsReactionsContent() = PreviewWrapper {
|
||||
now = MOCK_NOW,
|
||||
isPro = true,
|
||||
isNudgeAvailable = true,
|
||||
isClassicallyConnected = true,
|
||||
),
|
||||
onNavigateUp = {},
|
||||
)
|
||||
@@ -211,7 +241,7 @@ private fun PreviewDashboardLight() = DashboardContent()
|
||||
|
||||
@Preview(name = "2 - Dashboard Dark", locale = "en", device = DS, uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true)
|
||||
@Composable
|
||||
private fun PreviewDashboardDark() = DashboardContent()
|
||||
private fun PreviewDashboardDark() = DashboardContent(showAap = true)
|
||||
|
||||
@Preview(name = "3 - Case Pop-up", device = DS, showSystemUi = true)
|
||||
@Composable
|
||||
|
||||
@@ -141,6 +141,20 @@
|
||||
android:name=".monitor.core.worker.MonitorService"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".main.ui.tile.AncTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_anc_on"
|
||||
android:label="@string/tile_anc_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.ACTIVE_TILE"
|
||||
android:value="false" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||
import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
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.throttleLatest
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||
import eu.darken.capod.main.ui.widget.toWidgetKey
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@HiltAndroidApp
|
||||
open class App : Application() {
|
||||
@@ -42,19 +42,19 @@ open class App : Application() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
|
||||
|
||||
var foregroundExceptionHandled = false
|
||||
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
if (throwable.isForegroundServiceTimingException() && !foregroundExceptionHandled) {
|
||||
foregroundExceptionHandled = true
|
||||
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
|
||||
Looper.loop()
|
||||
return@setDefaultUncaughtExceptionHandler
|
||||
}
|
||||
log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" }
|
||||
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
|
||||
}
|
||||
Thread.setDefaultUncaughtExceptionHandler(
|
||||
CapodUncaughtExceptionHandler(
|
||||
previousHandler = oldHandler,
|
||||
cancelBeforeDelegate = { throwable ->
|
||||
// Best-effort shutdown: the system handler may terminate the process immediately,
|
||||
// but cancellation can still close sockets if it gets a scheduling window.
|
||||
if (::appScope.isInitialized) {
|
||||
appScope.cancel(CancellationException("Uncaught exception", throwable))
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
autoReporting.setup(this)
|
||||
|
||||
@@ -63,10 +63,10 @@ open class App : Application() {
|
||||
appScope.launch { widgetManager.refreshWidgets() }
|
||||
|
||||
deviceMonitor.devicesWithProfiles()
|
||||
.distinctUntilChanged()
|
||||
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
|
||||
.throttleLatest(1000)
|
||||
.onEach {
|
||||
log(TAG) { "Main device changed, refreshing widgets." }
|
||||
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
|
||||
widgetManager.refreshWidgets()
|
||||
}
|
||||
.launchIn(appScope)
|
||||
@@ -83,14 +83,5 @@ open class App : Application() {
|
||||
|
||||
companion object {
|
||||
internal val TAG = logTag("CAP")
|
||||
|
||||
private fun Throwable.isForegroundServiceTimingException(): Boolean {
|
||||
var current: Throwable? = this
|
||||
while (current != null) {
|
||||
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.os.Looper
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
internal class CapodUncaughtExceptionHandler(
|
||||
private val previousHandler: Thread.UncaughtExceptionHandler?,
|
||||
private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread },
|
||||
private val loopMainThread: () -> Unit = { Looper.loop() },
|
||||
private val reportForegroundServiceTimingException: (Throwable) -> Unit = { throwable ->
|
||||
Bugs.report(
|
||||
tag = App.TAG,
|
||||
message = "Foreground service timing exception suppressed",
|
||||
exception = throwable,
|
||||
)
|
||||
},
|
||||
private val cancelBeforeDelegate: (Throwable) -> Unit = {},
|
||||
private val exit: (Int) -> Unit = { exitProcess(it) },
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
|
||||
private val foregroundExceptionHandled = AtomicBoolean(false)
|
||||
|
||||
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||
if (shouldSuppress(thread, throwable)) {
|
||||
runCatching {
|
||||
log(App.TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
|
||||
reportForegroundServiceTimingException(throwable)
|
||||
}
|
||||
|
||||
val loopResult = runCatching { loopMainThread() }
|
||||
if (loopResult.isSuccess) return
|
||||
|
||||
val loopFailure = loopResult.exceptionOrNull()!!
|
||||
runCatching {
|
||||
log(App.TAG, ERROR) {
|
||||
"Main loop failed after foreground service timing exception suppression: ${loopFailure.asLog()}"
|
||||
}
|
||||
}
|
||||
delegate(thread, loopFailure)
|
||||
return
|
||||
}
|
||||
|
||||
runCatching { log(App.TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
|
||||
delegate(thread, throwable)
|
||||
}
|
||||
|
||||
private fun shouldSuppress(thread: Thread, throwable: Throwable): Boolean {
|
||||
val isMainThread = runCatching { thread === mainThreadProvider() }.getOrDefault(false)
|
||||
return throwable.isForegroundServiceTimingException() &&
|
||||
isMainThread &&
|
||||
foregroundExceptionHandled.compareAndSet(false, true)
|
||||
}
|
||||
|
||||
private fun delegate(thread: Thread, throwable: Throwable) {
|
||||
runCatching { cancelBeforeDelegate(throwable) }
|
||||
previousHandler?.uncaughtException(thread, throwable) ?: exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Throwable.isForegroundServiceTimingException(): Boolean {
|
||||
var current: Throwable? = this
|
||||
while (current != null) {
|
||||
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppForegroundState @Inject constructor(
|
||||
@AppScope appScope: CoroutineScope,
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
) {
|
||||
|
||||
val isForeground: StateFlow<Boolean> = callbackFlow {
|
||||
val lifecycle = runCatching { ProcessLifecycleOwner.get().lifecycle }
|
||||
.getOrElse {
|
||||
log(TAG, WARN) { "Failed to access process lifecycle, assuming background: ${it.asLog()}" }
|
||||
trySend(false)
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
trySend(safeCurrentForegroundState())
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> trySend(true)
|
||||
Lifecycle.Event.ON_STOP -> trySend(false)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
runCatching { lifecycle.addObserver(observer) }
|
||||
.onFailure {
|
||||
log(TAG, WARN) { "Failed to observe process lifecycle, assuming background: ${it.asLog()}" }
|
||||
trySend(false)
|
||||
close()
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
runCatching { lifecycle.removeObserver(observer) }
|
||||
.onFailure { log(TAG, WARN) { "Failed to remove process lifecycle observer: ${it.asLog()}" } }
|
||||
}
|
||||
}
|
||||
.flowOn(dispatcherProvider.MainImmediate)
|
||||
.catch {
|
||||
log(TAG, WARN) { "Foreground state flow failed, assuming background: ${it.asLog()}" }
|
||||
emit(false)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG) { "isForeground=$it" } }
|
||||
.stateIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L),
|
||||
initialValue = safeCurrentForegroundState(),
|
||||
)
|
||||
|
||||
private fun safeCurrentForegroundState(): Boolean = runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}.getOrElse {
|
||||
log(TAG, WARN) { "Failed to read process lifecycle state, assuming background: ${it.asLog()}" }
|
||||
false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("App", "ForegroundState")
|
||||
}
|
||||
}
|
||||
@@ -32,14 +32,25 @@ class MediaControl @Inject constructor(
|
||||
clearRecentCapPause()
|
||||
}
|
||||
|
||||
suspend fun sendPause() {
|
||||
/**
|
||||
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
|
||||
*
|
||||
* Returns `true` when a key event was actually dispatched (and the 15-second
|
||||
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
|
||||
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
|
||||
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
|
||||
* cooldown on a real pause — should branch on the return value rather than checking
|
||||
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
|
||||
*/
|
||||
suspend fun sendPause(): Boolean {
|
||||
log(TAG, INFO) { "sendPause()" }
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
||||
return
|
||||
return false
|
||||
}
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
||||
markRecentCapPause()
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun sendPlayPause() {
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -113,22 +114,7 @@ class BleScanner @Inject constructor(
|
||||
null
|
||||
}
|
||||
|
||||
val flushJob = if (!disableDirectScanCallback) {
|
||||
launch {
|
||||
log(TAG) { "Flush job launched" }
|
||||
while (isActive) {
|
||||
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
||||
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
|
||||
when (scannerMode) {
|
||||
ScannerMode.LOW_POWER -> break
|
||||
ScannerMode.BALANCED -> delay(2000)
|
||||
ScannerMode.LOW_LATENCY -> delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var flushJob: Job? = null
|
||||
|
||||
val filterList = when {
|
||||
useOffloadedFiltering -> filters.toList()
|
||||
@@ -167,26 +153,55 @@ class BleScanner @Inject constructor(
|
||||
setReportDelay(delay)
|
||||
}.build()
|
||||
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
val callbackIntent = createStartIntent()
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callbackIntent)
|
||||
} else {
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callback)
|
||||
flushJob = launch {
|
||||
log(TAG) { "Flush job launched" }
|
||||
while (isActive) {
|
||||
try {
|
||||
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
|
||||
scanner.flushPendingScanResults(callback)
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
|
||||
close(e)
|
||||
break
|
||||
}
|
||||
when (scannerMode) {
|
||||
ScannerMode.LOW_POWER -> break
|
||||
ScannerMode.BALANCED -> delay(2000)
|
||||
ScannerMode.LOW_LATENCY -> delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callbackIntent)
|
||||
} else {
|
||||
log(TAG) {
|
||||
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
|
||||
}
|
||||
scanner.startScan(filterList, scanSettings, callback)
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "startScan() denied: ${e.message}" }
|
||||
forwarderConsumer?.cancel()
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
forwarderConsumer?.cancel()
|
||||
flushJob?.cancel()
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
try {
|
||||
if (disableDirectScanCallback) {
|
||||
scanner.stopScan(createStopIntent())
|
||||
} else {
|
||||
scanner.stopScan(callback)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
|
||||
}
|
||||
log(TAG) { "BleScanner stopped" }
|
||||
}
|
||||
|
||||
@@ -4,12 +4,8 @@ import android.bluetooth.BluetoothDevice
|
||||
import java.time.Instant
|
||||
|
||||
data class BluetoothDevice2(
|
||||
internal val internal: BluetoothDevice,
|
||||
val address: BluetoothAddress,
|
||||
val name: String?,
|
||||
val seenFirstAt: Instant,
|
||||
) {
|
||||
val address: BluetoothAddress
|
||||
get() = internal.address
|
||||
|
||||
val name: String?
|
||||
get() = internal.name
|
||||
}
|
||||
internal val internal: BluetoothDevice? = null,
|
||||
)
|
||||
|
||||
@@ -255,6 +255,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
.map { device ->
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -304,6 +306,8 @@ class BluetoothManager2 @Inject constructor(
|
||||
val wrappedDevices = rawDevices.map { device ->
|
||||
|
||||
BluetoothDevice2(
|
||||
address = device.address,
|
||||
name = device.name,
|
||||
internal = device,
|
||||
seenFirstAt = seenDevicesLock.withLock {
|
||||
seenDevicesCache[device.address] ?: run {
|
||||
@@ -318,6 +322,74 @@ class BluetoothManager2 @Inject constructor(
|
||||
emit(wrappedDevices)
|
||||
}
|
||||
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
private fun queryBondedAddresses(): Set<BluetoothAddress> =
|
||||
adapter?.bondedDevices?.map { it.address }?.toSet() ?: emptySet()
|
||||
|
||||
val bondedDeviceAddresses: Flow<Set<BluetoothAddress>> = callbackFlow {
|
||||
fun sendBondedAddresses(): Boolean = try {
|
||||
trySend(queryBondedAddresses())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Error querying bonded device addresses: $e" }
|
||||
trySend(emptySet())
|
||||
close(e)
|
||||
false
|
||||
}
|
||||
|
||||
if (!sendBondedAddresses()) return@callbackFlow
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != BluetoothDevice.ACTION_BOND_STATE_CHANGED) return
|
||||
sendBondedAddresses()
|
||||
}
|
||||
}
|
||||
try {
|
||||
context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to register bond-state receiver: $e" }
|
||||
close(e)
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Error unregistering bond-state receiver: $e" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.retryWhen { cause, attempt ->
|
||||
log(TAG, WARN) { "bondedDeviceAddresses Flow failed (attempt ${attempt + 1}): $cause" }
|
||||
when {
|
||||
cause is SecurityException -> {
|
||||
delay(3_000L)
|
||||
true
|
||||
}
|
||||
attempt < 3 -> {
|
||||
delay(1000 * (attempt + 1))
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
log(TAG, ERROR) { "bondedDeviceAddresses Flow failed after retries: $e" }
|
||||
emit(emptySet())
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.setupCommonEventHandlers(TAG) { "bondedDeviceAddresses" }
|
||||
.stateIn(
|
||||
scope = appScope + dispatcherProvider.IO,
|
||||
started = SharingStarted.WhileSubscribed(
|
||||
stopTimeoutMillis = 5_000L,
|
||||
replayExpirationMillis = 0L,
|
||||
),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private var _isNudgeAvailable: Boolean = true
|
||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||
|
||||
@@ -340,9 +412,10 @@ class BluetoothManager2 @Inject constructor(
|
||||
@android.annotation.SuppressLint("MissingPermission")
|
||||
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
|
||||
return try {
|
||||
val target = device.internal ?: return false
|
||||
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
|
||||
.apply { isAccessible = true }
|
||||
val result = method.invoke(device.internal, alias) as? Boolean ?: false
|
||||
val result = method.invoke(target, alias) as? Boolean ?: false
|
||||
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
@@ -356,11 +429,12 @@ class BluetoothManager2 @Inject constructor(
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
|
||||
val target = device.internal ?: return@map false
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) as? Boolean ?: false
|
||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
|
||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ScannerMode(
|
||||
val identifier: String,
|
||||
@StringRes val labelRes: Int
|
||||
) {
|
||||
@SerialName("scanner.mode.lowpower") LOW_POWER(
|
||||
"scanner.mode.lowpower",
|
||||
R.string.settings_scanner_mode_lowpower_label
|
||||
),
|
||||
@SerialName("scanner.mode.balanced") BALANCED(
|
||||
"scanner.mode.balanced",
|
||||
R.string.settings_scanner_mode_balanced_label
|
||||
),
|
||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
|
||||
"scanner.mode.lowlatency",
|
||||
R.string.settings_scanner_mode_lowlatency_label
|
||||
),
|
||||
}
|
||||
enum class ScannerMode {
|
||||
@SerialName("scanner.mode.lowpower") LOW_POWER,
|
||||
@SerialName("scanner.mode.balanced") BALANCED,
|
||||
@SerialName("scanner.mode.lowlatency") LOW_LATENCY,
|
||||
}
|
||||
|
||||
@@ -180,18 +180,21 @@ object MockPodDataProvider {
|
||||
|
||||
fun dualPodMonitoredMixed(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-mixed",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProMixed(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithKeys(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-keys",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-aap",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
@@ -227,12 +230,14 @@ object MockPodDataProvider {
|
||||
|
||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||
profileId = "preview-single",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMax(),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
|
||||
profileId = "preview-single-aap",
|
||||
label = "AirPods Max",
|
||||
ble = airPodsMaxCharging(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
@@ -255,11 +260,42 @@ object MockPodDataProvider {
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** Dual pods matched to a profile that has no paired Bluetooth device selected. */
|
||||
fun dualPodMissingPairedDevice(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-missing-paired",
|
||||
ble = MockDualBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_PRO2,
|
||||
_label = "My AirPods Pro",
|
||||
batteryLeftPodPercent = 0.80f,
|
||||
batteryRightPodPercent = 0.45f,
|
||||
_batteryCasePercent = 0.60f,
|
||||
_isLeftPodCharging = true,
|
||||
leftPodIcon = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIcon = R.drawable.device_airpods_pro2_right,
|
||||
_caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** Single pod matched to a profile that has no paired Bluetooth device selected. */
|
||||
fun singlePodMissingPairedDevice(): PodDevice = PodDevice(
|
||||
profileId = "preview-single-missing-paired",
|
||||
ble = MockSingleBlePodSnapshot(
|
||||
_model = PodModel.AIRPODS_MAX,
|
||||
_label = "AirPods Max",
|
||||
batteryHeadsetPercent = 0.85f,
|
||||
_isBeingWorn = true,
|
||||
),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** Cached-only dual pod — device fully offline, showing last known state. */
|
||||
fun dualPodCachedOnly(): PodDevice = PodDevice(
|
||||
profileId = "preview-cached",
|
||||
ble = null,
|
||||
aap = null,
|
||||
profileAddress = "AA:BB:CC:DD:EE:FF",
|
||||
profileModel = PodModel.AIRPODS_PRO2,
|
||||
cached = CachedDeviceState(
|
||||
profileId = "preview-cached",
|
||||
model = PodModel.AIRPODS_PRO2,
|
||||
|
||||
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.asLogSummary
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
|
||||
@@ -15,13 +16,20 @@ object Bugs {
|
||||
message: String,
|
||||
exception: Throwable
|
||||
) {
|
||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
||||
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
|
||||
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
|
||||
|
||||
reporter?.notify(exception) ?: run {
|
||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
||||
val bugReporter = reporter
|
||||
if (bugReporter == null) {
|
||||
runCatching { log(TAG, WARN) { "Bug tracking not initialized yet." } }
|
||||
return
|
||||
}
|
||||
|
||||
runCatching { bugReporter.notify(exception) }
|
||||
.onFailure { failure ->
|
||||
runCatching { log(TAG, WARN) { "Bug reporter failed: ${failure.asLog()}" } }
|
||||
}
|
||||
}
|
||||
|
||||
private val TAG = logTag("Bugs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,19 @@ object Logging {
|
||||
message: String
|
||||
) {
|
||||
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
|
||||
snapshot
|
||||
.filter { it.isLoggable(priority) }
|
||||
.forEach {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
snapshot.forEach {
|
||||
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
|
||||
if (isLoggable) {
|
||||
runCatching {
|
||||
it.log(
|
||||
priority = priority,
|
||||
tag = tag,
|
||||
metaData = metaData,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
@@ -110,14 +113,28 @@ inline fun log(
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.asLog(): String {
|
||||
fun Throwable.asLog(): String = runCatching {
|
||||
val stringWriter = StringWriter(256)
|
||||
val printWriter = PrintWriter(stringWriter, false)
|
||||
printStackTrace(printWriter)
|
||||
printWriter.flush()
|
||||
return stringWriter.toString()
|
||||
stringWriter.toString()
|
||||
}.getOrElse { renderFailure ->
|
||||
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
|
||||
}
|
||||
|
||||
fun Throwable.asLogSummary(): String {
|
||||
val throwableClass = javaClass.name
|
||||
val throwableMessage = safeMessage()
|
||||
return if (throwableMessage.isNullOrBlank()) {
|
||||
throwableClass
|
||||
} else {
|
||||
"$throwableClass: $throwableMessage"
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
|
||||
|
||||
@PublishedApi
|
||||
internal fun Any.logTagViaCallSite(): String {
|
||||
val javaClass = this::class.java
|
||||
|
||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.scan
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
@@ -54,7 +53,6 @@ fun <T> Flow<T>.takeUntilAfter(predicate: suspend (T) -> Boolean) = transformWhi
|
||||
|
||||
fun <T> Flow<T>.setupCommonEventHandlers(tag: String, identifier: () -> String) = this
|
||||
.onStart { log(tag, VERBOSE) { "${identifier()}.onStart()" } }
|
||||
.onEach { log(tag, VERBOSE) { "${identifier()}.onEach(): $it" } }
|
||||
.onCompletion { log(tag, VERBOSE) { "${identifier()}.onCompletion()" } }
|
||||
.catch {
|
||||
if (it.hasCause(CancellationException::class)) {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package eu.darken.capod.common.permissions
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.PermissionChecker
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.withinApiLevel
|
||||
@@ -18,7 +17,7 @@ enum class Permission(
|
||||
val permissionId: String,
|
||||
val isScanBlocking: Boolean = false,
|
||||
val isGranted: (Context) -> Boolean = {
|
||||
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
|
||||
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
|
||||
},
|
||||
) {
|
||||
BLUETOOTH(
|
||||
@@ -87,4 +86,4 @@ enum class Permission(
|
||||
fun Permission.isRequired(context: Context): Boolean = when {
|
||||
!withinApiLevel(minApiLevel, maxApiLevel) -> false
|
||||
else -> !isGranted(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.datastore.createValue
|
||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
@@ -44,8 +43,6 @@ class GeneralSettings @Inject constructor(
|
||||
val keepConnectedNotificationAfterDisconnect =
|
||||
dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false)
|
||||
|
||||
val scannerMode = dataStore.createValue("core.scanner.mode", ScannerMode.BALANCED, json, onErrorFallbackToDefault = true)
|
||||
|
||||
val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f)
|
||||
|
||||
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
|
||||
@@ -79,6 +76,8 @@ class GeneralSettings @Inject constructor(
|
||||
|
||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||
|
||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||
|
||||
val themeMode = dataStore.createValue(
|
||||
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
||||
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.SingleEventFlow
|
||||
import eu.darken.capod.common.navigation.LocalNavigationController
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationController
|
||||
@@ -50,6 +51,11 @@ class MainActivity : Activity2() {
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
|
||||
// Buffers warm-start intents (onNewIntent) so they are consumed from inside the Compose tree,
|
||||
// after navCtrl.setup(backStack) has run. Calling navCtrl directly from onNewIntent races with
|
||||
// the asynchronous Compose lambda and crashes if the back stack is not yet registered.
|
||||
private val warmIntents = SingleEventFlow<Intent>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
@@ -80,7 +86,11 @@ class MainActivity : Activity2() {
|
||||
navCtrl.setup(backStack)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Cold-start intent (the activity's launching intent).
|
||||
consumeUpgradeExtra(intent)
|
||||
// Warm-start intents delivered via onNewIntent. Consuming them here (instead of
|
||||
// directly from onNewIntent) guarantees navCtrl.setup() has run.
|
||||
warmIntents.collect { newIntent -> consumeUpgradeExtra(newIntent) }
|
||||
}
|
||||
|
||||
CapodTheme(state = themeState) {
|
||||
@@ -132,7 +142,9 @@ class MainActivity : Activity2() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeUpgradeExtra(intent)
|
||||
setIntent(intent)
|
||||
// Defer to the Compose-side collector — navCtrl may not yet be set up here.
|
||||
warmIntents.tryEmit(intent)
|
||||
}
|
||||
|
||||
private fun consumeUpgradeExtra(intent: Intent?) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.AutoAwesome
|
||||
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
||||
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
||||
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
||||
}
|
||||
|
||||
|
||||
+138
-113
@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -41,14 +42,17 @@ import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsInfoBox
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.buildDeviceInfoDetailItems
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.rememberDeviceInfoDetailLabels
|
||||
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
|
||||
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
|
||||
@@ -61,6 +65,10 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun DeviceSettingsScreenHost(
|
||||
@@ -78,6 +86,19 @@ fun DeviceSettingsScreenHost(
|
||||
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
|
||||
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
|
||||
|
||||
val hasPendingSettings = state?.device?.hasPendingSettings
|
||||
var lastPendingState by remember { mutableStateOf<Boolean?>(null) }
|
||||
LaunchedEffect(hasPendingSettings) {
|
||||
val current = hasPendingSettings ?: return@LaunchedEffect
|
||||
val previous = lastPendingState
|
||||
lastPendingState = current
|
||||
if (previous == false && current) {
|
||||
snackbarHostState.showSnackbar(pendingInfoMessage)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
vm.events.collect { event ->
|
||||
@@ -99,6 +120,10 @@ fun DeviceSettingsScreenHost(
|
||||
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,8 +158,10 @@ fun DeviceSettingsScreenHost(
|
||||
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
|
||||
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
|
||||
onSleepDetectionChange = { vm.setSleepDetection(it) },
|
||||
onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) },
|
||||
onDeviceNameChange = { vm.setDeviceName(it) },
|
||||
onPressControlsClick = { vm.navToPressControls() },
|
||||
onEditProfile = { vm.navToEditProfile() },
|
||||
onForceConnect = { vm.forceConnect() },
|
||||
onUpgrade = { vm.launchUpgrade() },
|
||||
onOnePodModeChange = { vm.setOnePodMode(it) },
|
||||
@@ -146,6 +173,7 @@ fun DeviceSettingsScreenHost(
|
||||
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
||||
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
|
||||
onOpenIssueTracker = { vm.openIssueTracker() },
|
||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,8 +196,10 @@ fun DeviceSettingsScreen(
|
||||
onListeningModeCycleChange: (Int) -> Unit = {},
|
||||
onAllowOffOptionChange: (Boolean) -> Unit = {},
|
||||
onSleepDetectionChange: (Boolean) -> Unit = {},
|
||||
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||
onDeviceNameChange: (String) -> Unit = {},
|
||||
onPressControlsClick: () -> Unit = {},
|
||||
onEditProfile: () -> Unit = {},
|
||||
onForceConnect: () -> Unit = {},
|
||||
onUpgrade: () -> Unit = {},
|
||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||
@@ -181,6 +211,7 @@ fun DeviceSettingsScreen(
|
||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||
onFixMonitorMode: () -> Unit = {},
|
||||
onOpenIssueTracker: () -> Unit = {},
|
||||
onOpenAapTracker: () -> Unit = {},
|
||||
) {
|
||||
val device = state.device
|
||||
val features = device?.model?.features
|
||||
@@ -237,86 +268,18 @@ fun DeviceSettingsScreen(
|
||||
device.firstSeenFormatted(state.now)
|
||||
} else null
|
||||
val info = device.deviceInfo
|
||||
val detailItems = buildList<DeviceDetailItem> {
|
||||
if (info != null) {
|
||||
if (info.manufacturer.isNotBlank()) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_manufacturer_label),
|
||||
info.manufacturer
|
||||
)
|
||||
)
|
||||
}
|
||||
if (info.serialNumber.isNotBlank()) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_serial_label),
|
||||
info.serialNumber
|
||||
)
|
||||
)
|
||||
}
|
||||
val hasFirmware = info.firmwareVersion.isNotBlank()
|
||||
val hasBuild = !info.buildNumber.isNullOrBlank()
|
||||
if (hasFirmware && hasBuild) {
|
||||
add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_firmware_label),
|
||||
info.firmwareVersion
|
||||
),
|
||||
end = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_build_label),
|
||||
info.buildNumber!!
|
||||
),
|
||||
)
|
||||
)
|
||||
} else if (hasFirmware) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_firmware_label),
|
||||
info.firmwareVersion
|
||||
)
|
||||
)
|
||||
} else if (hasBuild) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_build_label),
|
||||
info.buildNumber!!
|
||||
)
|
||||
)
|
||||
}
|
||||
val hasLeft = !info.leftEarbudSerial.isNullOrBlank()
|
||||
val hasRight = !info.rightEarbudSerial.isNullOrBlank()
|
||||
if (hasLeft && hasRight) {
|
||||
add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_left_serial_label),
|
||||
info.leftEarbudSerial!!
|
||||
),
|
||||
end = DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_right_serial_label),
|
||||
info.rightEarbudSerial!!
|
||||
),
|
||||
)
|
||||
)
|
||||
} else if (hasLeft) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_left_serial_label),
|
||||
info.leftEarbudSerial!!
|
||||
)
|
||||
)
|
||||
} else if (hasRight) {
|
||||
add(
|
||||
DeviceDetailItem.Single(
|
||||
stringResource(R.string.device_settings_info_right_serial_label),
|
||||
info.rightEarbudSerial!!
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val locale = LocalConfiguration.current.locales[0]
|
||||
val zoneId = ZoneId.systemDefault()
|
||||
val dateFormatter = remember(locale, zoneId) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale)
|
||||
.withZone(zoneId)
|
||||
}
|
||||
val detailItems = buildDeviceInfoDetailItems(
|
||||
info = info,
|
||||
labels = rememberDeviceInfoDetailLabels(),
|
||||
formatDate = { instant -> dateFormatter.format(instant) },
|
||||
)
|
||||
DeviceInfoCard(
|
||||
deviceInfo = device.deviceInfo,
|
||||
modelLabel = buildModelLabel(device),
|
||||
@@ -331,27 +294,46 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Not connected info — BLE live but no AAP connection
|
||||
if (device != null && device.ble != null && !device.isAapConnected && device.address != null) {
|
||||
if (state.isClassicallyConnected) {
|
||||
// Device is connected for audio but AAP isn't available — show passive info
|
||||
item("aap_unavailable_info") {
|
||||
AapUnavailableCard()
|
||||
}
|
||||
} else {
|
||||
// Device is nearby but not connected — prompt user to connect
|
||||
item("not_connected_info") {
|
||||
NotConnectedCard(
|
||||
isNudgeAvailable = state.isNudgeAvailable,
|
||||
isForceConnecting = state.isForceConnecting,
|
||||
onConnect = onForceConnect,
|
||||
)
|
||||
}
|
||||
// Profile has no paired Bluetooth device — supersedes all other state cards
|
||||
if (device != null && !device.hasSelectedPairedDevice) {
|
||||
item("missing_paired_device") {
|
||||
MissingPairedDeviceBanner(
|
||||
onClick = onEditProfile,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reactions (per-profile, not gated on AAP) ─────────────────
|
||||
if (device != null && features != null) {
|
||||
// Not nearby — no live BLE; settings require the device to be present
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
device.ble == null && !state.isClassicallyConnected
|
||||
) {
|
||||
item("not_nearby_info") {
|
||||
SettingsInfoBox(
|
||||
title = stringResource(R.string.device_settings_not_nearby_label),
|
||||
text = stringResource(R.string.device_settings_not_nearby_description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Not connected info — device is nearby but not connected; prompt user to connect
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
device.ble != null && !device.isAapConnected && device.address != null &&
|
||||
!state.isClassicallyConnected
|
||||
) {
|
||||
item("not_connected_info") {
|
||||
NotConnectedCard(
|
||||
isNudgeAvailable = state.isNudgeAvailable,
|
||||
isForceConnecting = state.isForceConnecting,
|
||||
onConnect = onForceConnect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reactions (gated on classic connection — needs phone to be the audio target) ──
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
features != null && state.isClassicallyConnected
|
||||
) {
|
||||
item("reactions_section") {
|
||||
ReactionsCard(
|
||||
device = device,
|
||||
@@ -374,15 +356,7 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
|
||||
// Settings — only show when AAP is connected
|
||||
if (features != null && device.isAapConnected) {
|
||||
|
||||
if (device.hasPendingSettings == true) {
|
||||
item("pending_info") {
|
||||
SettingsInfoBox(
|
||||
text = stringResource(R.string.device_settings_pending_info),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
|
||||
|
||||
// ── Noise Control ────────────────────────────
|
||||
if (features.hasAncControl && device.ancMode != null) {
|
||||
@@ -412,7 +386,7 @@ fun DeviceSettingsScreen(
|
||||
val showSoundSection =
|
||||
(features.hasPersonalizedVolume && personalizedVol != null) ||
|
||||
(features.hasToneVolume && toneVol != null) ||
|
||||
features.hasMicrophoneMode
|
||||
(features.hasMicrophoneMode && device.microphoneMode != null)
|
||||
if (showSoundSection) {
|
||||
item("sound_section") {
|
||||
SoundCard(
|
||||
@@ -430,7 +404,7 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
|
||||
// ── Controls ─────────────────────────────────
|
||||
val showControlsSection = features.hasStemConfig ||
|
||||
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
@@ -449,6 +423,18 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────
|
||||
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
|
||||
item("battery_section") {
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = features,
|
||||
enabled = enabled,
|
||||
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connections ───────────────────────────────
|
||||
val connectedDevices = device.connectedDevices
|
||||
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
|
||||
@@ -462,13 +448,22 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// EQ visualization (debug only)
|
||||
val eqBands = device.eqBands
|
||||
if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) {
|
||||
// PME Config visualization (debug only, opcode 0x53).
|
||||
// PME = Personal Medical Equipment (hearing-aid band gains for iOS
|
||||
// 18.1+ AirPods Pro 2 hearing-aid mode). The bar chart reuses the
|
||||
// EQ visualization because the data shape (per-ear × per-profile ×
|
||||
// band gains) renders identically; hidden on all-zero payloads, which
|
||||
// just mean the user hasn't configured a hearing-aid profile yet.
|
||||
val pmeConfig = device.pmeConfig
|
||||
if (eu.darken.capod.BuildConfig.DEBUG &&
|
||||
pmeConfig != null &&
|
||||
pmeConfig.sets.isNotEmpty() &&
|
||||
!pmeConfig.isAllZero
|
||||
) {
|
||||
item("eq_section") {
|
||||
SettingsSection(title = stringResource(R.string.device_settings_eq_label)) {
|
||||
EqBarsChart(
|
||||
sets = eqBands.sets,
|
||||
sets = pmeConfig.sets,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
@@ -476,6 +471,16 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
|
||||
if (device != null && device.hasSelectedPairedDevice &&
|
||||
device.ble != null && !device.isAapConnected && device.address != null &&
|
||||
state.isClassicallyConnected
|
||||
) {
|
||||
item("aap_unavailable_info") {
|
||||
AapUnavailableCard(onOpenTracker = onOpenAapTracker)
|
||||
}
|
||||
}
|
||||
|
||||
item("bottom_spacer") {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
@@ -496,6 +501,12 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "7A305",
|
||||
hardwareVersion = "1.0.0",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
marketingVersion = "8454624",
|
||||
leftEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
|
||||
rightEarbudFirstPaired = Instant.ofEpochSecond(1697480211L),
|
||||
),
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
@@ -520,11 +531,13 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
|
||||
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
|
||||
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
|
||||
),
|
||||
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
now = MOCK_NOW,
|
||||
isPro = isPro,
|
||||
isClassicallyConnected = true,
|
||||
)
|
||||
|
||||
@Preview2
|
||||
@@ -590,3 +603,15 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
|
||||
onNavigateUp = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceSettingsMissingPairedDevicePreview() = PreviewWrapper {
|
||||
DeviceSettingsScreen(
|
||||
state = DeviceSettingsViewModel.State(
|
||||
device = MockPodDataProvider.dualPodMissingPairedDevice(),
|
||||
now = MOCK_NOW,
|
||||
),
|
||||
onNavigateUp = {},
|
||||
)
|
||||
}
|
||||
|
||||
+36
-1
@@ -79,6 +79,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||
data object SystemRenameUnavailable : Event
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object DynamicEndOfChargeRejectedByDevice : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
@@ -91,6 +92,16 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
aapManager.settingRejectedEvents.collect { (address, command) ->
|
||||
if (address != currentAddress()) return@collect
|
||||
when (command) {
|
||||
is AapCommand.SetDynamicEndOfCharge ->
|
||||
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
|
||||
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val state = targetProfileId.flatMapLatest { profileId ->
|
||||
@@ -283,7 +294,16 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled))
|
||||
fun setSleepDetection(enabled: Boolean) = launch {
|
||||
log(TAG, INFO) { "setSleepDetection($enabled)" }
|
||||
if (enabled && !upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
return@launch
|
||||
}
|
||||
sendInternal(AapCommand.SetSleepDetection(enabled))
|
||||
}
|
||||
|
||||
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
|
||||
|
||||
fun setDeviceName(name: String) = launch {
|
||||
val address = currentAddress() ?: return@launch
|
||||
@@ -419,9 +439,20 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
fun navToPressControls() = launch {
|
||||
log(TAG, INFO) { "navToPressControls()" }
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
val device = deviceMonitor.getDeviceForProfile(profileId)
|
||||
if (device?.isAapReady != true) {
|
||||
log(TAG, INFO) { "navToPressControls(): aborted, device not AAP-ready" }
|
||||
return@launch
|
||||
}
|
||||
navTo(Nav.Main.PressControls(profileId = profileId))
|
||||
}
|
||||
|
||||
fun navToEditProfile() = launch {
|
||||
log(TAG, INFO) { "navToEditProfile()" }
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
navTo(Nav.Main.DeviceProfileCreation(profileId = profileId))
|
||||
}
|
||||
|
||||
fun launchUpgrade() {
|
||||
log(TAG, INFO) { "launchUpgrade()" }
|
||||
navTo(Nav.Main.Upgrade)
|
||||
@@ -431,6 +462,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
webpageTool.open("https://github.com/d4rken-org/capod/issues")
|
||||
}
|
||||
|
||||
fun openAapCompatibilityTracker() {
|
||||
webpageTool.open("https://github.com/d4rken-org/capod/issues/538")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceSettings", "VM")
|
||||
private const val OFF_BIT = 0x01
|
||||
|
||||
+12
-1
@@ -9,7 +9,9 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -18,7 +20,9 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
internal fun AapUnavailableCard() {
|
||||
internal fun AapUnavailableCard(
|
||||
onOpenTracker: () -> Unit = {},
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -36,6 +40,13 @@ internal fun AapUnavailableCard() {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = onOpenTracker,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(stringResource(R.string.device_settings_aap_unavailable_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
|
||||
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
|
||||
* used by every other boolean setting.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BatteryCard(
|
||||
device: PodDevice,
|
||||
features: PodModel.Features,
|
||||
enabled: Boolean,
|
||||
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
if (!features.hasDynamicEndOfCharge) return
|
||||
val cap = device.dynamicEndOfCharge ?: return
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
title = stringResource(R.string.device_settings_charge_cap_label),
|
||||
subtitle = stringResource(R.string.device_settings_charge_cap_description),
|
||||
checked = cap.enabled,
|
||||
onCheckedChange = onDynamicEndOfChargeChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun BatteryCardEnabledPreview() = PreviewWrapper {
|
||||
val state = previewFullState(isPro = true)
|
||||
val device = state.device!!
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun BatteryCardDisabledPreview() = PreviewWrapper {
|
||||
val state = previewFullState(isPro = true)
|
||||
val device = state.device!!
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
|
||||
val volSwipe = device.volumeSwipe
|
||||
val volSwipeLen = device.volumeSwipeLength
|
||||
|
||||
val showPressControlsNav = features.hasStemConfig ||
|
||||
features.hasPressSpeed ||
|
||||
features.hasPressHoldDuration ||
|
||||
features.hasEndCallMuteMic
|
||||
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
|
||||
(features.hasPressSpeed && device.pressSpeed != null) ||
|
||||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
|
||||
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
|
||||
if (showPressControlsNav) {
|
||||
|
||||
+103
-45
@@ -18,6 +18,11 @@ 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.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
internal fun DeviceInfoBottomSheet(
|
||||
@@ -28,57 +33,110 @@ internal fun DeviceInfoBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_settings_info_details_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
items.forEach { item ->
|
||||
when (item) {
|
||||
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
|
||||
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(
|
||||
label = item.start.label,
|
||||
value = item.start.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoRow(
|
||||
label = item.end.label,
|
||||
value = item.end.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
DeviceInfoDetailsContent(items)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceInfoDetailsContent(items: List<DeviceDetailItem>) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_settings_info_details_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
items.forEach { item ->
|
||||
when (item) {
|
||||
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
|
||||
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(
|
||||
label = item.start.label,
|
||||
value = item.start.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoRow(
|
||||
label = item.end.label,
|
||||
value = item.end.value,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val previewDateFormatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneOffset.UTC)
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceInfoBottomSheetPreview() = PreviewWrapper {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Device Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
InfoRow(label = "Manufacturer", value = "Apple Inc.")
|
||||
InfoRow(label = "Serial Number", value = "W5J7KV0N04")
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(label = "Firmware", value = "7A305", modifier = Modifier.weight(1f))
|
||||
InfoRow(label = "Build", value = "8454624", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(label = "Left Pod Serial", value = "H3KL7HR926JY", modifier = Modifier.weight(1f))
|
||||
InfoRow(label = "Right Pod Serial", value = "H3KL2AYL26K0", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
|
||||
}
|
||||
}
|
||||
private fun DeviceInfoBottomSheetPreviewSameDayPairing() = PreviewWrapper {
|
||||
val pairedAt = Instant.parse("2023-10-16T12:00:00Z")
|
||||
val info = AapDeviceInfo(
|
||||
name = "AirPods Pro",
|
||||
modelNumber = "A2699",
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "81.2675000075000000.6814",
|
||||
hardwareVersion = "1.0.0",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
marketingVersion = "8454768",
|
||||
leftEarbudFirstPaired = pairedAt,
|
||||
rightEarbudFirstPaired = pairedAt,
|
||||
)
|
||||
DeviceInfoDetailsContent(
|
||||
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
|
||||
previewDateFormatter.format(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceInfoBottomSheetPreviewMismatchedPairing() = PreviewWrapper {
|
||||
val info = AapDeviceInfo(
|
||||
name = "AirPods Pro",
|
||||
modelNumber = "A2699",
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "81.2675000075000000.6814",
|
||||
firmwareVersionPending = "82.1000000075000000.7000",
|
||||
hardwareVersion = "1.0.0",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
marketingVersion = "8454768",
|
||||
leftEarbudFirstPaired = Instant.parse("2024-02-14T12:00:00Z"),
|
||||
rightEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
|
||||
)
|
||||
DeviceInfoDetailsContent(
|
||||
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
|
||||
previewDateFormatter.format(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DeviceInfoBottomSheetPreviewOneSidedPairing() = PreviewWrapper {
|
||||
val info = AapDeviceInfo(
|
||||
name = "AirPods Pro",
|
||||
modelNumber = "A2699",
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "7A305",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
leftEarbudFirstPaired = Instant.parse("2023-10-16T12:00:00Z"),
|
||||
)
|
||||
DeviceInfoDetailsContent(
|
||||
buildDeviceInfoDetailItems(info, rememberDeviceInfoDetailLabels()) {
|
||||
previewDateFormatter.format(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
|
||||
firmwareVersion = "7A305",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
buildNumber = "8454624",
|
||||
marketingVersion = "8454624",
|
||||
),
|
||||
modelLabel = "AirPods Pro 2 (A2699)",
|
||||
systemBluetoothName = "AirPods Pro",
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
|
||||
manufacturer = stringResource(R.string.device_settings_info_manufacturer_label),
|
||||
hardware = stringResource(R.string.device_settings_info_hardware_label),
|
||||
serial = stringResource(R.string.device_settings_info_serial_label),
|
||||
firmware = stringResource(R.string.device_settings_info_firmware_label),
|
||||
firmwarePending = stringResource(R.string.device_settings_info_firmware_pending_label),
|
||||
build = stringResource(R.string.device_settings_info_build_label),
|
||||
leftSerial = stringResource(R.string.device_settings_info_left_serial_label),
|
||||
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
|
||||
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
||||
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
||||
)
|
||||
|
||||
internal data class DeviceInfoDetailLabels(
|
||||
val manufacturer: String,
|
||||
val hardware: String,
|
||||
val serial: String,
|
||||
val firmware: String,
|
||||
val firmwarePending: String,
|
||||
val build: String,
|
||||
val leftSerial: String,
|
||||
val rightSerial: String,
|
||||
val leftBonded: String,
|
||||
val rightBonded: String,
|
||||
)
|
||||
|
||||
internal fun buildDeviceInfoDetailItems(
|
||||
info: AapDeviceInfo?,
|
||||
labels: DeviceInfoDetailLabels,
|
||||
formatDate: (Instant) -> String,
|
||||
): List<DeviceDetailItem> {
|
||||
if (info == null) return emptyList()
|
||||
return buildList {
|
||||
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
||||
}
|
||||
info.hardwareVersion?.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.hardware, it))
|
||||
}
|
||||
info.serialNumber.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.serial, it))
|
||||
}
|
||||
info.firmwareVersion.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.firmware, it))
|
||||
}
|
||||
info.firmwareVersionPending?.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.firmwarePending, it))
|
||||
}
|
||||
info.marketingVersion?.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.build, it))
|
||||
}
|
||||
val leftSerial = info.leftEarbudSerial?.takeIf { it.isNotBlank() }
|
||||
val rightSerial = info.rightEarbudSerial?.takeIf { it.isNotBlank() }
|
||||
when {
|
||||
leftSerial != null && rightSerial != null -> add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(labels.leftSerial, leftSerial),
|
||||
end = DeviceDetailItem.Single(labels.rightSerial, rightSerial),
|
||||
)
|
||||
)
|
||||
leftSerial != null -> add(DeviceDetailItem.Single(labels.leftSerial, leftSerial))
|
||||
rightSerial != null -> add(DeviceDetailItem.Single(labels.rightSerial, rightSerial))
|
||||
}
|
||||
val leftBonded = info.leftEarbudFirstPaired?.let(formatDate)
|
||||
val rightBonded = info.rightEarbudFirstPaired?.let(formatDate)
|
||||
when {
|
||||
leftBonded != null && rightBonded != null -> add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(labels.leftBonded, leftBonded),
|
||||
end = DeviceDetailItem.Single(labels.rightBonded, rightBonded),
|
||||
)
|
||||
)
|
||||
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
||||
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,10 @@ internal fun NoiseControlCard(
|
||||
text = stringResource(R.string.press_controls_long_press_anc_cycle_info),
|
||||
type = InfoBoxType.INFO,
|
||||
action = {
|
||||
TextButton(onClick = onPressControlsClick) {
|
||||
TextButton(
|
||||
onClick = onPressControlsClick,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Text(stringResource(R.string.device_settings_noise_control_open_press_controls_action))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -140,6 +140,7 @@ internal fun ReactionsCard(
|
||||
checked = sleepDet.enabled,
|
||||
onCheckedChange = onSleepDetectionChange,
|
||||
enabled = enabled,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (sleepDet.enabled) {
|
||||
SettingsInfoBox(
|
||||
|
||||
@@ -81,7 +81,7 @@ internal fun SoundCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (features.hasMicrophoneMode) {
|
||||
if (features.hasMicrophoneMode && device.microphoneMode != null) {
|
||||
if (isPro) {
|
||||
val micMode = device.microphoneMode
|
||||
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
|
||||
|
||||
@@ -59,6 +59,7 @@ import eu.darken.capod.main.ui.overview.cards.DualPodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
|
||||
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
@@ -67,6 +68,22 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
|
||||
internal fun profiledDeviceKey(
|
||||
device: PodDevice,
|
||||
index: Int,
|
||||
duplicateProfileIds: Set<String>,
|
||||
): String {
|
||||
val pid = requireNotNull(device.profileId)
|
||||
return if (pid !in duplicateProfileIds) {
|
||||
"profiled:$pid"
|
||||
} else {
|
||||
"profiled:$pid:${device.identifier ?: "idx:$index"}"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun unmatchedDeviceKey(device: PodDevice, index: Int): String =
|
||||
"unmatched:${device.identifier ?: "idx:$index"}"
|
||||
|
||||
@Composable
|
||||
fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
@@ -158,7 +175,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||
onDeviceSettings = { device ->
|
||||
if (currentState.showReactionsHint) vm.dismissReactionsHint()
|
||||
vm.goToDeviceSettings(device)
|
||||
},
|
||||
onEditProfile = { device -> vm.goToEditProfile(device) },
|
||||
onToggleDeviceExpansion = { device ->
|
||||
device.profileId?.let { vm.toggleDeviceExpansion(it) }
|
||||
@@ -286,9 +306,21 @@ fun OverviewScreen(
|
||||
|
||||
// 4. Profiled device cards (limited to 1 for free users)
|
||||
if (!state.isScanBlocked && state.isBluetoothEnabled) {
|
||||
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
|
||||
item(key = "reactions_hint") {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
}
|
||||
|
||||
val duplicateProfileIds = state.visibleProfiledDevices
|
||||
.mapNotNull { it.profileId }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
itemsIndexed(
|
||||
items = state.visibleProfiledDevices,
|
||||
key = { _, device -> requireNotNull(device.profileId) },
|
||||
key = { index, device -> profiledDeviceKey(device, index, duplicateProfileIds) },
|
||||
) { index, device ->
|
||||
val isCollapsed = !state.isExpanded(device, index)
|
||||
val isToggleable = state.isToggleable(device, index)
|
||||
@@ -337,10 +369,10 @@ fun OverviewScreen(
|
||||
}
|
||||
|
||||
if (state.showUnmatchedDevices) {
|
||||
items(
|
||||
itemsIndexed(
|
||||
items = state.unmatchedDevices,
|
||||
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
||||
) { device ->
|
||||
key = { index, device -> unmatchedDeviceKey(device, index) },
|
||||
) { _, device ->
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
isPro = state.upgradeInfo.isPro,
|
||||
@@ -414,6 +446,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
showReactionsHint = true,
|
||||
),
|
||||
onRequestPermission = {},
|
||||
onBluetoothSettings = {},
|
||||
|
||||
@@ -4,6 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
@@ -22,6 +23,7 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.tierRank
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
@@ -130,7 +132,9 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeRepo.upgradeInfo,
|
||||
showUnmatchedDevices,
|
||||
userExpansionOverrides,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds ->
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
profilesRepo.hadLegacyReactionData,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
||||
// Prune stale overrides (profiles that no longer exist)
|
||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||
@@ -145,6 +149,7 @@ class OverviewViewModel @Inject constructor(
|
||||
upgradeInfo = upgradeInfo,
|
||||
showUnmatchedDevices = showUnmatched,
|
||||
userExpandedIds = prunedExpandedIds,
|
||||
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
@@ -160,6 +165,7 @@ class OverviewViewModel @Inject constructor(
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
val showUnmatchedDevices: Boolean,
|
||||
val userExpandedIds: Set<String> = emptySet(),
|
||||
val showReactionsHint: Boolean = false,
|
||||
) {
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
|
||||
@@ -170,7 +176,7 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
val profiledDevices: List<PodDevice> by lazy {
|
||||
devices.filter { it.profileId != null }.sortedWith(
|
||||
compareBy<PodDevice> { deviceTierRank(it) }
|
||||
compareBy<PodDevice> { it.tierRank() }
|
||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||
)
|
||||
}
|
||||
@@ -252,6 +258,13 @@ class OverviewViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissReactionsHint() {
|
||||
log(TAG, INFO) { "dismissReactionsHint()" }
|
||||
launch {
|
||||
generalSettings.reactionsHintDismissed.value(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermission(permission: Permission) {
|
||||
log(TAG, INFO) { "requestPermission($permission)" }
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
@@ -272,12 +285,5 @@ class OverviewViewModel @Inject constructor(
|
||||
companion object {
|
||||
private const val FREE_DEVICE_LIMIT = 1
|
||||
private val TAG = logTag("Overview", "VM")
|
||||
|
||||
/** Connection tier rank for sorting: lower = higher priority. */
|
||||
internal fun deviceTierRank(device: PodDevice): Int = when {
|
||||
device.isSystemConnected -> 0
|
||||
device.isLive -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.GridView
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
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.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -52,6 +51,7 @@ import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
|
||||
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
||||
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow
|
||||
@@ -61,13 +61,12 @@ import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.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
|
||||
@@ -167,7 +166,7 @@ fun DualPodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
@@ -175,15 +174,15 @@ fun DualPodsCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (device.profileId != null && device.address == null && onEditProfile != null) {
|
||||
IconButton(onClick = onEditProfile) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCollapsed
|
||||
&& device.profileId != null
|
||||
&& !device.hasSelectedPairedDevice
|
||||
&& onEditProfile != null
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MissingPairedDeviceBanner(onClick = onEditProfile)
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
@@ -226,8 +225,9 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
) {
|
||||
PodGauge(
|
||||
iconRes = device.leftPodIcon,
|
||||
batteryPercent = device.batteryLeft.toBatteryFloat(),
|
||||
isCharging = device.isLeftPodCharging ?: false,
|
||||
batteryPercent = device.batteryLeft,
|
||||
chargingState = device.leftPodChargingState
|
||||
?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isLeftInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||
@@ -237,8 +237,9 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
|
||||
PodGauge(
|
||||
iconRes = device.rightPodIcon,
|
||||
batteryPercent = device.batteryRight.toBatteryFloat(),
|
||||
isCharging = device.isRightPodCharging ?: false,
|
||||
batteryPercent = device.batteryRight,
|
||||
chargingState = device.rightPodChargingState
|
||||
?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null },
|
||||
isInEar = device.isRightInEar ?: false,
|
||||
showEarDetection = device.hasEarDetection && device.hasDualPods,
|
||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||
@@ -292,7 +293,7 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
private fun PodGauge(
|
||||
iconRes: Int,
|
||||
batteryPercent: Float,
|
||||
isCharging: Boolean,
|
||||
chargingState: AapPodState.ChargingState?,
|
||||
isInEar: Boolean,
|
||||
showEarDetection: Boolean,
|
||||
isMicrophone: Boolean,
|
||||
@@ -357,7 +358,7 @@ private fun PodGauge(
|
||||
|
||||
// Battery percentage
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent.toBatteryOrNull()),
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
@@ -370,12 +371,13 @@ private fun PodGauge(
|
||||
|
||||
// Status chips
|
||||
StatusChipRow(
|
||||
isCharging = isCharging,
|
||||
chargingState = chargingState,
|
||||
isInEar = isInEar,
|
||||
showEarDetection = showEarDetection,
|
||||
isMicrophone = isMicrophone,
|
||||
showMicrophone = showMicrophone,
|
||||
chargingLabel = stringResource(R.string.pods_charging_label),
|
||||
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
||||
inEarLabel = stringResource(R.string.pods_inear_label),
|
||||
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
||||
)
|
||||
@@ -408,7 +410,7 @@ private fun CaseRow(
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase.toBatteryFloat(),
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
@@ -420,11 +422,17 @@ private fun CaseRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (device.isCaseCharging == true) {
|
||||
StatusChip(
|
||||
when (val caseState = device.caseChargingState
|
||||
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val lidState = device.caseLidState
|
||||
@@ -490,7 +498,7 @@ private fun DualPodsCardCollapsedPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun DualPodsCardMissingAddressPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
device = MockPodDataProvider.dualPodMissingPairedDevice(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
onEditProfile = {},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
fun ReactionsMovedHintCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_title),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.overview_reactions_hint_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun ReactionsMovedHintCardPreview() = PreviewWrapper {
|
||||
ReactionsMovedHintCard()
|
||||
}
|
||||
@@ -24,13 +24,12 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -50,6 +49,7 @@ import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
|
||||
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
|
||||
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
|
||||
import eu.darken.capod.main.ui.overview.cards.components.MissingPairedDeviceBanner
|
||||
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
|
||||
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
|
||||
import eu.darken.capod.common.SystemTimeSource
|
||||
@@ -58,8 +58,11 @@ import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import java.time.Instant
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -149,7 +152,7 @@ fun SinglePodsCard(
|
||||
}
|
||||
|
||||
if (device.profileId != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
OutlinedIconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
@@ -157,15 +160,15 @@ fun SinglePodsCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (device.profileId != null && device.address == null && onEditProfile != null) {
|
||||
IconButton(onClick = onEditProfile) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = stringResource(R.string.overview_card_missing_paired_device_cd),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCollapsed
|
||||
&& device.profileId != null
|
||||
&& !device.hasSelectedPairedDevice
|
||||
&& onEditProfile != null
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MissingPairedDeviceBanner(onClick = onEditProfile)
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
@@ -191,17 +194,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val clamped = device.batteryHeadset?.coerceIn(0f, 1f)
|
||||
val percent = device.batteryHeadset
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "gaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -236,7 +240,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(88.dp),
|
||||
@@ -249,9 +253,9 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
|
||||
// Battery text inside ring
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryHeadset),
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (device.batteryHeadset != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -267,11 +271,17 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (device.isHeadsetBeingCharged == true) {
|
||||
StatusChip(
|
||||
when (val headsetState = device.headsetChargingState
|
||||
?: device.isHeadsetBeingCharged?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
if (device.isBeingWorn == true) {
|
||||
StatusChip(
|
||||
@@ -359,7 +369,7 @@ private fun SinglePodsCardCollapsedPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper {
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodMonitored(),
|
||||
device = MockPodDataProvider.singlePodMissingPairedDevice(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
onEditProfile = {},
|
||||
|
||||
+17
-15
@@ -33,17 +33,19 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
|
||||
@Composable
|
||||
fun CompactBatterySummary(
|
||||
device: PodDevice,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasAnyBattery = device.batteryLeft != null
|
||||
|| device.batteryRight != null
|
||||
|| device.batteryHeadset != null
|
||||
|| device.batteryCase != null
|
||||
val hasAnyBattery = isKnownBattery(device.batteryLeft)
|
||||
|| isKnownBattery(device.batteryRight)
|
||||
|| isKnownBattery(device.batteryHeadset)
|
||||
|| isKnownBattery(device.batteryCase)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
@@ -80,7 +82,7 @@ private fun RowScope.DualPodsRow(device: PodDevice) {
|
||||
percent = device.batteryRight,
|
||||
)
|
||||
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ private fun RowScope.SinglePodRow(device: PodDevice) {
|
||||
iconRes = null,
|
||||
percent = device.batteryHeadset,
|
||||
)
|
||||
if (device.hasCase && device.batteryCase != null) {
|
||||
if (device.hasCase && isKnownBattery(device.batteryCase)) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
MiniCaseCluster(device = device)
|
||||
}
|
||||
@@ -121,21 +123,21 @@ private fun RowScope.EmptyBatteryRow() {
|
||||
@Composable
|
||||
private fun MiniPodRing(
|
||||
iconRes: Int?,
|
||||
percent: Float?,
|
||||
percent: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = percent?.coerceIn(0f, 1f)
|
||||
val isKnown = isKnownBattery(percent)
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = clamped ?: 0f,
|
||||
targetValue = batteryProgress(percent),
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "miniGaugeProgress",
|
||||
)
|
||||
|
||||
val ringColor = when {
|
||||
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
clamped > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
!isKnown -> MaterialTheme.colorScheme.surfaceVariant
|
||||
percent > 0.30f -> MaterialTheme.colorScheme.primary
|
||||
percent >= 0.15f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
@@ -155,7 +157,7 @@ private fun MiniPodRing(
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
if (clamped != null) {
|
||||
if (isKnown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.size(28.dp),
|
||||
@@ -177,7 +179,7 @@ private fun MiniPodRing(
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (percent != null) {
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -204,7 +206,7 @@ private fun MiniCaseCluster(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase ?: -1f,
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
.height(6.dp),
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package eu.darken.capod.main.ui.overview.cards.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
|
||||
@Composable
|
||||
fun MissingPairedDeviceBanner(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.semantics(mergeDescendants = true) {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.overview_card_missing_paired_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun MissingPairedDeviceBannerPreview() = PreviewWrapper {
|
||||
MissingPairedDeviceBanner(onClick = {})
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
|
||||
@Composable
|
||||
fun StatusChip(
|
||||
@@ -60,12 +61,13 @@ fun StatusChip(
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun StatusChipRow(
|
||||
isCharging: Boolean,
|
||||
chargingState: AapPodState.ChargingState?,
|
||||
isInEar: Boolean,
|
||||
showEarDetection: Boolean,
|
||||
isMicrophone: Boolean,
|
||||
showMicrophone: Boolean,
|
||||
chargingLabel: String,
|
||||
chargingOptimizedLabel: String,
|
||||
inEarLabel: String,
|
||||
microphoneLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -75,11 +77,16 @@ fun StatusChipRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (isCharging) {
|
||||
StatusChip(
|
||||
when (chargingState) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = chargingOptimizedLabel,
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = chargingLabel,
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
if (showMicrophone && isMicrophone) {
|
||||
StatusChip(
|
||||
@@ -106,12 +113,29 @@ private fun StatusChipChargingPreview() = PreviewWrapper {
|
||||
@Composable
|
||||
private fun StatusChipRowAllPreview() = PreviewWrapper {
|
||||
StatusChipRow(
|
||||
isCharging = true,
|
||||
chargingState = AapPodState.ChargingState.CHARGING,
|
||||
isInEar = true,
|
||||
showEarDetection = true,
|
||||
isMicrophone = true,
|
||||
showMicrophone = true,
|
||||
chargingLabel = "Charging",
|
||||
chargingOptimizedLabel = "Optimized",
|
||||
inEarLabel = "In Ear",
|
||||
microphoneLabel = "Mic",
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun StatusChipRowOptimizedPreview() = PreviewWrapper {
|
||||
StatusChipRow(
|
||||
chargingState = AapPodState.ChargingState.CHARGING_OPTIMIZED,
|
||||
isInEar = false,
|
||||
showEarDetection = true,
|
||||
isMicrophone = true,
|
||||
showMicrophone = true,
|
||||
chargingLabel = "Charging",
|
||||
chargingOptimizedLabel = "Optimized",
|
||||
inEarLabel = "In Ear",
|
||||
microphoneLabel = "Mic",
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -70,6 +71,18 @@ fun PressControlsScreenHost(
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val currentState = state ?: return
|
||||
|
||||
val isAapConnected = currentState.device?.isAapConnected == true
|
||||
var hasSeenAapConnected by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||
var didAutoNavigate by rememberSaveable(profileId) { mutableStateOf(false) }
|
||||
LaunchedEffect(profileId, isAapConnected) {
|
||||
if (isAapConnected) {
|
||||
hasSeenAapConnected = true
|
||||
} else if (hasSeenAapConnected && !didAutoNavigate) {
|
||||
didAutoNavigate = true
|
||||
vm.navUp()
|
||||
}
|
||||
}
|
||||
|
||||
PressControlsScreen(
|
||||
state = currentState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
|
||||
@@ -100,6 +100,13 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://github.com/furiousMAC/continuity") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "apple-wireshark",
|
||||
subtitle = "Thanks to Pablo Aul for the Wireshark dissector catalog of the AAP/AACP protocol.",
|
||||
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "crowdin.com",
|
||||
@@ -110,13 +117,6 @@ fun AcknowledgementsScreen(
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_licenses_label))
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Glide",
|
||||
subtitle = "An image loading and caching library for Android focused on smooth scrolling. (Multiple licenses)",
|
||||
onClick = { onOpenUrl("https://github.com/bumptech/glide") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Material Design Icons",
|
||||
@@ -145,6 +145,13 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://github.com/kavishdevar/librepods") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "apple-wireshark",
|
||||
subtitle = "Wireshark dissector plugins for Apple protocols. (GPL-3.0)",
|
||||
onClick = { onOpenUrl("https://github.com/pabloaul/apple-wireshark") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Kotlin",
|
||||
@@ -166,13 +173,6 @@ fun AcknowledgementsScreen(
|
||||
onClick = { onOpenUrl("https://source.android.com/source/licenses.html") },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = "Android",
|
||||
subtitle = "The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.",
|
||||
onClick = { onOpenUrl("https://developer.android.com/distribute/tools/promote/brand.html") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.material.icons.twotone.FilterList
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.twotone.Notifications
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.SettingsBluetooth
|
||||
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -45,7 +44,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
@@ -70,7 +68,6 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
state = it,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
|
||||
onScannerModeSelected = { mode -> vm.setScannerMode(mode) },
|
||||
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
||||
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
||||
onDebugSettings = { vm.goToDebugSettings() },
|
||||
@@ -90,7 +87,6 @@ fun GeneralSettingsScreen(
|
||||
state: GeneralSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onMonitorModeSelected: (MonitorMode) -> Unit,
|
||||
onScannerModeSelected: (ScannerMode) -> Unit,
|
||||
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
||||
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
||||
onDebugSettings: () -> Unit,
|
||||
@@ -104,7 +100,6 @@ fun GeneralSettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMonitorModeDialog by remember { mutableStateOf(false) }
|
||||
var showScannerModeDialog by remember { mutableStateOf(false) }
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
||||
@@ -138,14 +133,6 @@ fun GeneralSettingsScreen(
|
||||
onClick = { showMonitorModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_scanner_mode_label),
|
||||
subtitle = stringResource(state.scannerMode.labelRes),
|
||||
icon = Icons.TwoTone.SettingsBluetooth,
|
||||
onClick = { showScannerModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
||||
}
|
||||
@@ -313,20 +300,6 @@ fun GeneralSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showScannerModeDialog) {
|
||||
ListPreferenceDialog(
|
||||
title = stringResource(R.string.settings_scanner_mode_label),
|
||||
entries = ScannerMode.entries,
|
||||
selectedEntry = state.scannerMode,
|
||||
onEntrySelected = {
|
||||
onScannerModeSelected(it)
|
||||
showScannerModeDialog = false
|
||||
},
|
||||
entryLabel = { stringResource(it.labelRes) },
|
||||
onDismiss = { showScannerModeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showColorDialog) {
|
||||
ThemeColorSelectorDialog(
|
||||
selectedColor = state.themeState.color,
|
||||
@@ -342,7 +315,6 @@ fun GeneralSettingsScreen(
|
||||
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
||||
isPro = isPro,
|
||||
monitorMode = MonitorMode.AUTOMATIC,
|
||||
scannerMode = ScannerMode.BALANCED,
|
||||
showConnectedNotification = true,
|
||||
keepNotificationAfterDisconnect = false,
|
||||
isOffloadedFilteringDisabled = false,
|
||||
@@ -358,7 +330,6 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
||||
state = previewGeneralState(isPro = true),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onScannerModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
@@ -375,7 +346,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
||||
state = previewGeneralState(isPro = false),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onScannerModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
|
||||
+4
-13
@@ -1,7 +1,6 @@
|
||||
package eu.darken.capod.main.ui.settings.general
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -32,7 +31,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
data class State(
|
||||
val isPro: Boolean,
|
||||
val monitorMode: MonitorMode,
|
||||
val scannerMode: ScannerMode,
|
||||
val showConnectedNotification: Boolean,
|
||||
val keepNotificationAfterDisconnect: Boolean,
|
||||
val isOffloadedFilteringDisabled: Boolean,
|
||||
@@ -46,12 +44,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
val state = combine(
|
||||
combine(
|
||||
generalSettings.monitorMode.flow,
|
||||
generalSettings.scannerMode.flow,
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { monitorMode, scannerMode, showNotif, keepNotif ->
|
||||
) { monitorMode, showNotif, keepNotif ->
|
||||
@Suppress("USELESS_CAST")
|
||||
arrayOf<Any>(monitorMode as Any, scannerMode as Any, showNotif as Any, keepNotif as Any)
|
||||
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
|
||||
},
|
||||
combine(
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -67,9 +64,8 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
State(
|
||||
isPro = isPro,
|
||||
monitorMode = general[0] as MonitorMode,
|
||||
scannerMode = general[1] as ScannerMode,
|
||||
showConnectedNotification = general[2] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[3] as Boolean,
|
||||
showConnectedNotification = general[1] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[2] as Boolean,
|
||||
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||
@@ -82,11 +78,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
generalSettings.monitorMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setScannerMode(mode: ScannerMode) {
|
||||
log(TAG, INFO) { "setScannerMode($mode)" }
|
||||
generalSettings.scannerMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setShowConnectedNotification(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
||||
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Process-scoped trailing-edge debouncer for tile-driven `SetAncMode` commands.
|
||||
*
|
||||
* The QS panel collapses after each tap, destroying the [AncTileService] instance.
|
||||
* If the pending-job state lived on the service, taps across consecutive panel
|
||||
* sessions wouldn't cancel each other — every tap would fire a separate
|
||||
* `SetAncMode`, overwhelming the AAP verification loop and triggering
|
||||
* "Rejected after retry" storms that leave the device unresponsive until the
|
||||
* app restarts. Keeping the job here, on a `@Singleton`, means a tap in panel
|
||||
* session B can cancel the deferred send queued by panel session A.
|
||||
*/
|
||||
@Singleton
|
||||
class AncTileSendCoordinator @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val aapManager: AapConnectionManager,
|
||||
) {
|
||||
|
||||
private val lock = Any()
|
||||
private val pendingJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||
private val timeoutJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||
|
||||
private val _pendingModes = MutableStateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>>(emptyMap())
|
||||
val pendingModes: StateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>> = _pendingModes.asStateFlow()
|
||||
|
||||
fun scheduleSetAncMode(
|
||||
address: BluetoothAddress,
|
||||
mode: AapSetting.AncMode.Value,
|
||||
debounce: Duration,
|
||||
timeout: Duration = 5.seconds,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val replacing = pendingJobs[address]?.isActive == true
|
||||
log(TAG, VERBOSE) { "scheduleSetAncMode($mode, addr=$address, debounce=$debounce, replacingPending=$replacing)" }
|
||||
_pendingModes.value = _pendingModes.value + (address to mode)
|
||||
|
||||
pendingJobs.remove(address)?.cancel()
|
||||
timeoutJobs.remove(address)?.cancel()
|
||||
|
||||
pendingJobs[address] = appScope.launch {
|
||||
delay(debounce)
|
||||
log(TAG, VERBOSE) { "debounce elapsed, dispatching SetAncMode($mode) to AAP for $address" }
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
|
||||
log(TAG, VERBOSE) { "sent SetAncMode($mode) to $address" }
|
||||
} catch (e: CancellationException) {
|
||||
log(TAG, VERBOSE) { "send for $mode cancelled (newer tap superseded it)" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "sendCommand failed: ${e.asLog()}" }
|
||||
clearPendingTarget(address, mode)
|
||||
}
|
||||
}
|
||||
|
||||
timeoutJobs[address] = appScope.launch {
|
||||
delay(timeout)
|
||||
if (clearPendingTargetFromTimeout(address, mode)) {
|
||||
log(TAG, VERBOSE) { "pending tile target $mode timed out before device confirmation" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applyPendingTarget(state: AncTileState): AncTileState {
|
||||
val active = state as? AncTileState.Active ?: return state
|
||||
val address = active.deviceAddress ?: return active
|
||||
val target = pendingModes.value[address] ?: return active
|
||||
|
||||
if (target !in active.visible) return active
|
||||
|
||||
if (active.isConfirmed(target)) return active
|
||||
|
||||
return active.copy(pending = target)
|
||||
}
|
||||
|
||||
internal fun acknowledgeDeviceState(state: AncTileState) {
|
||||
val active = state as? AncTileState.Active ?: return
|
||||
val address = active.deviceAddress ?: return
|
||||
val target = pendingModes.value[address] ?: return
|
||||
|
||||
if (target !in active.visible || active.isConfirmed(target)) {
|
||||
clearPendingTarget(address, target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AncTileState.Active.isConfirmed(target: AapSetting.AncMode.Value): Boolean =
|
||||
pending == target || (current == target && pending == null)
|
||||
|
||||
private fun clearPendingTarget(
|
||||
address: BluetoothAddress,
|
||||
expectedMode: AapSetting.AncMode.Value,
|
||||
): Boolean = synchronized(lock) {
|
||||
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||
if (current != expectedMode) return@synchronized false
|
||||
|
||||
_pendingModes.value = _pendingModes.value - address
|
||||
pendingJobs.remove(address)?.cancel()
|
||||
timeoutJobs.remove(address)?.cancel()
|
||||
true
|
||||
}
|
||||
|
||||
private fun clearPendingTargetFromTimeout(
|
||||
address: BluetoothAddress,
|
||||
expectedMode: AapSetting.AncMode.Value,
|
||||
): Boolean = synchronized(lock) {
|
||||
val current = _pendingModes.value[address] ?: return@synchronized false
|
||||
if (current != expectedMode) return@synchronized false
|
||||
|
||||
_pendingModes.value = _pendingModes.value - address
|
||||
pendingJobs.remove(address)?.cancel()
|
||||
timeoutJobs.remove(address)
|
||||
true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Tile", "Anc", "Coord")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.SystemClock
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.throttleLatest
|
||||
import eu.darken.capod.common.hasApiLevel
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||
import eu.darken.capod.main.ui.components.shortLabel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Quick Settings tile that cycles ANC modes for the user-perceived primary AirPods.
|
||||
* Mirrors [eu.darken.capod.main.ui.widget.AncGlanceWidget] but renders into the system
|
||||
* QS panel instead of a home-screen widget.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AncTileService : TileService() {
|
||||
|
||||
@Inject lateinit var dispatcherProvider: DispatcherProvider
|
||||
@Inject lateinit var sendCoordinator: AncTileSendCoordinator
|
||||
@Inject lateinit var stateStore: AncTileStateStore
|
||||
|
||||
private var listenScope: CoroutineScope? = null
|
||||
private val instanceId = Integer.toHexString(System.identityHashCode(this))
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
log(TAG, VERBOSE) { "onCreate(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||
}
|
||||
|
||||
override fun onTileAdded() {
|
||||
log(TAG, VERBOSE) { "onTileAdded(instance=$instanceId)" }
|
||||
// Render a static placeholder so we never block SystemUI's bind path on a
|
||||
// first-emission DataStore/Billing read. onStartListening will fill in real state.
|
||||
renderTile(AncTileState.Connecting)
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
log(TAG, VERBOSE) { "onStartListening(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
|
||||
listenScope?.cancel()
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.Default)
|
||||
listenScope = scope
|
||||
scope.launch {
|
||||
stateStore.state.throttleLatest(250).collect { state ->
|
||||
log(TAG, VERBOSE) { "collector: state=$state" }
|
||||
withContext(dispatcherProvider.Main) { renderTile(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
log(TAG, VERBOSE) { "onStopListening(instance=$instanceId)" }
|
||||
listenScope?.cancel()
|
||||
listenScope = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
log(TAG, VERBOSE) { "onDestroy(instance=$instanceId)" }
|
||||
lastDestroyAt = now
|
||||
listenScope?.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
log(TAG, VERBOSE) { "onClick(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()}) received tap" }
|
||||
val state = stateStore.currentState()
|
||||
log(TAG, VERBOSE) {
|
||||
"onClick: resolved from state store=$state"
|
||||
}
|
||||
dispatchClick(state)
|
||||
}
|
||||
|
||||
private fun dispatchClick(state: AncTileState) {
|
||||
log(TAG, VERBOSE) { "dispatchClick($state)" }
|
||||
when (state) {
|
||||
AncTileState.NotPro,
|
||||
AncTileState.PermissionRequired -> openMainActivityWithUpgrade()
|
||||
is AncTileState.Active -> sendNextMode(state)
|
||||
AncTileState.BluetoothOff,
|
||||
AncTileState.NoDevice,
|
||||
AncTileState.NoAncSupport,
|
||||
AncTileState.NotConnected,
|
||||
AncTileState.Connecting -> {
|
||||
// Tile state is STATE_UNAVAILABLE; system shouldn't deliver clicks here,
|
||||
// but defensively no-op so we don't crash on unexpected delivery.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNextMode(state: AncTileState.Active) {
|
||||
val nextMode = pickNextMode(state.visible, state.current, state.pending)
|
||||
log(TAG, VERBOSE) {
|
||||
"sendNextMode: visible=${state.visible} current=${state.current} pending=${state.pending} -> next=$nextMode"
|
||||
}
|
||||
if (nextMode == null || nextMode == (state.pending ?: state.current)) {
|
||||
log(TAG, VERBOSE) { "sendNextMode: no advance possible (visible=${state.visible})" }
|
||||
return
|
||||
}
|
||||
val address: BluetoothAddress = state.deviceAddress ?: run {
|
||||
log(TAG, WARN) { "sendNextMode: state has no device address" }
|
||||
return
|
||||
}
|
||||
|
||||
sendCoordinator.scheduleSetAncMode(address, nextMode, 1.seconds)
|
||||
|
||||
val optimisticState = state.copy(pending = nextMode)
|
||||
renderTile(optimisticState)
|
||||
}
|
||||
|
||||
private fun openMainActivityWithUpgrade() {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
|
||||
}
|
||||
if (hasApiLevel(34)) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderTile(state: AncTileState) {
|
||||
val tile = qsTile ?: run {
|
||||
log(TAG, VERBOSE) { "renderTile: skipped, qsTile is null (state=$state)" }
|
||||
return
|
||||
}
|
||||
log(TAG, VERBOSE) { "renderTile: $state" }
|
||||
val baseLabel = getString(R.string.tile_anc_label)
|
||||
|
||||
val (subtitle, iconRes, tileState) = when (state) {
|
||||
AncTileState.NotPro -> Triple(
|
||||
getString(R.string.common_upgrade_required_label),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_INACTIVE,
|
||||
)
|
||||
AncTileState.PermissionRequired -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_permission_required),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_INACTIVE,
|
||||
)
|
||||
AncTileState.BluetoothOff -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_bluetooth_off),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.NoDevice -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_no_device),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.NoAncSupport -> Triple(
|
||||
getString(R.string.tile_anc_subtitle_no_anc_support),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.NotConnected -> Triple(
|
||||
getString(R.string.anc_widget_aap_not_connected_label),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
AncTileState.Connecting -> Triple(
|
||||
getString(R.string.anc_widget_aap_connecting_label),
|
||||
R.drawable.ic_anc_off,
|
||||
Tile.STATE_UNAVAILABLE,
|
||||
)
|
||||
is AncTileState.Active -> {
|
||||
val displayMode = state.pending?.takeIf { it in state.visible } ?: state.current
|
||||
Triple(
|
||||
displayMode.shortLabel(this),
|
||||
displayMode.iconDrawableRes(),
|
||||
Tile.STATE_ACTIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tile.icon = Icon.createWithResource(this, iconRes)
|
||||
tile.state = tileState
|
||||
if (hasApiLevel(29)) {
|
||||
tile.label = baseLabel
|
||||
tile.subtitle = subtitle
|
||||
} else {
|
||||
// Pre-API 29 tiles can't show a subtitle; fold it into the label.
|
||||
tile.label = "$baseLabel · $subtitle"
|
||||
}
|
||||
if (hasApiLevel(30)) {
|
||||
tile.stateDescription = subtitle
|
||||
}
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Tile", "Anc")
|
||||
@Volatile private var lastDestroyAt: Long? = null
|
||||
|
||||
private fun elapsedSinceLastDestroy(): String {
|
||||
val destroyedAt = lastDestroyAt ?: return "n/a"
|
||||
return "${SystemClock.elapsedRealtime() - destroyedAt}ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Pure data → data mapper. Decides which [AncTileState] to render given a snapshot
|
||||
* of inputs. Mirrors [AncWidgetRenderStateMapper]'s precedence so the two surfaces
|
||||
* stay in sync, while remaining unit-testable without Android resources.
|
||||
*
|
||||
* Note: cached/offline-tier devices land in [AncTileState.NotConnected] (not
|
||||
* [AncTileState.Connecting]) — without an active AAP session, "Connecting…" would
|
||||
* be misleading because nothing is in progress.
|
||||
*/
|
||||
object AncTileStateMapper {
|
||||
|
||||
fun map(
|
||||
device: PodDevice?,
|
||||
isPro: Boolean,
|
||||
isBluetoothEnabled: Boolean,
|
||||
missingPermissions: Set<Permission>,
|
||||
): AncTileState {
|
||||
if (!isPro) return AncTileState.NotPro
|
||||
if (missingPermissions.any { it.isTileBlocking }) return AncTileState.PermissionRequired
|
||||
if (!isBluetoothEnabled) return AncTileState.BluetoothOff
|
||||
if (device == null) return AncTileState.NoDevice
|
||||
if (!device.hasAncControl) return AncTileState.NoAncSupport
|
||||
if (!device.isAapConnected) return AncTileState.NotConnected
|
||||
if (!device.isAapReady) return AncTileState.Connecting
|
||||
|
||||
val ancMode = device.ancMode ?: return AncTileState.Connecting
|
||||
val visible = device.visibleAncModes
|
||||
if (visible.isEmpty()) return AncTileState.Connecting
|
||||
|
||||
return AncTileState.Active(
|
||||
current = ancMode.current,
|
||||
pending = device.pendingAncMode,
|
||||
visible = visible,
|
||||
deviceLabel = device.label,
|
||||
deviceAddress = device.address,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A permission whose absence prevents the tile from working. Scan-blocking permissions
|
||||
* gate BLE; [Permission.BLUETOOTH_CONNECT] gates the AAP L2CAP socket — without either,
|
||||
* the tile cannot do useful work.
|
||||
*/
|
||||
private val Permission.isTileBlocking: Boolean
|
||||
get() = isScanBlocking || this == Permission.BLUETOOTH_CONNECT
|
||||
|
||||
sealed interface AncTileState {
|
||||
data object NotPro : AncTileState
|
||||
data object PermissionRequired : AncTileState
|
||||
data object BluetoothOff : AncTileState
|
||||
data object NoDevice : AncTileState
|
||||
data object NoAncSupport : AncTileState
|
||||
data object NotConnected : AncTileState
|
||||
data object Connecting : AncTileState
|
||||
data class Active(
|
||||
val current: AapSetting.AncMode.Value,
|
||||
val pending: AapSetting.AncMode.Value?,
|
||||
val visible: List<AapSetting.AncMode.Value>,
|
||||
val deviceLabel: String?,
|
||||
val deviceAddress: BluetoothAddress?,
|
||||
) : AncTileState
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles to the next mode in [visible]. Anchors on [pending] when it's still in
|
||||
* [visible] so rapid taps walk forward through the list rather than oscillate against
|
||||
* the device-echoed [current]. A [pending] that has been filtered out (e.g. user toggled
|
||||
* Allow Off mid-cycle) falls through to [current].
|
||||
*/
|
||||
internal fun pickNextMode(
|
||||
visible: List<AapSetting.AncMode.Value>,
|
||||
current: AapSetting.AncMode.Value?,
|
||||
pending: AapSetting.AncMode.Value?,
|
||||
): AapSetting.AncMode.Value? {
|
||||
if (visible.isEmpty()) return null
|
||||
val anchor = pending?.takeIf { it in visible } ?: current ?: return visible.first()
|
||||
val idx = visible.indexOf(anchor)
|
||||
return if (idx < 0) visible.first() else visible[(idx + 1) % visible.size]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package eu.darken.capod.main.ui.tile
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.flow.combine
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.primaryByTier
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.combine as combineFlows
|
||||
|
||||
/**
|
||||
* Process-scoped state holder for the ANC QS tile.
|
||||
*
|
||||
* TileService instances are short-lived and can disappear between taps. Keeping
|
||||
* the latest state here lets a recreated service resolve clicks synchronously
|
||||
* while the app process is alive. A cold or hydrating process intentionally
|
||||
* reports [AncTileState.Connecting] so the tile UI is honest about not being
|
||||
* ready for input yet.
|
||||
*/
|
||||
@Singleton
|
||||
class AncTileStateStore @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
deviceMonitor: DeviceMonitor,
|
||||
profilesRepo: DeviceProfilesRepo,
|
||||
upgradeRepo: UpgradeRepo,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
permissionTool: PermissionTool,
|
||||
private val sendCoordinator: AncTileSendCoordinator,
|
||||
) {
|
||||
|
||||
private val rawState: StateFlow<AncTileState> = combine(
|
||||
deviceMonitor.devices,
|
||||
profilesRepo.profiles,
|
||||
upgradeRepo.upgradeInfo.map { it.isPro },
|
||||
bluetoothManager.isBluetoothEnabled,
|
||||
permissionTool.missingPermissions,
|
||||
) { devices, profiles, isPro, isBluetoothEnabled, missingPermissions ->
|
||||
val profileOrder = profiles.mapIndexed { idx, p -> p.id to idx }.toMap()
|
||||
val device = devices.primaryByTier(profileOrder)
|
||||
AncTileStateMapper.map(
|
||||
device = device,
|
||||
isPro = isPro,
|
||||
isBluetoothEnabled = isBluetoothEnabled,
|
||||
missingPermissions = missingPermissions,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = AncTileState.Connecting,
|
||||
)
|
||||
|
||||
init {
|
||||
appScope.launch {
|
||||
rawState.collect { state -> sendCoordinator.acknowledgeDeviceState(state) }
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<AncTileState> = combineFlows(
|
||||
rawState,
|
||||
sendCoordinator.pendingModes,
|
||||
) { rawState, _ ->
|
||||
sendCoordinator.applyPendingTarget(rawState)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = appScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = AncTileState.Connecting,
|
||||
)
|
||||
|
||||
fun currentState(): AncTileState = sendCoordinator.applyPendingTarget(rawState.value)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class AncGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -43,17 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val ep: AncWidgetEntryPoint
|
||||
val appWidgetId: Int
|
||||
val initialIsPro: Boolean
|
||||
val initialProfileId: String?
|
||||
val cachedDevice: PodDevice?
|
||||
|
||||
try {
|
||||
ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java)
|
||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||
appWidgetId,
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
@@ -73,26 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
provideContent {
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
val heightDp = LocalSize.current.height
|
||||
|
||||
val state = try {
|
||||
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
val theme = WidgetTheme.fromBundle(
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
|
||||
)
|
||||
// Glance keeps the content session alive and does not restart provideGlance()
|
||||
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||
// changes update active sessions without recomposing on every BLE advertisement.
|
||||
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
val state = if (config != null) {
|
||||
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||
.getOrDefault(false)
|
||||
val initialDevice = remember(config.profileId) {
|
||||
config.profileId?.let { pid ->
|
||||
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
val device by config.profileId
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
}
|
||||
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||
|
||||
val widthCells = getCellsForSize(widthDp.value.toInt())
|
||||
val heightCells = getCellsForSize(heightDp.value.toInt())
|
||||
@@ -108,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
AncWidgetRenderStateMapper.map(
|
||||
context = context,
|
||||
device = device,
|
||||
theme = theme,
|
||||
theme = config.theme,
|
||||
isPro = isPro,
|
||||
hasConfiguredProfile = profileId != null,
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
layout = layout,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
||||
} else {
|
||||
AncWidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
@@ -158,7 +171,7 @@ class AncGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
private fun getCellsForSize(size: Int): Int {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
while (70 * n - 30 <= size) {
|
||||
++n
|
||||
}
|
||||
return n - 1
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.annotation.Keep
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
fun aapConnectionManager(): AapConnectionManager
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceMonitor(): DeviceMonitor
|
||||
fun widgetManager(): WidgetManager
|
||||
}
|
||||
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
@@ -53,7 +53,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
|
||||
val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
|
||||
|
||||
val profileId = ep.widgetSettings().getWidgetProfile(widgetId)
|
||||
val profileId = ep.widgetSettings().getWidgetConfig(widgetId).profileId
|
||||
if (profileId == null) {
|
||||
log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
|
||||
return
|
||||
@@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback {
|
||||
val device = ep.deviceMonitor().getDeviceForProfile(profileId)
|
||||
if (device == null) {
|
||||
log(TAG, ERROR) { "onAction: no device for profileId=$profileId" }
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
val address = device.address
|
||||
if (address == null) {
|
||||
log(TAG, ERROR) { "onAction: device has no address" }
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback {
|
||||
log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" }
|
||||
}
|
||||
|
||||
AncGlanceWidget().updateAll(context)
|
||||
ep.widgetManager().refreshWidgets()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.components.iconDrawableRes
|
||||
import eu.darken.capod.main.ui.components.shortLabel
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
@@ -57,13 +58,21 @@ object AncWidgetRenderStateMapper {
|
||||
primaryText = context.getString(R.string.anc_widget_no_anc_support_label),
|
||||
)
|
||||
|
||||
!device.isAapConnected -> AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = context.getString(R.string.anc_widget_aap_not_connected_label),
|
||||
)
|
||||
!device.isAapConnected -> {
|
||||
val secondaryRes = if (device.ble != null) {
|
||||
R.string.anc_widget_aap_not_connected_nearby_description
|
||||
} else {
|
||||
R.string.anc_widget_aap_not_connected_not_nearby_description
|
||||
}
|
||||
AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = profileLabel ?: device.getLabel(context),
|
||||
secondaryText = context.getString(secondaryRes),
|
||||
)
|
||||
}
|
||||
|
||||
!device.isAapReady -> AncWidgetRenderState.Message(
|
||||
theme = theme,
|
||||
@@ -121,10 +130,3 @@ object AncWidgetRenderStateMapper {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
|
||||
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
|
||||
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
@@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -43,26 +45,26 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val ep: WidgetEntryPoint
|
||||
val appWidgetId: Int
|
||||
val initialIsPro: Boolean
|
||||
val initialProfileId: String?
|
||||
val cachedDevice: PodDevice?
|
||||
|
||||
try {
|
||||
ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
|
||||
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
initialIsPro = ep.upgradeRepo().isPro()
|
||||
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
|
||||
ep.widgetSettings().migrateLegacyConfigIfNeeded(
|
||||
appWidgetId,
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
|
||||
provideContent {
|
||||
val layout = BatteryLayout.forCells(getCellsForSize(LocalSize.current.width.value.toInt()))
|
||||
GlanceWidgetContent(
|
||||
state = WidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
layout = layout,
|
||||
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
||||
),
|
||||
context = context,
|
||||
@@ -72,45 +74,57 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
}
|
||||
|
||||
provideContent {
|
||||
// Composable reads — must be outside try-catch
|
||||
val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
|
||||
|
||||
val state = try {
|
||||
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
val theme = WidgetTheme.fromBundle(
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
|
||||
)
|
||||
// Glance keeps the content session alive and does not restart provideGlance()
|
||||
// for every update(). Observe a widget-key-deduped device flow so visible state
|
||||
// changes update active sessions without recomposing on every BLE advertisement.
|
||||
val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) }
|
||||
.onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.profileId == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
val state = if (config != null) {
|
||||
val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } }
|
||||
.getOrDefault(false)
|
||||
val initialDevice = remember(config.profileId) {
|
||||
config.profileId?.let { pid ->
|
||||
runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } }
|
||||
.onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
val device by config.profileId
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
}
|
||||
.onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5
|
||||
log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" }
|
||||
|
||||
WidgetRenderStateMapper.map(
|
||||
context = context,
|
||||
device = device,
|
||||
theme = theme,
|
||||
theme = config.theme,
|
||||
isPro = isPro,
|
||||
hasConfiguredProfile = profileId != null,
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
isWide = isWide,
|
||||
layout = layout,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
||||
} else {
|
||||
WidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
layout = layout,
|
||||
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
||||
)
|
||||
}
|
||||
@@ -137,7 +151,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
*/
|
||||
private fun getCellsForSize(size: Int): Int {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
while (70 * n - 30 <= size) {
|
||||
++n
|
||||
}
|
||||
return n - 1
|
||||
|
||||
@@ -25,12 +25,13 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -87,86 +88,51 @@ private fun DualPodPreview(
|
||||
val iconColor = Color(state.resolvedIconColor)
|
||||
val iconTint = ColorFilter.tint(iconColor)
|
||||
|
||||
if (state.isWide) {
|
||||
// Wide layout: left | case | right in a horizontal row
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Left pod
|
||||
PodItemRow(
|
||||
icon = state.leftIcon,
|
||||
percent = state.leftPercent,
|
||||
charging = state.leftCharging,
|
||||
inEar = state.leftInEar,
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
// Case
|
||||
PodItemRow(
|
||||
icon = state.caseIcon,
|
||||
percent = state.casePercent,
|
||||
charging = state.caseCharging,
|
||||
inEar = false,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
// Right pod
|
||||
PodItemRow(
|
||||
icon = state.rightIcon,
|
||||
percent = state.rightPercent,
|
||||
charging = state.rightCharging,
|
||||
inEar = state.rightInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Compact layout: vertical stack
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
PodItemRow(
|
||||
icon = state.leftIcon,
|
||||
percent = state.leftPercent,
|
||||
charging = state.leftCharging,
|
||||
inEar = state.leftInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
PodItemRow(
|
||||
icon = state.rightIcon,
|
||||
percent = state.rightPercent,
|
||||
charging = state.rightCharging,
|
||||
inEar = state.rightInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
PodItemRow(
|
||||
icon = state.caseIcon,
|
||||
percent = state.casePercent,
|
||||
charging = state.caseCharging,
|
||||
inEar = false,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
BatteryLayout.NARROW -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
TinyPodItem(state.leftIcon, state.leftPercent, textColor, iconTint)
|
||||
TinyPodItem(state.rightIcon, state.rightPercent, textColor, iconTint)
|
||||
TinyPodItem(state.caseIcon, state.casePercent, textColor, iconTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,45 +146,60 @@ private fun SinglePodPreview(
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent.toBatteryOrNull()),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
TinyPodItem(state.batteryIcon, state.percent, textColor, iconTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,17 +210,25 @@ private fun MessagePreview(
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = state.primaryText,
|
||||
fontSize = 12.sp,
|
||||
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (state.secondaryText != null) {
|
||||
if (!isCompact && state.secondaryText != null) {
|
||||
Text(
|
||||
text = state.secondaryText,
|
||||
fontSize = 12.sp,
|
||||
@@ -260,11 +249,17 @@ private fun LoadingPreview(
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
WidgetContainer(
|
||||
bgColor = bgColor,
|
||||
modifier = modifier,
|
||||
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = "…",
|
||||
fontSize = 12.sp,
|
||||
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -277,13 +272,15 @@ private fun LoadingPreview(
|
||||
private fun WidgetContainer(
|
||||
bgColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalPadding: Dp = 16.dp,
|
||||
verticalPadding: Dp = 4.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -314,7 +311,7 @@ private fun PodItemRow(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent.toBatteryOrNull()),
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
@@ -338,6 +335,34 @@ private fun PodItemRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TinyPodItem(
|
||||
icon: Int,
|
||||
percent: Float,
|
||||
textColor: Color,
|
||||
iconTint: ColorFilter,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceLabel(
|
||||
label: String?,
|
||||
@@ -357,18 +382,35 @@ private fun DeviceLabel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
private fun formatPercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualTiny11() = PreviewWrapper {
|
||||
ComposeWidgetPreview(
|
||||
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualCompact() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod())
|
||||
private fun PreviewDualTinyTall() = PreviewWrapper {
|
||||
ComposeWidgetPreview(
|
||||
state = WidgetRenderState.previewDualPod(layout = BatteryLayout.TINY_COLUMN),
|
||||
modifier = Modifier.size(width = 40.dp, height = 110.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualNarrow() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.NARROW))
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualWide() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true))
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(layout = BatteryLayout.WIDE))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package eu.darken.capod.main.ui.widget
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
@@ -26,7 +27,7 @@ import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -53,31 +54,47 @@ private fun GlanceDualPod(
|
||||
state: WidgetRenderState.DualPod,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||
|
||||
if (state.isWide) {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
} else {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
|
||||
BatteryLayout.NARROW -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
GlanceTinyPodItem(state.leftIcon, state.leftPercent, textStyle, iconTint)
|
||||
GlanceTinyPodItem(state.rightIcon, state.rightPercent, textStyle, iconTint)
|
||||
GlanceTinyPodItem(state.caseIcon, state.casePercent, textStyle, iconTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,47 +104,60 @@ private fun GlanceSinglePod(
|
||||
state: WidgetRenderState.SinglePod,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent.toBatteryOrNull()),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
when (state.layout) {
|
||||
BatteryLayout.WIDE, BatteryLayout.NARROW -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
|
||||
BatteryLayout.TINY_COLUMN -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 0.dp,
|
||||
) {
|
||||
GlanceTinyPodItem(state.batteryIcon, state.percent, textStyle, iconTint)
|
||||
}
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,20 +166,29 @@ private fun GlanceMessage(
|
||||
state: WidgetRenderState.Message,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
val fontSize = if (isCompact) 10.sp else 12.sp
|
||||
val hPad = if (isCompact) 4.dp else 16.dp
|
||||
val vPad = if (isCompact) 0.dp else 4.dp
|
||||
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = hPad,
|
||||
verticalPadding = vPad,
|
||||
) {
|
||||
Text(
|
||||
text = state.primaryText,
|
||||
style = textStyle,
|
||||
style = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
maxLines = if (isCompact) 2 else Int.MAX_VALUE,
|
||||
)
|
||||
if (state.secondaryText != null) {
|
||||
if (!isCompact && state.secondaryText != null) {
|
||||
Text(
|
||||
text = state.secondaryText,
|
||||
style = TextStyle(
|
||||
@@ -169,12 +208,18 @@ private fun GlanceLoading(
|
||||
state: WidgetRenderState.Loading,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
val isCompact = state.layout == BatteryLayout.TINY_COLUMN
|
||||
GlanceWidgetRoot(
|
||||
bgColor = state.resolvedBgColor,
|
||||
clickModifier = clickModifier,
|
||||
horizontalPadding = if (isCompact) 4.dp else 16.dp,
|
||||
verticalPadding = if (isCompact) 0.dp else 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = "…",
|
||||
style = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
fontSize = if (isCompact) 10.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
@@ -187,13 +232,15 @@ private fun GlanceLoading(
|
||||
private fun GlanceWidgetRoot(
|
||||
bgColor: Int,
|
||||
clickModifier: GlanceModifier,
|
||||
horizontalPadding: Dp = 16.dp,
|
||||
verticalPadding: Dp = 4.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = clickModifier
|
||||
.fillMaxSize()
|
||||
.background(fixedColor(bgColor))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -223,7 +270,7 @@ private fun GlancePodItem(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent.toBatteryOrNull()),
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
@@ -246,6 +293,33 @@ private fun GlancePodItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceTinyPodItem(
|
||||
icon: Int,
|
||||
percent: Float,
|
||||
textStyle: TextStyle,
|
||||
iconTint: ColorFilter,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(14.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceDeviceLabel(
|
||||
label: String?,
|
||||
@@ -267,6 +341,5 @@ private fun GlanceDeviceLabel(
|
||||
|
||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||
|
||||
private fun formatGlancePercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
private fun formatGlancePercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WidgetConfig(
|
||||
val profileId: ProfileId? = null,
|
||||
val theme: WidgetTheme = WidgetTheme(),
|
||||
)
|
||||
@@ -47,19 +47,21 @@ class WidgetConfigurationViewModel @Inject constructor(
|
||||
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
log(TAG) { "Invalid widget ID" }
|
||||
} else {
|
||||
widgetSettings.migrateLegacyConfigIfNeeded(widgetId, appWidgetManager.getAppWidgetOptions(widgetId))
|
||||
}
|
||||
}
|
||||
|
||||
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
|
||||
|
||||
private val initialTheme: WidgetTheme = run {
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
|
||||
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
|
||||
private val initialConfig: WidgetConfig = run {
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetConfig()
|
||||
widgetSettings.getWidgetConfig(widgetId)
|
||||
}
|
||||
|
||||
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null)
|
||||
private val selectedProfile = MutableStateFlow(initialConfig.profileId)
|
||||
|
||||
private val currentTheme = MutableStateFlow(initialTheme)
|
||||
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialConfig.theme) == null)
|
||||
|
||||
private val currentTheme = MutableStateFlow(initialConfig.theme)
|
||||
|
||||
private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
|
||||
if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
|
||||
@@ -152,18 +154,16 @@ class WidgetConfigurationViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun confirmSelection() {
|
||||
val selectedProfile = selectedProfile.value
|
||||
if (selectedProfile != null) {
|
||||
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
|
||||
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
log(TAG, INFO) { "confirmSelection: invalid widget ID, skipping save" }
|
||||
return
|
||||
}
|
||||
|
||||
// Save theme to AppWidgetOptions bundle
|
||||
val theme = currentTheme.value
|
||||
log(TAG, INFO) { "confirmSelection: saving theme=$theme" }
|
||||
val options = appWidgetManager.getAppWidgetOptions(widgetId)
|
||||
theme.toBundle(options)
|
||||
appWidgetManager.updateAppWidgetOptions(widgetId, options)
|
||||
val config = WidgetConfig(
|
||||
profileId = selectedProfile.value,
|
||||
theme = currentTheme.value,
|
||||
)
|
||||
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, config=$config)" }
|
||||
widgetSettings.saveWidgetConfig(widgetId, config)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal fun DeviceMonitor.widgetDeviceFlow(profileId: String): Flow<PodDevice?> = devices
|
||||
.map { devices -> devices.firstOrNull { it.profileId == profileId } }
|
||||
.distinctUntilChangedBy { it?.toWidgetKey() }
|
||||
@@ -0,0 +1,57 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.visibleAncModes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Snapshot of the [PodDevice] fields that affect widget rendering. Used to
|
||||
* gate widget refreshes so RSSI / reliability / scan-counter / timestamp churn
|
||||
* doesn't cause a refresh on every BLE advertisement.
|
||||
*/
|
||||
internal data class WidgetDeviceKey(
|
||||
val profileId: String?,
|
||||
val profileLabel: String?,
|
||||
val model: PodModel,
|
||||
val batteryLeft: Float,
|
||||
val batteryRight: Float,
|
||||
val batteryCase: Float,
|
||||
val batteryHeadset: Float,
|
||||
val isLeftPodCharging: Boolean?,
|
||||
val isRightPodCharging: Boolean?,
|
||||
val isCaseCharging: Boolean?,
|
||||
val isHeadsetBeingCharged: Boolean?,
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val isAapConnected: Boolean,
|
||||
val isAapReady: Boolean,
|
||||
val hasBleAdvertisement: Boolean,
|
||||
val ancMode: AapSetting.AncMode.Value?,
|
||||
val pendingAncMode: AapSetting.AncMode.Value?,
|
||||
val visibleAncModes: List<AapSetting.AncMode.Value>,
|
||||
)
|
||||
|
||||
internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
profileId = profileId,
|
||||
profileLabel = label,
|
||||
model = model,
|
||||
batteryLeft = batteryLeft,
|
||||
batteryRight = batteryRight,
|
||||
batteryCase = batteryCase,
|
||||
batteryHeadset = batteryHeadset,
|
||||
isLeftPodCharging = isLeftPodCharging,
|
||||
isRightPodCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
isHeadsetBeingCharged = isHeadsetBeingCharged,
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
hasBleAdvertisement = ble != null,
|
||||
ancMode = ancMode?.current,
|
||||
pendingAncMode = pendingAncMode,
|
||||
visibleAncModes = visibleAncModes,
|
||||
)
|
||||
@@ -1,9 +1,16 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
@@ -16,9 +23,63 @@ class WidgetManager @Inject constructor(
|
||||
) {
|
||||
|
||||
suspend fun refreshWidgets() {
|
||||
log(TAG, VERBOSE) { "refreshWidgets()" }
|
||||
BatteryGlanceWidget().updateAll(context)
|
||||
AncGlanceWidget().updateAll(context)
|
||||
val manager = GlanceAppWidgetManager(context)
|
||||
refreshWidget(
|
||||
name = "battery",
|
||||
widget = BatteryGlanceWidget(),
|
||||
providerClass = BatteryGlanceWidget::class.java,
|
||||
receiverClass = WidgetProvider::class.java,
|
||||
manager = manager,
|
||||
)
|
||||
refreshWidget(
|
||||
name = "anc",
|
||||
widget = AncGlanceWidget(),
|
||||
providerClass = AncGlanceWidget::class.java,
|
||||
receiverClass = AncWidgetProvider::class.java,
|
||||
manager = manager,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T : GlanceAppWidget> refreshWidget(
|
||||
name: String,
|
||||
widget: T,
|
||||
providerClass: Class<T>,
|
||||
receiverClass: Class<*>,
|
||||
manager: GlanceAppWidgetManager,
|
||||
) {
|
||||
val ids = manager.getGlanceIds(providerClass).ifEmpty {
|
||||
val appWidgetIds = AppWidgetManager.getInstance(context)
|
||||
.getAppWidgetIds(ComponentName(context, receiverClass))
|
||||
.toList()
|
||||
if (appWidgetIds.isNotEmpty()) {
|
||||
log(TAG, WARN) { "refresh($name): Glance IDs empty, falling back to platform ids=$appWidgetIds" }
|
||||
}
|
||||
appWidgetIds.mapNotNull { appWidgetId ->
|
||||
runCatching { manager.getGlanceIdBy(appWidgetId) }
|
||||
.onFailure { error ->
|
||||
log(TAG, ERROR) { "refresh($name): failed to resolve widgetId=$appWidgetId: ${error.asLog()}" }
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "refresh($name): ids=${ids.toAppWidgetIds(manager)}" }
|
||||
|
||||
ids.forEach { id ->
|
||||
runCatching { widget.update(context, id) }
|
||||
.onFailure { error ->
|
||||
log(TAG, ERROR) { "refresh($name): update failed for id=${id.toAppWidgetId(manager)}: ${error.asLog()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<GlanceId>.toAppWidgetIds(manager: GlanceAppWidgetManager): List<Int> =
|
||||
map { it.toAppWidgetId(manager) }
|
||||
|
||||
private fun GlanceId.toAppWidgetId(manager: GlanceAppWidgetManager): Int = runCatching {
|
||||
manager.getAppWidgetId(this)
|
||||
}.getOrElse {
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -4,18 +4,33 @@ import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
|
||||
enum class BatteryLayout {
|
||||
TINY_COLUMN,
|
||||
NARROW,
|
||||
WIDE;
|
||||
|
||||
companion object {
|
||||
fun forCells(widthCells: Int): BatteryLayout = when {
|
||||
widthCells <= 1 -> TINY_COLUMN
|
||||
widthCells >= 5 -> WIDE
|
||||
else -> NARROW
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class WidgetRenderState {
|
||||
abstract val theme: WidgetTheme
|
||||
@get:ColorInt abstract val resolvedBgColor: Int
|
||||
@get:ColorInt abstract val resolvedTextColor: Int
|
||||
@get:ColorInt abstract val resolvedIconColor: Int
|
||||
abstract val layout: BatteryLayout
|
||||
|
||||
data class DualPod(
|
||||
override val theme: WidgetTheme,
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
val isWide: Boolean,
|
||||
override val layout: BatteryLayout,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val leftIcon: Int,
|
||||
val leftPercent: Float,
|
||||
@@ -35,6 +50,7 @@ sealed class WidgetRenderState {
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
override val layout: BatteryLayout,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val headsetIcon: Int,
|
||||
val percent: Float,
|
||||
@@ -48,6 +64,7 @@ sealed class WidgetRenderState {
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
override val layout: BatteryLayout,
|
||||
val primaryText: String,
|
||||
val secondaryText: String? = null,
|
||||
) : WidgetRenderState()
|
||||
@@ -57,6 +74,7 @@ sealed class WidgetRenderState {
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
override val layout: BatteryLayout,
|
||||
) : WidgetRenderState()
|
||||
|
||||
companion object {
|
||||
@@ -65,13 +83,13 @@ sealed class WidgetRenderState {
|
||||
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
|
||||
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
|
||||
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
|
||||
isWide: Boolean = false,
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
): DualPod = DualPod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
isWide = isWide,
|
||||
layout = layout,
|
||||
deviceLabel = "My AirPods Pro",
|
||||
leftIcon = R.drawable.device_airpods_pro2_left,
|
||||
leftPercent = 0.85f,
|
||||
@@ -85,5 +103,25 @@ sealed class WidgetRenderState {
|
||||
casePercent = 1.0f,
|
||||
caseCharging = false,
|
||||
)
|
||||
|
||||
fun previewSinglePod(
|
||||
theme: WidgetTheme = WidgetTheme.DEFAULT,
|
||||
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
|
||||
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
|
||||
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
): SinglePod = SinglePod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
deviceLabel = "My AirPods Max",
|
||||
headsetIcon = R.drawable.device_airpods_max,
|
||||
percent = 0.72f,
|
||||
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
|
||||
charging = false,
|
||||
worn = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
|
||||
@@ -18,7 +17,7 @@ object WidgetRenderStateMapper {
|
||||
isPro: Boolean,
|
||||
hasConfiguredProfile: Boolean,
|
||||
profileLabel: String?,
|
||||
isWide: Boolean = false,
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
): WidgetRenderState {
|
||||
val bgColor = resolvedBgColor(context, theme)
|
||||
val textColor = resolvedTextColor(context, theme)
|
||||
@@ -30,6 +29,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
primaryText = context.getString(R.string.upgrade_capod_label),
|
||||
secondaryText = context.getString(R.string.upgrade_capod_description),
|
||||
)
|
||||
@@ -39,18 +39,18 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
isWide = isWide,
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeft.toBatteryFloat(),
|
||||
leftPercent = device.batteryLeft,
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRight.toBatteryFloat(),
|
||||
rightPercent = device.batteryRight,
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase.toBatteryFloat(),
|
||||
casePercent = device.batteryCase,
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
)
|
||||
|
||||
@@ -59,9 +59,10 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadset.toBatteryFloat(),
|
||||
percent = device.batteryHeadset,
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == true,
|
||||
worn = device.isBeingWorn == true,
|
||||
@@ -72,6 +73,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
primaryText = context.getString(R.string.pods_unknown_label),
|
||||
)
|
||||
|
||||
@@ -86,6 +88,7 @@ object WidgetRenderStateMapper {
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
layout = layout,
|
||||
primaryText = context.getString(messageRes),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.SharedPreferencesMigration
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
@@ -11,15 +12,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WidgetSettings @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
@ApplicationContext private val context: Context,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(
|
||||
@@ -29,32 +34,76 @@ class WidgetSettings @Inject constructor(
|
||||
|
||||
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
||||
|
||||
fun saveWidgetProfile(widgetId: Int, profileId: ProfileId) {
|
||||
log(TAG, VERBOSE) { "saveWidgetProfile(widgetId=$widgetId, profileId=$profileId)" }
|
||||
runBlocking {
|
||||
dataStore.edit { it[stringPreferencesKey(getWidgetProfileKey(widgetId))] = profileId }
|
||||
fun getWidgetConfig(widgetId: Int): WidgetConfig {
|
||||
val raw = runBlocking { dataStore.data.first()[configKey(widgetId)] }
|
||||
if (raw == null) {
|
||||
log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = absent → default" }
|
||||
return WidgetConfig()
|
||||
}
|
||||
return try {
|
||||
json.decodeFromString<WidgetConfig>(raw).also {
|
||||
log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = $it" }
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
log(TAG) { "getWidgetConfig(widgetId=$widgetId): malformed JSON, returning default ($e)" }
|
||||
WidgetConfig()
|
||||
}
|
||||
}
|
||||
|
||||
fun getWidgetProfile(widgetId: Int): ProfileId? {
|
||||
val profileId = runBlocking {
|
||||
dataStore.data.first()[stringPreferencesKey(getWidgetProfileKey(widgetId))]
|
||||
fun saveWidgetConfig(widgetId: Int, config: WidgetConfig) {
|
||||
log(TAG, VERBOSE) { "saveWidgetConfig(widgetId=$widgetId, config=$config)" }
|
||||
val encoded = json.encodeToString(config)
|
||||
runBlocking {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[configKey(widgetId)] = encoded
|
||||
prefs.remove(legacyProfileKey(widgetId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot migration from the legacy storage layout (separate `widget_profile_<id>` DataStore key
|
||||
* + per-widget theme in `AppWidgetManager.getAppWidgetOptions()`) to the unified
|
||||
* `widget_config_<id>` JSON. Marker-guarded inside the same `dataStore.edit {}` block so
|
||||
* concurrent saves can't be overwritten.
|
||||
*
|
||||
* Pre-reboot the legacy options Bundle still carries the user's theme; post-reboot it does not
|
||||
* (Android only persists the standard `OPTION_APPWIDGET_*` keys).
|
||||
*/
|
||||
fun migrateLegacyConfigIfNeeded(widgetId: Int, legacyOptions: Bundle) {
|
||||
val legacyTheme = WidgetTheme.fromLegacyBundleOrNull(legacyOptions)
|
||||
runBlocking {
|
||||
dataStore.edit { prefs ->
|
||||
if (prefs[configKey(widgetId)] != null) return@edit
|
||||
val legacyProfileId = prefs[legacyProfileKey(widgetId)]
|
||||
if (legacyProfileId == null && legacyTheme == null) return@edit
|
||||
val migrated = WidgetConfig(
|
||||
profileId = legacyProfileId,
|
||||
theme = legacyTheme ?: WidgetTheme(),
|
||||
)
|
||||
log(TAG) { "migrateLegacyConfigIfNeeded(widgetId=$widgetId): writing $migrated" }
|
||||
prefs[configKey(widgetId)] = json.encodeToString(migrated)
|
||||
prefs.remove(legacyProfileKey(widgetId))
|
||||
}
|
||||
}
|
||||
log(TAG, VERBOSE) { "getWidgetProfile(widgetId=$widgetId) = $profileId" }
|
||||
return profileId
|
||||
}
|
||||
|
||||
fun removeWidget(widgetId: Int) {
|
||||
log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" }
|
||||
runBlocking {
|
||||
dataStore.edit { it.remove(stringPreferencesKey(getWidgetProfileKey(widgetId))) }
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(configKey(widgetId))
|
||||
prefs.remove(legacyProfileKey(widgetId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWidgetProfileKey(widgetId: Int): String = "$WIDGET_PROFILE_PREFIX$widgetId"
|
||||
private fun configKey(widgetId: Int) = stringPreferencesKey("$WIDGET_CONFIG_PREFIX$widgetId")
|
||||
private fun legacyProfileKey(widgetId: Int) = stringPreferencesKey("$LEGACY_WIDGET_PROFILE_PREFIX$widgetId")
|
||||
|
||||
companion object {
|
||||
private const val WIDGET_PROFILE_PREFIX = "widget_profile_"
|
||||
private const val WIDGET_CONFIG_PREFIX = "widget_config_"
|
||||
private const val LEGACY_WIDGET_PROFILE_PREFIX = "widget_profile_"
|
||||
private val TAG = logTag("Widget", "Settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WidgetTheme(
|
||||
@ColorInt val backgroundColor: Int? = null,
|
||||
@ColorInt val foregroundColor: Int? = null,
|
||||
@@ -36,7 +38,18 @@ data class WidgetTheme(
|
||||
|
||||
val DEFAULT = WidgetTheme()
|
||||
|
||||
fun fromBundle(bundle: Bundle): WidgetTheme {
|
||||
/**
|
||||
* Reads a [WidgetTheme] from the legacy `AppWidgetManager.getAppWidgetOptions()` Bundle.
|
||||
* Returns `null` if the Bundle holds none of the legacy keys — distinguishing "system reset
|
||||
* the Bundle to system-only keys" from "user picked Material You + 255".
|
||||
*/
|
||||
fun fromLegacyBundleOrNull(bundle: Bundle): WidgetTheme? {
|
||||
val hasLegacyKey = bundle.containsKey(KEY_THEME_MODE)
|
||||
|| bundle.containsKey(KEY_BG_ALPHA)
|
||||
|| bundle.containsKey(KEY_SHOW_LABEL)
|
||||
|| bundle.containsKey(KEY_CUSTOM_BG)
|
||||
|| bundle.containsKey(KEY_CUSTOM_FG)
|
||||
if (!hasLegacyKey) return null
|
||||
val mode = bundle.getString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
|
||||
val showLabel = bundle.getBoolean(KEY_SHOW_LABEL, true)
|
||||
val alpha = bundle.getInt(KEY_BG_ALPHA, 255).coerceIn(0, 255)
|
||||
@@ -71,25 +84,4 @@ data class WidgetTheme(
|
||||
}
|
||||
}
|
||||
|
||||
fun toBundle(bundle: Bundle) {
|
||||
if (backgroundColor == null && foregroundColor == null) {
|
||||
bundle.putString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
|
||||
bundle.remove(KEY_CUSTOM_BG)
|
||||
bundle.remove(KEY_CUSTOM_FG)
|
||||
} else {
|
||||
bundle.putString(KEY_THEME_MODE, MODE_CUSTOM)
|
||||
if (backgroundColor != null) {
|
||||
bundle.putInt(KEY_CUSTOM_BG, backgroundColor)
|
||||
} else {
|
||||
bundle.remove(KEY_CUSTOM_BG)
|
||||
}
|
||||
if (foregroundColor != null) {
|
||||
bundle.putInt(KEY_CUSTOM_FG, foregroundColor)
|
||||
} else {
|
||||
bundle.remove(KEY_CUSTOM_FG)
|
||||
}
|
||||
}
|
||||
bundle.putInt(KEY_BG_ALPHA, backgroundAlpha.coerceIn(0, 255))
|
||||
bundle.putBoolean(KEY_SHOW_LABEL, showDeviceLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
@@ -20,6 +23,7 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.toReactionConfig
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
|
||||
connectedAddresses = connectedAddresses,
|
||||
)
|
||||
}.onEach { liveState ->
|
||||
persistLiveDevices(liveState.liveDevices)
|
||||
persistLiveDevices(liveState.liveDevices + aapOnlyForPersistence(liveState))
|
||||
}
|
||||
|
||||
/**
|
||||
* AAP-only profiles whose state didn't make it into [LiveMergeState.liveDevices] (no BLE pod
|
||||
* in the current scan). Without this, [persistLiveDevices] would only ever see BLE-backed
|
||||
* devices, and AAP-delivered DeviceInfo (earbud serials, marketing version) for an out-of-BLE
|
||||
* pod would never reach the cache even though the AAP socket is alive.
|
||||
*/
|
||||
private fun aapOnlyForPersistence(state: LiveMergeState): List<PodDevice> {
|
||||
val coveredProfileIds = state.liveDevices.mapNotNull { it.profileId }.toSet()
|
||||
return state.profiles.mapNotNull { profile ->
|
||||
if (profile.id in coveredProfileIds) return@mapNotNull null
|
||||
val aap = state.aapStates.forProfile(profile) ?: return@mapNotNull null
|
||||
PodDevice(
|
||||
profileId = profile.id,
|
||||
label = profile.label,
|
||||
ble = null,
|
||||
aap = aap,
|
||||
profileAddress = profile.address,
|
||||
profileModel = profile.model,
|
||||
profileKeyState = profile.toBleKeyState(),
|
||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||
reactions = profile.toReactionConfig(),
|
||||
isSystemConnected = profile.address in state.connectedAddresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val devices: Flow<List<PodDevice>> = combine(
|
||||
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}.replayingShare(appScope)
|
||||
|
||||
private val reportedPersistFailures = mutableSetOf<String>()
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
for (device in devices) {
|
||||
val profileId = device.profileId ?: continue
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
try {
|
||||
val existing = deviceStateCache.cachedStates.value[profileId]
|
||||
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
|
||||
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
log(TAG, VERBOSE) { "Persisting state for $profileId" }
|
||||
deviceStateCache.save(profileId, newState)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "Failed to persist state for $profileId: ${e.asLog()}" }
|
||||
if (reportedPersistFailures.add(profileId)) {
|
||||
runCatching { Bugs.report(tag = TAG, message = "persistLiveDevices failed for $profileId", exception = e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
@@ -65,6 +66,13 @@ data class PodDevice(
|
||||
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
|
||||
/**
|
||||
* True when the matched profile has a selected paired Bluetooth device. Unlike [address],
|
||||
* this never falls back to the cache, so a profile whose paired device was removed still
|
||||
* reports `false` even while a stale cached address lingers.
|
||||
*/
|
||||
val hasSelectedPairedDevice: Boolean
|
||||
get() = (ble?.meta?.profile?.address ?: profileAddress) != null
|
||||
/** BLE scan address (RPA, rotates). */
|
||||
val bleAddress: BluetoothAddress? get() = ble?.address
|
||||
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
|
||||
@@ -78,6 +86,7 @@ data class PodDevice(
|
||||
val hasEarDetection: Boolean get() = model.features.hasEarDetection
|
||||
val hasAncControl: Boolean get() = model.features.hasAncControl
|
||||
val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone
|
||||
val hasDynamicEndOfCharge: Boolean get() = model.features.hasDynamicEndOfCharge
|
||||
|
||||
// Signal / timing
|
||||
val seenLastAt: Instant?
|
||||
@@ -130,18 +139,24 @@ data class PodDevice(
|
||||
}
|
||||
}
|
||||
|
||||
// Battery — AAP preferred, BLE fallback, then cached
|
||||
val batteryLeft: Float?
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: cached?.left?.percent
|
||||
// Battery — AAP preferred, BLE fallback, then cached. Returns BATTERY_UNKNOWN (-1f)
|
||||
// for unknown to keep the type primitive; this is the boundary that previously emitted
|
||||
// Float? and triggered Android 10 ART JIT crashes via R8-merged unbox call sites.
|
||||
val batteryLeft: Float
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
?: cached?.left?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryRight: Float?
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: cached?.right?.percent
|
||||
val batteryRight: Float
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
?: cached?.right?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryCase: Float?
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: cached?.case?.percent
|
||||
val batteryCase: Float
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
?: cached?.case?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
val batteryHeadset: Float?
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: cached?.headset?.percent
|
||||
val batteryHeadset: Float
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
?: cached?.headset?.percent ?: BATTERY_UNKNOWN
|
||||
|
||||
/** True when at least one displayed battery value was filled from cache (not live). */
|
||||
val isBatteryCached: Boolean
|
||||
@@ -179,6 +194,15 @@ data class PodDevice(
|
||||
val isHeadsetBeingCharged: Boolean?
|
||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
|
||||
|
||||
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
|
||||
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
|
||||
// after the device went out of range. Existing isLeftCharging/etc. remain the Boolean
|
||||
// collapse of CHARGING + CHARGING_OPTIMIZED for everyone who just cares "is it charging".
|
||||
val leftPodChargingState: AapPodState.ChargingState? get() = aap?.leftChargingState
|
||||
val rightPodChargingState: AapPodState.ChargingState? get() = aap?.rightChargingState
|
||||
val caseChargingState: AapPodState.ChargingState? get() = aap?.caseChargingState
|
||||
val headsetChargingState: AapPodState.ChargingState? get() = aap?.headsetChargingState
|
||||
|
||||
// Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback.
|
||||
private val resolvedPrimaryPod: DualBlePodSnapshot.Pod?
|
||||
get() = aap?.aapPrimaryPod?.pod?.let { aapPod ->
|
||||
@@ -188,18 +212,27 @@ data class PodDevice(
|
||||
}
|
||||
} ?: (ble as? DualApplePods)?.primaryPod
|
||||
|
||||
private fun AapSetting.EarDetection.PodPlacement.isInEar(): Boolean =
|
||||
this == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
|
||||
private fun AapSetting.EarDetection.samePlacementInEarOrNull(): Boolean? =
|
||||
if (primaryPod == secondaryPod) primaryPod.isInEar() else null
|
||||
|
||||
// Ear detection — AAP preferred (lower latency), BLE fallback.
|
||||
// AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary.
|
||||
val isLeftInEar: Boolean?
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
if (earDetection != null) {
|
||||
if (primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.LEFT) {
|
||||
earDetection.primaryPod.isInEar()
|
||||
} else {
|
||||
earDetection.secondaryPod.isInEar()
|
||||
}
|
||||
}
|
||||
earDetection.samePlacementInEarOrNull()?.let { return it }
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isLeftPodInEar
|
||||
}
|
||||
@@ -208,12 +241,15 @@ data class PodDevice(
|
||||
get() {
|
||||
val earDetection = aap?.aapEarDetection
|
||||
val primary = resolvedPrimaryPod
|
||||
if (earDetection != null && primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
} else {
|
||||
earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
if (earDetection != null) {
|
||||
if (primary != null) {
|
||||
return if (primary == DualBlePodSnapshot.Pod.RIGHT) {
|
||||
earDetection.primaryPod.isInEar()
|
||||
} else {
|
||||
earDetection.secondaryPod.isInEar()
|
||||
}
|
||||
}
|
||||
earDetection.samePlacementInEarOrNull()?.let { return it }
|
||||
}
|
||||
return (ble as? HasEarDetectionDual)?.isRightPodInEar
|
||||
}
|
||||
@@ -330,13 +366,16 @@ data class PodDevice(
|
||||
val sleepDetection: AapSetting.SleepDetection?
|
||||
get() = aap?.setting()
|
||||
|
||||
val dynamicEndOfCharge: AapSetting.DynamicEndOfCharge?
|
||||
get() = aap?.setting()
|
||||
|
||||
val connectedDevices: AapSetting.ConnectedDevices?
|
||||
get() = aap?.setting()
|
||||
|
||||
val audioSource: AapSetting.AudioSource?
|
||||
get() = aap?.setting()
|
||||
|
||||
val eqBands: AapSetting.EqBands?
|
||||
val pmeConfig: AapSetting.PmeConfig?
|
||||
get() = aap?.setting()
|
||||
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
/**
|
||||
* Connection tier rank used to sort devices by user-perceived "primary":
|
||||
* lower rank = higher priority. System-connected devices come first, then
|
||||
* any live device (BLE or AAP), then profiled-but-offline.
|
||||
*
|
||||
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
|
||||
* non-tier-ranked for reaction flows that want "any profiled device".
|
||||
*/
|
||||
fun PodDevice.tierRank(): Int = when {
|
||||
isSystemConnected -> 0
|
||||
isLive -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the user-perceived primary profiled device: lowest [tierRank], with
|
||||
* the user's profile-list order as the tiebreaker.
|
||||
*/
|
||||
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
|
||||
filter { it.profileId != null }
|
||||
.minWithOrNull(
|
||||
compareBy<PodDevice> { it.tierRank() }
|
||||
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
|
||||
)
|
||||
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
try {
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||
break
|
||||
} catch (retryException: Exception) {
|
||||
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||
withTimeout(CONNECT_TIMEOUT) {
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
}
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP reconnected to $address" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
@@ -218,7 +211,7 @@ class AapAutoConnect @Inject constructor(
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded != null) {
|
||||
aapManager.connect(address, bonded.internal, detectedModel)
|
||||
aapManager.connect(address, bonded.internal!!, detectedModel)
|
||||
log(TAG) { "AAP reconnected $address with corrected model $detectedModel" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapAutoConnect")
|
||||
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||
private val CONNECT_TIMEOUT = 5.seconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -45,12 +47,13 @@ import javax.inject.Singleton
|
||||
class BlePodMonitor @Inject constructor(
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val bleScanner: BleScanner,
|
||||
private val bleScanModeController: BleScanModeController,
|
||||
private val podFactory: PodFactory,
|
||||
private val timeSource: TimeSource,
|
||||
private val generalSettings: GeneralSettings,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
permissionTool: PermissionTool,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
|
||||
@@ -88,8 +91,10 @@ class BlePodMonitor @Inject constructor(
|
||||
log(
|
||||
TAG,
|
||||
Logging.Priority.WARN
|
||||
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
|
||||
false
|
||||
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
|
||||
permissionTool.recheck()
|
||||
delay(3000)
|
||||
true
|
||||
} else {
|
||||
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
||||
delay(3000)
|
||||
@@ -128,7 +133,7 @@ class BlePodMonitor @Inject constructor(
|
||||
)
|
||||
|
||||
private fun createBleScanner() = combine(
|
||||
generalSettings.scannerMode.flow,
|
||||
bleScanModeController.scannerMode,
|
||||
debugSettings.showUnfiltered.flow,
|
||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -159,13 +164,27 @@ class BlePodMonitor @Inject constructor(
|
||||
else -> ProximityPairing.getBleScanFilter()
|
||||
}
|
||||
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||
disableDirectScanCallback = options.disableDirectCallback,
|
||||
)
|
||||
flow {
|
||||
emitAll(
|
||||
bleScanner.scan(
|
||||
filters = filters,
|
||||
scannerMode = options.scannerMode,
|
||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
||||
disableDirectScanCallback = options.disableDirectCallback,
|
||||
)
|
||||
)
|
||||
}.catch { cause ->
|
||||
if (cause is SecurityException) {
|
||||
log(TAG, Logging.Priority.WARN) {
|
||||
"BLE scanner failed due to missing permission, rechecking permissions: ${cause.asLog()}"
|
||||
}
|
||||
permissionTool.recheck()
|
||||
emit(emptyList())
|
||||
} else {
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { it.onlyNewAndUnique() }
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package eu.darken.capod.monitor.core.ble
|
||||
|
||||
import eu.darken.capod.common.AppForegroundState
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.replayingShare
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BleScanModeController @Inject constructor(
|
||||
@AppScope appScope: CoroutineScope,
|
||||
appForegroundState: AppForegroundState,
|
||||
profilesRepo: DeviceProfilesRepo,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
) {
|
||||
|
||||
private val overrideCounts = MutableStateFlow<Map<ScannerMode, Int>>(emptyMap())
|
||||
private val activeOverride: Flow<ScannerMode?> = overrideCounts.map { it.topPriority() }
|
||||
|
||||
val scannerMode: Flow<ScannerMode> = combine(
|
||||
activeOverride,
|
||||
appForegroundState.isForeground,
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices
|
||||
.onStart { emit(emptyList()) }
|
||||
.catch {
|
||||
log(TAG, Logging.Priority.WARN) { "connectedDevices failed, treating as empty: $it" }
|
||||
emit(emptyList())
|
||||
},
|
||||
bluetoothManager.bondedDeviceAddresses,
|
||||
) { override, isForeground, profiles, connectedDevices, bondedAddresses ->
|
||||
resolveScannerMode(
|
||||
overrideMode = override,
|
||||
isForeground = isForeground,
|
||||
profileAddresses = profiles.mapNotNull { it.address }.toSet(),
|
||||
bondedAddresses = bondedAddresses,
|
||||
connectedAddresses = connectedDevices.map { it.address }.toSet(),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG) { "Effective scanner mode: $it" } }
|
||||
.setupCommonEventHandlers(TAG) { "scannerMode" }
|
||||
.replayingShare(appScope)
|
||||
|
||||
suspend fun <T> withTemporaryOverride(mode: ScannerMode, block: suspend () -> T): T {
|
||||
log(TAG) { "withTemporaryOverride($mode) acquire" }
|
||||
overrideCounts.update { it.adjust(mode, +1) }
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
overrideCounts.update { it.adjust(mode, -1) }
|
||||
log(TAG) { "withTemporaryOverride($mode) release" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<ScannerMode, Int>.adjust(mode: ScannerMode, delta: Int): Map<ScannerMode, Int> {
|
||||
val current = this[mode] ?: 0
|
||||
val next = current + delta
|
||||
return when {
|
||||
next <= 0 -> this - mode
|
||||
else -> this + (mode to next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<ScannerMode, Int>.topPriority(): ScannerMode? = OVERRIDE_PRIORITY.firstOrNull { containsKey(it) }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "ScannerMode")
|
||||
private val OVERRIDE_PRIORITY = listOf(ScannerMode.LOW_LATENCY, ScannerMode.BALANCED, ScannerMode.LOW_POWER)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveScannerMode(
|
||||
overrideMode: ScannerMode?,
|
||||
isForeground: Boolean,
|
||||
profileAddresses: Set<BluetoothAddress>,
|
||||
bondedAddresses: Set<BluetoothAddress>,
|
||||
connectedAddresses: Set<BluetoothAddress>,
|
||||
): ScannerMode {
|
||||
if (overrideMode != null) return overrideMode
|
||||
|
||||
val connectedProfileAddresses = profileAddresses.normalized()
|
||||
.intersect(bondedAddresses.normalized())
|
||||
.intersect(connectedAddresses.normalized())
|
||||
|
||||
return when {
|
||||
connectedProfileAddresses.isNotEmpty() -> ScannerMode.LOW_LATENCY
|
||||
isForeground -> ScannerMode.BALANCED
|
||||
else -> ScannerMode.LOW_POWER
|
||||
}
|
||||
}
|
||||
|
||||
private fun Iterable<BluetoothAddress>.normalized(): Set<BluetoothAddress> = this
|
||||
.map { it.uppercase(Locale.US) }
|
||||
.toSet()
|
||||
@@ -25,13 +25,20 @@ data class CachedDeviceState(
|
||||
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
|
||||
@SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null,
|
||||
@SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null,
|
||||
@SerialName("buildNumber") val buildNumber: String? = null,
|
||||
/**
|
||||
* Formerly known as `buildNumber` — the Wireshark AAP dissector calls this
|
||||
* "Marketing Version". The `@SerialName("buildNumber")` preserves the wire
|
||||
* key so cache entries from older CAPod versions keep deserializing.
|
||||
*/
|
||||
@SerialName("buildNumber") val marketingVersion: String? = null,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
||||
) {
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() {
|
||||
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
|
||||
if (deviceName == null && serialNumber == null && firmwareVersion == null
|
||||
&& leftEarbudSerial == null && rightEarbudSerial == null && marketingVersion == null
|
||||
) return null
|
||||
return AapDeviceInfo(
|
||||
name = deviceName ?: "",
|
||||
modelNumber = "",
|
||||
@@ -40,7 +47,7 @@ data class CachedDeviceState(
|
||||
firmwareVersion = firmwareVersion ?: "",
|
||||
leftEarbudSerial = leftEarbudSerial,
|
||||
rightEarbudSerial = rightEarbudSerial,
|
||||
buildNumber = buildNumber,
|
||||
marketingVersion = marketingVersion,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+31
-29
@@ -3,9 +3,11 @@ package eu.darken.capod.monitor.core.cache
|
||||
import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState.CachedBatterySlot
|
||||
import eu.darken.capod.pods.core.apple.ble.BATTERY_UNKNOWN
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@@ -14,7 +16,7 @@ import java.time.Instant
|
||||
* Returns null if:
|
||||
* - The device is not live (cached-only)
|
||||
* - The device has no profile
|
||||
* - All live battery values are null
|
||||
* - All live battery values AND live DeviceInfo are null (nothing fresh to persist)
|
||||
* - The state hasn't changed from [existing] (dedup)
|
||||
*/
|
||||
fun PodDevice.toCachedState(
|
||||
@@ -24,15 +26,18 @@ fun PodDevice.toCachedState(
|
||||
if (!isLive) return null
|
||||
val pid = profileId ?: return null
|
||||
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
||||
|
||||
// RAW LIVE EXTRACTION — must NOT use device.batteryLeft etc. (which fall back to cache).
|
||||
// Reading the unified getter would re-stamp stale cached readings as fresh live data.
|
||||
val liveLeft = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent ?: BATTERY_UNKNOWN
|
||||
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent ?: BATTERY_UNKNOWN
|
||||
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent ?: BATTERY_UNKNOWN
|
||||
val liveDeviceInfo = aap?.deviceInfo
|
||||
|
||||
if (!isKnownBattery(liveLeft) && !isKnownBattery(liveRight) &&
|
||||
!isKnownBattery(liveCase) && !isKnownBattery(liveHeadset) && liveDeviceInfo == null
|
||||
) return null
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
@@ -50,7 +55,7 @@ fun PodDevice.toCachedState(
|
||||
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
|
||||
leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial,
|
||||
rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial,
|
||||
buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber,
|
||||
marketingVersion = liveDeviceInfo?.marketingVersion ?: existing?.marketingVersion,
|
||||
lastSeenAt = seenLastAt ?: now,
|
||||
)
|
||||
|
||||
@@ -60,40 +65,37 @@ fun PodDevice.toCachedState(
|
||||
}
|
||||
|
||||
private fun mergeBatterySlot(
|
||||
livePercent: Float?,
|
||||
livePercent: Float,
|
||||
existing: CachedBatterySlot?,
|
||||
now: Instant,
|
||||
): CachedBatterySlot? {
|
||||
if (livePercent == null) return existing
|
||||
if (existing == null) return CachedBatterySlot(livePercent, now)
|
||||
|
||||
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
|
||||
if (!isKnownBattery(livePercent)) return existing
|
||||
val current = existing ?: return CachedBatterySlot(livePercent, now)
|
||||
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
|
||||
return if (current.percent == livePercent && !isStale) current else CachedBatterySlot(livePercent, now)
|
||||
}
|
||||
|
||||
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
|
||||
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
|
||||
if (hasSlotTimestampChanged(old.left, new.left)) return true
|
||||
if (hasSlotTimestampChanged(old.right, new.right)) return true
|
||||
if (hasSlotTimestampChanged(old.case, new.case)) return true
|
||||
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
|
||||
return old.left?.percent != new.left?.percent
|
||||
|| old.right?.percent != new.right?.percent
|
||||
|| old.case?.percent != new.case?.percent
|
||||
|| old.headset?.percent != new.headset?.percent
|
||||
|| old.isLeftCharging != new.isLeftCharging
|
||||
if (hasSlotChanged(old.left, new.left)) return true
|
||||
if (hasSlotChanged(old.right, new.right)) return true
|
||||
if (hasSlotChanged(old.case, new.case)) return true
|
||||
if (hasSlotChanged(old.headset, new.headset)) return true
|
||||
return old.isLeftCharging != new.isLeftCharging
|
||||
|| old.isRightCharging != new.isRightCharging
|
||||
|| old.isCaseCharging != new.isCaseCharging
|
||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||
|| old.deviceName != new.deviceName
|
||||
|| old.serialNumber != new.serialNumber
|
||||
|| old.firmwareVersion != new.firmwareVersion
|
||||
|| old.leftEarbudSerial != new.leftEarbudSerial
|
||||
|| old.rightEarbudSerial != new.rightEarbudSerial
|
||||
|| old.marketingVersion != new.marketingVersion
|
||||
}
|
||||
|
||||
private fun hasSlotTimestampChanged(
|
||||
old: CachedBatterySlot?,
|
||||
new: CachedBatterySlot?,
|
||||
): Boolean {
|
||||
if (old == null || new == null) return false
|
||||
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
|
||||
if (old == null && new == null) return false
|
||||
if (old == null || new == null) return true
|
||||
if (old.percent != new.percent) return true
|
||||
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
@@ -35,11 +36,13 @@ import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||
import eu.darken.capod.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.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||
import eu.darken.capod.reaction.core.playpause.PlayPause
|
||||
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
||||
import eu.darken.capod.reaction.core.sleep.SleepReaction
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpWindow
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -48,6 +51,7 @@ import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -73,6 +77,7 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var playPause: PlayPause
|
||||
@Inject lateinit var autoConnect: AutoConnect
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var sleepReaction: SleepReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||
@@ -223,31 +228,19 @@ class MonitorService : Service() {
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
aapConnectionManager.allStates,
|
||||
) { monitorMode, profiles, connectedDevices, aapStates ->
|
||||
listOf(monitorMode, profiles, connectedDevices, aapStates)
|
||||
) { mode, profiles, devices, aapStates ->
|
||||
buildMonitorModeState(mode, profiles, devices, aapStates)
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.setupCommonEventHandlers(TAG) { "MonitorMode" }
|
||||
.flatMapLatest { arguments ->
|
||||
val monitorMode = arguments[0] as MonitorMode
|
||||
.flatMapLatest { state ->
|
||||
log(TAG) { "Monitor mode: ${state.mode}" }
|
||||
log(TAG) { "connectedAddresses: ${state.connectedAddresses}" }
|
||||
log(TAG) { "knownAddresses: ${state.knownAddresses}" }
|
||||
|
||||
@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" }
|
||||
log(TAG) { "connectedAddresses: $connectedAddresses" }
|
||||
log(TAG) { "knownAddresses: $knownAddresses" }
|
||||
|
||||
when (monitorMode) {
|
||||
when (state.mode) {
|
||||
MonitorMode.MANUAL -> flow<Unit> {
|
||||
monitorScope.coroutineContext.cancelChildren()
|
||||
}
|
||||
@@ -255,11 +248,11 @@ class MonitorService : Service() {
|
||||
MonitorMode.ALWAYS -> emptyFlow()
|
||||
MonitorMode.AUTOMATIC -> flow {
|
||||
when {
|
||||
profiles.isEmpty() && devices.isNotEmpty() -> {
|
||||
!state.hasProfiles && state.connectedAddresses.isNotEmpty() -> {
|
||||
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
|
||||
}
|
||||
|
||||
knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> {
|
||||
state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> {
|
||||
log(TAG) { "A device is connected, aborting any timeout." }
|
||||
}
|
||||
|
||||
@@ -302,6 +295,11 @@ class MonitorService : Service() {
|
||||
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
sleepReaction.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "sleepReaction" }
|
||||
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||
monitorJob.join()
|
||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||
@@ -340,6 +338,27 @@ class MonitorService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
internal data class MonitorModeState(
|
||||
val mode: MonitorMode,
|
||||
val hasProfiles: Boolean,
|
||||
val knownAddresses: Set<BluetoothAddress>,
|
||||
val connectedAddresses: Set<BluetoothAddress>,
|
||||
val hasAapSession: Boolean,
|
||||
)
|
||||
|
||||
internal fun buildMonitorModeState(
|
||||
mode: MonitorMode,
|
||||
profiles: List<DeviceProfile>,
|
||||
devices: Collection<BluetoothDevice2>,
|
||||
aapStates: Map<BluetoothAddress, AapPodState>,
|
||||
): MonitorModeState = MonitorModeState(
|
||||
mode = mode,
|
||||
hasProfiles = profiles.isNotEmpty(),
|
||||
knownAddresses = profiles.mapNotNull { it.address }.toSet(),
|
||||
connectedAddresses = devices.map { it.address }.toSet(),
|
||||
hasAapSession = aapStates.isNotEmpty(),
|
||||
)
|
||||
|
||||
private data class NotificationDeviceKey(
|
||||
val profileId: String?,
|
||||
val label: String?,
|
||||
|
||||
@@ -9,6 +9,7 @@ import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -150,9 +151,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
private fun percentToInt(percent: Float?): Int {
|
||||
if (percent == null) return 0
|
||||
return (percent * 100).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
private fun percentToInt(percent: Float): Int =
|
||||
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
@@ -37,7 +39,9 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
@@ -52,10 +56,13 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
@@ -74,6 +81,7 @@ enum class PodModel(
|
||||
hasPressSpeed = true,
|
||||
hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
hasEndCallMuteMic = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
@@ -232,6 +240,7 @@ enum class PodModel(
|
||||
hasAllowOffOption = true,
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
hasDynamicEndOfCharge = true,
|
||||
),
|
||||
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
@@ -313,7 +322,6 @@ enum class PodModel(
|
||||
"Beats Solo Pro",
|
||||
R.drawable.device_beats_headphones,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1881"), // headphones
|
||||
@@ -342,7 +350,6 @@ enum class PodModel(
|
||||
"Beats Studio 3",
|
||||
R.drawable.device_beats_studio3,
|
||||
Features(
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
),
|
||||
modelNumbers = setOf("A1914"), // headphones
|
||||
@@ -409,6 +416,7 @@ enum class PodModel(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
|
||||
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
|
||||
@@ -425,6 +433,7 @@ enum class PodModel(
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
@@ -443,6 +452,8 @@ enum class PodModel(
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
|
||||
leftPodIconRes = R.drawable.device_beats_fitpro_left,
|
||||
@@ -457,6 +468,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -470,6 +482,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -483,6 +496,7 @@ enum class PodModel(
|
||||
Features(
|
||||
hasDualPods = true,
|
||||
hasCase = true,
|
||||
hasEarDetection = true,
|
||||
),
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
@@ -558,5 +572,12 @@ enum class PodModel(
|
||||
val hasAllowOffOption: Boolean = false,
|
||||
val hasStemConfig: Boolean = false,
|
||||
val hasSleepDetection: Boolean = false,
|
||||
/**
|
||||
* Apple's "Optimized Charge Limit" (AAP setting 0x3B). Distinct from the older
|
||||
* "Optimized Battery Charging" — that earlier feature isn't exposed as a toggleable
|
||||
* AAP setting. Enable on any model that's been confirmed (via capture) to push 0x3B
|
||||
* on connect and accept writes to it.
|
||||
*/
|
||||
val hasDynamicEndOfCharge: Boolean = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,10 +64,30 @@ class AapConnectionManager @Inject constructor(
|
||||
private val _stemPressEvents = MutableSharedFlow<Pair<BluetoothAddress, StemPressEvent>>(extraBufferCapacity = 32)
|
||||
val stemPressEvents: SharedFlow<Pair<BluetoothAddress, StemPressEvent>> = _stemPressEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Emits when a connected device fires a Sleep Detection update (AAP opcode 0x57).
|
||||
* Address-only — the opaque payload stays logged at engine level per the "never suppress
|
||||
* protocol logging" convention; downstream consumers (SleepReaction) only need the origin
|
||||
* address to decide whether to act.
|
||||
*/
|
||||
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
||||
|
||||
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
|
||||
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Emits when any setting command failed verification on the device side. Unlike
|
||||
* [offRejectedEvents] this covers every rejected write (including the ANC-OFF case); UI
|
||||
* consumers filter by the command type they care about — e.g. the charge-cap toggle shows
|
||||
* a snackbar only for [AapCommand.SetDynamicEndOfCharge].
|
||||
*/
|
||||
private val _settingRejectedEvents =
|
||||
MutableSharedFlow<Pair<BluetoothAddress, AapCommand>>(extraBufferCapacity = 16)
|
||||
val settingRejectedEvents: SharedFlow<Pair<BluetoothAddress, AapCommand>> =
|
||||
_settingRejectedEvents.asSharedFlow()
|
||||
|
||||
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
|
||||
|
||||
suspend fun connect(
|
||||
@@ -112,6 +132,14 @@ class AapConnectionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Forward sleep events from this connection (child coroutine). Address-only —
|
||||
// the opaque payload stays logged at the engine layer.
|
||||
launch {
|
||||
connection.sleepEvents.collect {
|
||||
_sleepEvents.tryEmit(address)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward OFF-rejection events from this connection (child coroutine)
|
||||
launch {
|
||||
connection.offRejected.collect {
|
||||
@@ -119,6 +147,14 @@ class AapConnectionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Forward generic setting-rejection events from this connection (child coroutine).
|
||||
// Covers every rejected write — consumers filter by the command they care about.
|
||||
launch {
|
||||
connection.settingRejected.collect { command ->
|
||||
_settingRejectedEvents.tryEmit(address to command)
|
||||
}
|
||||
}
|
||||
|
||||
connection.state.collect { podState ->
|
||||
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "Connection to $address disconnected" }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package eu.darken.capod.pods.core.apple.aap
|
||||
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
@@ -16,6 +17,16 @@ data class AapPodState(
|
||||
val lastMessageAt: Instant? = null,
|
||||
val pendingAncMode: AapSetting.AncMode.Value? = null,
|
||||
val pendingSettingsCount: Int = 0,
|
||||
/**
|
||||
* 64-bit features bitmask from the Connect Response (packet type 0x0001).
|
||||
* Opaque — we don't yet know the bit-to-feature mapping. Logged for future
|
||||
* correlation work. Null until a Connect Response is seen.
|
||||
*/
|
||||
val negotiatedFeatures: ULong? = null,
|
||||
/** Status field from the Connect Response. 0 = success. Null before Connect Response. */
|
||||
val connectResponseStatus: Int? = null,
|
||||
/** Parsed Case Info (message type 0x23), if the device responded to a 0x22 probe. */
|
||||
val caseInfo: AapCaseInfo? = null,
|
||||
) {
|
||||
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
|
||||
|
||||
@@ -42,6 +53,13 @@ data class AapPodState(
|
||||
val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent
|
||||
val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent
|
||||
|
||||
// Raw charging state per slot — the full enum, not collapsed to a Boolean. Lets the UI
|
||||
// distinguish CHARGING_OPTIMIZED ("Optimized Charge Limit in effect") from plain CHARGING.
|
||||
val leftChargingState: ChargingState? get() = batteries[BatteryType.LEFT]?.charging
|
||||
val rightChargingState: ChargingState? get() = batteries[BatteryType.RIGHT]?.charging
|
||||
val caseChargingState: ChargingState? get() = batteries[BatteryType.CASE]?.charging
|
||||
val headsetChargingState: ChargingState? get() = batteries[BatteryType.SINGLE]?.charging
|
||||
|
||||
// Charging state from AAP battery
|
||||
val isLeftCharging: Boolean?
|
||||
get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
|
||||
@@ -12,9 +12,11 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -25,7 +27,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Manages a single AAP L2CAP connection to a device.
|
||||
@@ -38,6 +44,7 @@ internal class AapConnection(
|
||||
private val profile: AapDeviceProfile,
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
timeSource: TimeSource,
|
||||
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||
) {
|
||||
|
||||
private val engine = AapSessionEngine(profile, timeSource)
|
||||
@@ -45,7 +52,9 @@ internal class AapConnection(
|
||||
val state: StateFlow<AapPodState> get() = engine.state
|
||||
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
||||
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
|
||||
val offRejected: SharedFlow<Unit> get() = engine.offRejected
|
||||
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var readerJob: Job? = null
|
||||
@@ -65,7 +74,7 @@ internal class AapConnection(
|
||||
|
||||
try {
|
||||
val sock = socketFactory.createSocket(device, PSM)
|
||||
sock.connect()
|
||||
sock.connectCancellable()
|
||||
socket = sock
|
||||
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
|
||||
|
||||
@@ -143,11 +152,36 @@ internal class AapConnection(
|
||||
break
|
||||
}
|
||||
|
||||
// L2CAP SEQPACKET: each read() returns exactly one complete message
|
||||
// L2CAP SEQPACKET: each read() returns exactly one complete frame
|
||||
val raw = buf.copyOfRange(0, len)
|
||||
val message = AapMessage.parse(raw)
|
||||
if (message != null) {
|
||||
engine.processMessage(message)
|
||||
val packet = AapPacket.parse(raw) ?: continue
|
||||
|
||||
when (packet) {
|
||||
is AapPacket.Message -> engine.processMessage(packet)
|
||||
is AapPacket.ConnectResponse -> {
|
||||
engine.processConnectResponse(packet)
|
||||
if (packet.status == 0) dispatchCaseInfoProbe()
|
||||
}
|
||||
is AapPacket.Disconnect -> {
|
||||
log(TAG, Logging.Priority.INFO) {
|
||||
"Disconnect received: service=0x${"%04X".format(packet.service)} status=0x${"%04X".format(packet.status)}"
|
||||
}
|
||||
}
|
||||
is AapPacket.DisconnectResponse -> {
|
||||
log(TAG) { "DisconnectResponse received: service=0x${"%04X".format(packet.service)}" }
|
||||
}
|
||||
is AapPacket.Connect -> {
|
||||
// Unexpected — we're the source, not the peer. Log and ignore.
|
||||
log(TAG, Logging.Priority.WARN) {
|
||||
"Unexpected Connect packet from peer: service=0x${"%04X".format(packet.service)}"
|
||||
}
|
||||
}
|
||||
is AapPacket.Unknown -> {
|
||||
val hex = packet.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, Logging.Priority.INFO) {
|
||||
"Unknown AAP packet type=0x${"%04X".format(packet.packetType)} len=${packet.raw.size} raw=$hex"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
@@ -158,16 +192,73 @@ internal class AapConnection(
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupSocket() {
|
||||
/**
|
||||
* Fire-and-forget Case Info request (message type 0x22). Sent after a
|
||||
* successful Connect Response. Not an AapCommand — bypasses the outbound
|
||||
* queue, ear-gating, and pending-settings counter. If the device doesn't
|
||||
* reply, nothing happens; the next connect attempt re-probes.
|
||||
*/
|
||||
private suspend fun dispatchCaseInfoProbe() {
|
||||
val bytes = profile.encodeCaseInfoRequest() ?: return
|
||||
try {
|
||||
socket?.close()
|
||||
writeMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sock = socket ?: return@withContext
|
||||
sock.outputStream.write(bytes)
|
||||
sock.outputStream.flush()
|
||||
val hex = bytes.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, Logging.Priority.VERBOSE) { "SEND CaseInfoProbe len=${bytes.size} raw=$hex" }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Non-critical: the probe is fire-and-forget; a send failure just means
|
||||
// we don't get Case Info this session. No retry, no state change.
|
||||
log(TAG, Logging.Priority.WARN) { "CaseInfoProbe send failed: $e" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupSocket() {
|
||||
socket?.closeQuietly()
|
||||
socket = null
|
||||
}
|
||||
|
||||
private suspend fun BluetoothSocket.connectCancellable() {
|
||||
val result = CompletableDeferred<Result<Unit>>()
|
||||
val cancelled = AtomicBoolean(false)
|
||||
val connectThread = Thread(
|
||||
{
|
||||
val outcome = runCatching { connect() }
|
||||
result.complete(outcome)
|
||||
if (cancelled.get() && outcome.isSuccess) closeQuietly()
|
||||
},
|
||||
"AAP-L2CAP-connect-${device.address}",
|
||||
).apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
|
||||
try {
|
||||
withTimeout(connectTimeout) {
|
||||
result.await().getOrThrow()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
cancelled.set(true)
|
||||
closeQuietly()
|
||||
connectThread.interrupt()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun BluetoothSocket.closeQuietly() {
|
||||
try {
|
||||
close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
socket = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PSM = 0x1001
|
||||
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||
private val TAG = logTag("AAP", "Connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-29
@@ -4,47 +4,88 @@ import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
|
||||
/**
|
||||
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for
|
||||
* issue #173 engraving discovery logging.
|
||||
* Diagnostic-only segmentation of a 0x1D INFORMATION payload, used for issue #173
|
||||
* engraving discovery logging.
|
||||
*
|
||||
* Schema matches the Wireshark AAP dissector's INFORMATION message:
|
||||
* - Segments 0-10 are NUL-delimited UTF-8 strings.
|
||||
* - Segments 11 and 12 are fixed 17-byte UUIDs (may contain 0x00 internally —
|
||||
* splitting on NUL here would corrupt the offsets for every later segment).
|
||||
* - Segments 13 and 14 are NUL-delimited ASCII-decimal timestamps.
|
||||
*
|
||||
* Payloads that truncate early just produce fewer segments — the helper stays
|
||||
* best-effort and never throws.
|
||||
*/
|
||||
internal object AapDeviceInfoDiagnostics {
|
||||
|
||||
private const val UUID_LEN = 17
|
||||
private const val LAST_STRING_SEGMENT = 10 // after this the UUID blob starts
|
||||
|
||||
fun describeSegments(payload: ByteArray): List<DeviceInfoSegment> {
|
||||
var start = 0
|
||||
while (start < payload.size) {
|
||||
val b = payload[start].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) break
|
||||
start++
|
||||
}
|
||||
if (start >= payload.size) return emptyList()
|
||||
var offset = skipBinaryHeader(payload)
|
||||
if (offset >= payload.size) return emptyList()
|
||||
|
||||
val segments = mutableListOf<DeviceInfoSegment>()
|
||||
var segIndex = 0
|
||||
var i = start
|
||||
while (i < payload.size) {
|
||||
while (i < payload.size && payload[i] == 0x00.toByte()) i++
|
||||
if (i >= payload.size) break
|
||||
|
||||
val segStart = i
|
||||
while (i < payload.size && payload[i] != 0x00.toByte()) i++
|
||||
val segBytes = payload.copyOfRange(segStart, i)
|
||||
|
||||
val utf8: String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(segBytes))
|
||||
.toString()
|
||||
} catch (_: CharacterCodingException) {
|
||||
null
|
||||
}
|
||||
val hex = segBytes.joinToString("") { "%02X".format(it) }
|
||||
|
||||
segments += DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex)
|
||||
// Segments 0..10 — NUL-delimited UTF-8 strings
|
||||
while (segIndex <= LAST_STRING_SEGMENT && offset < payload.size) {
|
||||
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
|
||||
if (offset >= payload.size) break
|
||||
val segStart = offset
|
||||
while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++
|
||||
segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset))
|
||||
segIndex++
|
||||
}
|
||||
|
||||
// Skip the NUL terminator of segment 10 before the UUID blob.
|
||||
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
|
||||
|
||||
// Segments 11 and 12 — fixed 17-byte UUIDs, read verbatim.
|
||||
for (targetIdx in 11..12) {
|
||||
if (offset + UUID_LEN > payload.size) break
|
||||
val segBytes = payload.copyOfRange(offset, offset + UUID_LEN)
|
||||
segments += buildSegment(targetIdx, offset, segBytes)
|
||||
offset += UUID_LEN
|
||||
segIndex = targetIdx + 1
|
||||
}
|
||||
|
||||
// Segments 13+ — NUL-delimited timestamps / any trailing strings.
|
||||
while (offset < payload.size) {
|
||||
while (offset < payload.size && payload[offset] == 0x00.toByte()) offset++
|
||||
if (offset >= payload.size) break
|
||||
val segStart = offset
|
||||
while (offset < payload.size && payload[offset] != 0x00.toByte()) offset++
|
||||
segments += buildSegment(segIndex, segStart, payload.copyOfRange(segStart, offset))
|
||||
segIndex++
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
private fun skipBinaryHeader(payload: ByteArray): Int {
|
||||
var i = 0
|
||||
while (i < payload.size) {
|
||||
val b = payload[i].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) return i
|
||||
i++
|
||||
}
|
||||
return payload.size
|
||||
}
|
||||
|
||||
private fun buildSegment(index: Int, offset: Int, bytes: ByteArray): DeviceInfoSegment {
|
||||
val utf8: String? = try {
|
||||
Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
.toString()
|
||||
} catch (_: CharacterCodingException) {
|
||||
null
|
||||
}
|
||||
val hex = bytes.joinToString("") { "%02X".format(it) }
|
||||
return DeviceInfoSegment(index, offset, bytes.size, utf8, hex)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DeviceInfoSegment(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.engine
|
||||
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCaseInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDynamicEndOfChargeEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlin.reflect.KClass
|
||||
@@ -14,6 +17,9 @@ internal sealed interface AapInboundUpdate {
|
||||
data class Battery(val batteries: Map<AapPodState.BatteryType, AapPodState.Battery>) : AapInboundUpdate
|
||||
data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate
|
||||
data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate
|
||||
data class CaseInfo(val info: AapCaseInfo) : AapInboundUpdate
|
||||
data class SleepEvent(val event: AapSleepEvent) : AapInboundUpdate
|
||||
data class DynamicEndOfChargeEvent(val event: AapDynamicEndOfChargeEvent) : AapInboundUpdate
|
||||
data class Setting(val key: KClass<out AapSetting>, val value: AapSetting) : AapInboundUpdate
|
||||
}
|
||||
|
||||
@@ -25,6 +31,11 @@ internal class AapInboundInterpreter(
|
||||
profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) }
|
||||
profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) }
|
||||
profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) }
|
||||
profile.decodeCaseInfo(message)?.let { return AapInboundUpdate.CaseInfo(it) }
|
||||
profile.decodeSleepEvent(message)?.let { return AapInboundUpdate.SleepEvent(it) }
|
||||
profile.decodeDynamicEndOfChargeEvent(message)?.let {
|
||||
return AapInboundUpdate.DynamicEndOfChargeEvent(it)
|
||||
}
|
||||
profile.decodeSetting(message)?.let { (key, value) ->
|
||||
return AapInboundUpdate.Setting(key, value)
|
||||
}
|
||||
|
||||
+5
-1
@@ -35,7 +35,11 @@ internal class AapOutboundController(
|
||||
runtimeState: OutboundRuntimeState,
|
||||
command: AapCommand,
|
||||
): OutboundDecision {
|
||||
if (command !is AapCommand.SetDeviceName) {
|
||||
// SetDeviceName and SetDynamicEndOfCharge bypass ear-gating:
|
||||
// - Rename is a user-initiated metadata change, independent of wear state.
|
||||
// - Charge cap (setting 0x3B) is toggled while pods sit in the closed case; queueing
|
||||
// it until worn would make the toggle look broken for its main use case.
|
||||
if (command !is AapCommand.SetDeviceName && command !is AapCommand.SetDynamicEndOfCharge) {
|
||||
val earDetection = podState.setting<AapSetting.EarDetection>()
|
||||
if (earDetection != null && !earDetection.isEitherPodInEar) {
|
||||
val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState)
|
||||
|
||||
@@ -10,7 +10,10 @@ import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -47,10 +50,24 @@ internal class AapSessionEngine(
|
||||
MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
|
||||
|
||||
private val _sleepEvents =
|
||||
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
|
||||
|
||||
private val _offRejected =
|
||||
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Fires whenever a write command fails verification after the coordinator's single retry —
|
||||
* i.e. the device neither echoed the expected state nor retried into a matching one.
|
||||
* Separate from [offRejected] which is specialised for the ANC-OFF UX path. Consumers filter
|
||||
* by the command type they care about (e.g. the charge-cap toggle shows a snackbar).
|
||||
*/
|
||||
private val _settingRejected =
|
||||
MutableSharedFlow<AapCommand>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val settingRejected: SharedFlow<AapCommand> = _settingRejected.asSharedFlow()
|
||||
|
||||
private val hidTracker = HidTracker { msg -> log(TAG) { msg } }
|
||||
private val inboundInterpreter = AapInboundInterpreter(profile)
|
||||
private val ancController = AapAncController()
|
||||
@@ -88,6 +105,21 @@ internal class AapSessionEngine(
|
||||
dispatch(AapEngineEvent.MessageReceived(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Connect Response (packet type 0x0001) from the peer. Stores
|
||||
* the 64-bit features bitmask in [AapPodState.negotiatedFeatures] for
|
||||
* future correlation work, but does NOT drive state transitions itself —
|
||||
* the first subsequent Message packet triggers HANDSHAKING → READY via
|
||||
* the existing logic.
|
||||
*
|
||||
* On non-zero status we log an error and skip the features update; the
|
||||
* engine stays in HANDSHAKING (no probes should fire until the session
|
||||
* succeeds).
|
||||
*/
|
||||
fun processConnectResponse(packet: AapPacket.ConnectResponse) {
|
||||
dispatch(AapEngineEvent.ConnectResponseReceived(packet))
|
||||
}
|
||||
|
||||
private fun dispatch(event: AapEngineEvent) {
|
||||
when (event) {
|
||||
is AapEngineEvent.SessionStarted -> {
|
||||
@@ -112,13 +144,36 @@ internal class AapSessionEngine(
|
||||
}
|
||||
|
||||
is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message)
|
||||
is AapEngineEvent.ConnectResponseReceived -> handleConnectResponse(event.packet)
|
||||
is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update)
|
||||
is AapEngineEvent.TimerFired -> handleTimerFired(event.key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
|
||||
if (packet.status != 0) {
|
||||
log(TAG, ERROR) {
|
||||
"ConnectResponse failed: status=0x${"%04X".format(packet.status)} " +
|
||||
"major=${packet.major} minor=${packet.minor} " +
|
||||
"features=0x${"%016X".format(packet.features.toLong())}"
|
||||
}
|
||||
_state.value = _state.value.copy(connectResponseStatus = packet.status)
|
||||
return
|
||||
}
|
||||
|
||||
log(TAG, INFO) {
|
||||
"ConnectResponse OK: major=${packet.major} minor=${packet.minor} " +
|
||||
"features=0x${"%016X".format(packet.features.toLong())}"
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
negotiatedFeatures = packet.features,
|
||||
connectResponseStatus = packet.status,
|
||||
lastMessageAt = timeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleMessageReceived(message: AapMessage) {
|
||||
if (message.commandType != CMD_HID_DESCRIPTOR) {
|
||||
if (message.commandType != AapMessageType.BUDDY_COMMAND.value) {
|
||||
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, VERBOSE) {
|
||||
"MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex"
|
||||
@@ -127,7 +182,7 @@ internal class AapSessionEngine(
|
||||
}
|
||||
|
||||
if (!runtimeState.handshakeResponseReceived &&
|
||||
message.commandType != CMD_SETTING &&
|
||||
message.commandType != AapMessageType.CONTROL.value &&
|
||||
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
|
||||
) {
|
||||
runtimeState = runtimeState.copy(handshakeResponseReceived = true)
|
||||
@@ -135,13 +190,13 @@ internal class AapSessionEngine(
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
|
||||
if (message.commandType == CMD_HID_DESCRIPTOR) {
|
||||
if (message.commandType == AapMessageType.BUDDY_COMMAND.value) {
|
||||
hidTracker.consume(message.payload)
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
return
|
||||
}
|
||||
|
||||
if (message.commandType == CMD_DEVICE_INFO) {
|
||||
if (message.commandType == AapMessageType.INFORMATION.value) {
|
||||
logDeviceInfoDiagnostics(message.payload)
|
||||
}
|
||||
|
||||
@@ -186,6 +241,25 @@ internal class AapSessionEngine(
|
||||
log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" }
|
||||
}
|
||||
|
||||
is AapInboundUpdate.CaseInfo -> {
|
||||
_state.value = _state.value.copy(caseInfo = update.info, lastMessageAt = timeSource.now())
|
||||
val hex = update.info.rawPayload.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, INFO) { "Case info: ${update.info.rawPayload.size}B payload=[$hex]" }
|
||||
}
|
||||
|
||||
is AapInboundUpdate.SleepEvent -> {
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, INFO) { "Sleep event: ${update.event.rawPayload.size}B payload=[$hex]" }
|
||||
_sleepEvents.tryEmit(update.event)
|
||||
}
|
||||
|
||||
is AapInboundUpdate.DynamicEndOfChargeEvent -> {
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
val hex = update.event.rawPayload.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, INFO) { "Dynamic EoC event: ${update.event.rawPayload.size}B payload=[$hex]" }
|
||||
}
|
||||
|
||||
is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value)
|
||||
}
|
||||
}
|
||||
@@ -361,10 +435,12 @@ internal class AapSessionEngine(
|
||||
}
|
||||
|
||||
private fun handleRejectedCommand(command: AapCommand?) {
|
||||
if (command == null) return
|
||||
if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) {
|
||||
applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc))
|
||||
_offRejected.tryEmit(Unit)
|
||||
}
|
||||
_settingRejected.tryEmit(command)
|
||||
}
|
||||
|
||||
private fun scheduleTimer(key: EngineTimerKey, delayMs: Long) {
|
||||
@@ -411,20 +487,24 @@ internal class AapSessionEngine(
|
||||
"DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}"
|
||||
}
|
||||
segments.forEach { seg ->
|
||||
// Labels per the Wireshark AAP dissector. The segmenter knows the schema —
|
||||
// segments 11 and 12 are fixed 17-byte UUIDs.
|
||||
val label = when (seg.index) {
|
||||
0 -> "name"
|
||||
1 -> "modelNumber"
|
||||
2 -> "manufacturer"
|
||||
3 -> "serialNumber"
|
||||
4 -> "firmwareVersion"
|
||||
5 -> "firmwareVersionDup"
|
||||
6 -> "protocolVersion"
|
||||
7 -> "updaterAppId"
|
||||
5 -> "firmwareVersionPending"
|
||||
6 -> "hardwareVersion"
|
||||
7 -> "eaProtocolName"
|
||||
8 -> "leftEarbudSerial"
|
||||
9 -> "rightEarbudSerial"
|
||||
10 -> "buildNumber"
|
||||
11 -> "encryptedBlob"
|
||||
12 -> "timestamp"
|
||||
10 -> "marketingVersion"
|
||||
11 -> "leftEarbudUuid (17 bytes fixed)"
|
||||
12 -> "rightEarbudUuid (17 bytes fixed)"
|
||||
13 -> "leftEarbudFirstPaired"
|
||||
14 -> "rightEarbudFirstPaired"
|
||||
else -> "unknown"
|
||||
}
|
||||
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
|
||||
@@ -438,7 +518,7 @@ internal class AapSessionEngine(
|
||||
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
|
||||
val sendInfo = currentSendDebugInfo()
|
||||
|
||||
if (message.commandType == CMD_SETTING && message.payload.size >= 2) {
|
||||
if (message.commandType == AapMessageType.CONTROL.value && message.payload.size >= 2) {
|
||||
val settingId = message.payload[0].toInt() and 0xFF
|
||||
val value = message.payload[1].toInt() and 0xFF
|
||||
val boolHint = value.appleBoolHint()
|
||||
@@ -461,7 +541,7 @@ internal class AapSessionEngine(
|
||||
return
|
||||
}
|
||||
|
||||
if (message.commandType == CMD_CONNECTED_DEVICE && message.payload.size >= 6) {
|
||||
if (message.commandType == AapMessageType.MAC_ADDRESS.value && message.payload.size >= 6) {
|
||||
val macRaw = message.payload.formatMac(reverse = false)
|
||||
val macReversed = message.payload.formatMac(reverse = true)
|
||||
val tailHex = if (message.payload.size > 6) {
|
||||
@@ -472,7 +552,7 @@ internal class AapSessionEngine(
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
log(TAG, VERBOSE) {
|
||||
buildString {
|
||||
append("Known cmd=0x000C")
|
||||
append("Known cmd=0x000C (${AapMessageType.MAC_ADDRESS.wiresharkName})")
|
||||
append(" payload=${message.payload.size}B")
|
||||
append(" macRaw=$macRaw macReversed=$macReversed")
|
||||
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
|
||||
@@ -484,26 +564,56 @@ internal class AapSessionEngine(
|
||||
|
||||
if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) {
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
val namedType = AapMessageType.byValue(message.commandType)
|
||||
val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: ""
|
||||
log(TAG, VERBOSE) {
|
||||
"Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
"Known cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val namedType = AapMessageType.byValue(message.commandType)
|
||||
val nameLabel = namedType?.let { " (${it.wiresharkName})" } ?: " (unknown)"
|
||||
log(TAG, INFO) {
|
||||
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
"Unhandled cmd=0x${"%04X".format(message.commandType)}$nameLabel payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("AAP", "Engine")
|
||||
private const val CMD_SETTING = 0x0009
|
||||
private const val CMD_CONNECTED_DEVICE = 0x000C
|
||||
private const val CMD_DEVICE_INFO = 0x001D
|
||||
private const val CMD_HID_DESCRIPTOR = 0x0017
|
||||
|
||||
private val KNOWN_NON_SETTINGS_COMMANDS = setOf(
|
||||
0x0000, 0x0002, 0x002B, 0x004E, 0x0052, 0x0055, 0x0057,
|
||||
/**
|
||||
* Opcodes we see on-wire but don't model as a domain update. Decoded only
|
||||
* enough to refresh `lastMessageAt` (freshness feeds the AAP quality boost
|
||||
* in PodDevice.computeAapBoost). Add newly-catalogued push-only opcodes here
|
||||
* when they appear in captures so logs get a named label AND freshness stays
|
||||
* intact.
|
||||
*/
|
||||
private val KNOWN_NON_SETTINGS_COMMANDS: Set<Int> = setOf(
|
||||
0x0000, // Connect (shouldn't reach here but historically observed)
|
||||
AapMessageType.CAPABILITIES.value, // 0x0002
|
||||
AapMessageType.DEVICE_LIST.value, // 0x000B
|
||||
AapMessageType.TRIANGLE_STATUS_REQUEST.value, // 0x0015
|
||||
AapMessageType.MAGNET_LINK.value, // 0x0016
|
||||
AapMessageType.TIMESTAMP.value, // 0x001B
|
||||
AapMessageType.UNKNOWN_0X21.value, // 0x0021
|
||||
AapMessageType.CASE_INFO.value, // 0x0023 (handled via decoder later; still refresh)
|
||||
AapMessageType.GYRO_INFO.value, // 0x0028
|
||||
AapMessageType.STREAM_STATE_INFO.value, // 0x002B
|
||||
AapMessageType.GAPA_CHALLENGE.value, // 0x002C
|
||||
AapMessageType.UNKNOWN_0X40.value, // 0x0040
|
||||
AapMessageType.ADAPTIVE_VOLUME_MESSAGE.value, // 0x004C
|
||||
AapMessageType.SOURCE_FEATURE_CAPABILITIES.value, // 0x004D
|
||||
AapMessageType.FEATURE_PROX_CARD_STATUS_UPDATE.value, // 0x004E
|
||||
AapMessageType.UARP_DATA.value, // 0x004F
|
||||
AapMessageType.UNKNOWN_0X50.value, // 0x0050
|
||||
AapMessageType.SOURCE_CONTEXT.value, // 0x0052
|
||||
AapMessageType.SET_BAND_EDGES.value, // 0x0054 RF band gating (push-only; see AapMessageType kdoc)
|
||||
AapMessageType.UNKNOWN_0X55.value, // 0x0055
|
||||
AapMessageType.SLEEP_DETECTION_UPDATE.value, // 0x0057
|
||||
AapMessageType.UNKNOWN_0X58.value, // 0x0058
|
||||
AapMessageType.DYNAMIC_END_OF_CHARGE.value, // 0x0059
|
||||
AapMessageType.PERSONAL_TRANSLATION.value, // 0x0060
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -517,6 +627,7 @@ internal sealed interface AapEngineEvent {
|
||||
data object HandshakeSent : AapEngineEvent
|
||||
data object ResetRequested : AapEngineEvent
|
||||
data class MessageReceived(val message: AapMessage) : AapEngineEvent
|
||||
data class ConnectResponseReceived(val packet: AapPacket.ConnectResponse) : AapEngineEvent
|
||||
data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent
|
||||
data class TimerFired(val key: EngineTimerKey) : AapEngineEvent
|
||||
}
|
||||
|
||||
@@ -172,6 +172,10 @@ internal class AapSettingsCoordinator(
|
||||
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
|
||||
}
|
||||
|
||||
is AapCommand.SetDynamicEndOfCharge -> {
|
||||
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled)
|
||||
}
|
||||
|
||||
is AapCommand.SetDeviceName -> {
|
||||
val currentInfo = baseState.deviceInfo ?: return null
|
||||
return baseState.copy(
|
||||
@@ -205,6 +209,7 @@ internal class AapSettingsCoordinator(
|
||||
is AapCommand.SetAllowOffOption -> { s -> s.setting<AapSetting.AllowOffOption>()?.enabled == command.enabled }
|
||||
is AapCommand.SetStemConfig -> { s -> s.setting<AapSetting.StemConfig>()?.claimedPressMask == command.claimedPressMask }
|
||||
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
|
||||
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
|
||||
is AapCommand.SetDeviceName -> null
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,12 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
||||
log("HID: terminator (${type.payloadSize}B)")
|
||||
}
|
||||
|
||||
is HidFrameType.ServiceInfo -> {
|
||||
flush()
|
||||
val tokens = type.asciiTokens.joinToString(", ")
|
||||
log("HID: service info tokens=[$tokens] (${type.payloadSize}B)")
|
||||
}
|
||||
|
||||
is HidFrameType.Other -> {
|
||||
flush()
|
||||
val hex = payload.joinToString(" ") { "%02X".format(it) }
|
||||
@@ -70,10 +76,25 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
||||
data class ServiceDirectory(val services: List<String>) : HidFrameType()
|
||||
data class Descriptor(val phase: Int, val fill: Int) : HidFrameType()
|
||||
data class Terminator(val payloadSize: Int) : HidFrameType()
|
||||
|
||||
/**
|
||||
* 0x0017 "service info" frames — a TLV-ish metadata dump that carries
|
||||
* ASCII key/value pairs like `VendorID`, `SerialNumber`, `CFG`,
|
||||
* `ReportDescriptor`, etc.
|
||||
*
|
||||
* We don't decode the TLV structure yet (the framing is not fully
|
||||
* documented). Instead we extract all printable ASCII runs of length
|
||||
* ≥ 3 so the log tells you which keys/values the frame contains.
|
||||
*/
|
||||
data class ServiceInfo(val asciiTokens: List<String>, val payloadSize: Int) : HidFrameType()
|
||||
|
||||
data object Other : HidFrameType()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Minimum length for an ASCII run to count as a token in a ServiceInfo frame. */
|
||||
private const val MIN_ASCII_TOKEN_LEN = 3
|
||||
|
||||
internal fun classify(payload: ByteArray): HidFrameType {
|
||||
// Service directory frame — starts with FE 00 00 and contains repeated
|
||||
// [len=4? ascii service name + 4B flags] blocks. For logging, extract names.
|
||||
@@ -111,7 +132,40 @@ internal class HidTracker(private val log: (String) -> Unit) {
|
||||
return HidFrameType.Terminator(payload.size)
|
||||
}
|
||||
|
||||
// "Service info" frame — 4-byte magic 00 00 10 00 followed by a TLV-ish
|
||||
// payload with ASCII keys and mixed binary values. Observed on AirPods Pro 2
|
||||
// USB-C after the descriptor batch.
|
||||
if (payload.size >= 8 &&
|
||||
payload[0] == 0x00.toByte() &&
|
||||
payload[1] == 0x00.toByte() &&
|
||||
payload[2] == 0x10.toByte() &&
|
||||
payload[3] == 0x00.toByte()
|
||||
) {
|
||||
return HidFrameType.ServiceInfo(
|
||||
asciiTokens = extractAsciiTokens(payload),
|
||||
payloadSize = payload.size,
|
||||
)
|
||||
}
|
||||
|
||||
return HidFrameType.Other
|
||||
}
|
||||
|
||||
/** Extract printable ASCII runs ≥ 3 chars. Skips all non-printable bytes. */
|
||||
private fun extractAsciiTokens(payload: ByteArray): List<String> {
|
||||
val tokens = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < payload.size) {
|
||||
val startByte = payload[i].toInt() and 0xFF
|
||||
if (startByte in 0x20..0x7E) {
|
||||
val start = i
|
||||
while (i < payload.size && (payload[i].toInt() and 0xFF) in 0x20..0x7E) i++
|
||||
val run = String(payload, start, i - start, Charsets.US_ASCII)
|
||||
if (run.length >= MIN_ASCII_TOKEN_LEN) tokens += run
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Information about the case itself (message type 0x23, requested via 0x22).
|
||||
*
|
||||
* Fields defined by the Wireshark AAP dissector:
|
||||
* - CaseInfoMessageVersion
|
||||
* - CaseInfoVID / CaseInfoPID / CaseInfoVIDSource
|
||||
* - CaseInfoColor
|
||||
* - CaseInfoVersion (case firmware)
|
||||
* - CaseInfoName
|
||||
*
|
||||
* The Wireshark dissector does not yet decode the per-field byte offsets
|
||||
* reliably — the payload length and presence of each field varies across
|
||||
* models. This class holds the raw bytes for future iteration once more
|
||||
* captures are available, and a best-effort named-field view.
|
||||
*/
|
||||
data class AapCaseInfo(
|
||||
/** The complete payload (after the 6-byte header) for future analysis. */
|
||||
val rawPayload: ByteArray,
|
||||
val messageVersion: Int? = null,
|
||||
val vid: Int? = null,
|
||||
val pid: Int? = null,
|
||||
val vidSource: Int? = null,
|
||||
val color: Int? = null,
|
||||
val version: String? = null,
|
||||
val name: String? = null,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapCaseInfo) return false
|
||||
if (!rawPayload.contentEquals(other.rawPayload)) return false
|
||||
if (messageVersion != other.messageVersion) return false
|
||||
if (vid != other.vid) return false
|
||||
if (pid != other.pid) return false
|
||||
if (vidSource != other.vidSource) return false
|
||||
if (color != other.color) return false
|
||||
if (version != other.version) return false
|
||||
if (name != other.name) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var r = rawPayload.contentHashCode()
|
||||
r = 31 * r + (messageVersion ?: 0)
|
||||
r = 31 * r + (vid ?: 0)
|
||||
r = 31 * r + (pid ?: 0)
|
||||
r = 31 * r + (vidSource ?: 0)
|
||||
r = 31 * r + (color ?: 0)
|
||||
r = 31 * r + (version?.hashCode() ?: 0)
|
||||
r = 31 * r + (name?.hashCode() ?: 0)
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,6 @@ sealed class AapCommand {
|
||||
data class SetAllowOffOption(val enabled: Boolean) : AapCommand()
|
||||
data class SetStemConfig(val claimedPressMask: Int) : AapCommand()
|
||||
data class SetSleepDetection(val enabled: Boolean) : AapCommand()
|
||||
data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand()
|
||||
data class SetDeviceName(val name: String) : AapCommand()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Catalog of AAP control/setting IDs — the 8-bit ID at payload byte 0 of a
|
||||
* Control message (see [AapMessageType.CONTROL], opcode 0x0009).
|
||||
*
|
||||
* Sources:
|
||||
* - https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua
|
||||
* - LibrePods / MagicPodsCore
|
||||
* - Real device captures (AirPods Pro 1, Pro 2 USB-C, Pro 3)
|
||||
*
|
||||
* Where CAPod ships a setting under a different user-facing name than the
|
||||
* Wireshark catalog uses, both names are preserved — the enum uses the
|
||||
* Wireshark name, UI-facing strings use the CAPod name.
|
||||
*/
|
||||
enum class AapControlId(val value: Int, val wiresharkName: String) {
|
||||
MIC_MODE(0x01, "Mic Mode"),
|
||||
SCAN(0x02, "Scan"),
|
||||
RESET(0x03, "Reset"),
|
||||
BASIC_DOUBLE_TAP_MODE(0x04, "Basic Double Tap Mode"),
|
||||
BUTTON_SEND_MODE(0x05, "Button Send Mode"),
|
||||
OWNERSHIP_STATE(0x06, "Ownership state"),
|
||||
TAP_INTERVAL(0x07, "Tap Interval"),
|
||||
|
||||
/** Request the connected bud to go secondary. */
|
||||
BUD_ROLE(0x08, "Bud Role"),
|
||||
|
||||
DEBUG_GET_DATA(0x09, "Debug Get Data"),
|
||||
IN_EAR_DETECTION(0x0A, "In Ear Detection"),
|
||||
|
||||
/** Aka "Dynamic Latency". */
|
||||
JITTER_BUFFER(0x0B, "Jitter Buffer"),
|
||||
|
||||
DOUBLE_TAP_MODE(0x0C, "Double Tap Mode"),
|
||||
LISTEN_MODE(0x0D, "Listen Mode"),
|
||||
HEART_RATE_MONITOR_1(0x0E, "Heart Rate Monitor"),
|
||||
HEART_RATE_MONITOR_2(0x0F, "Heart Rate Monitor"),
|
||||
UNKNOWN_0X10(0x10, "Unknown/Unassigned"),
|
||||
SWITCH_CONTROL(0x11, "Switch Control"),
|
||||
VOICE_TRIGGER(0x12, "Voice Trigger"),
|
||||
|
||||
/** "Dictation over AirPods" for Siri. */
|
||||
DOAP_MODE(0x13, "DoAP mode"),
|
||||
|
||||
SINGLE_CLICK(0x14, "Single Click"),
|
||||
DOUBLE_CLICK(0x15, "Double Click"),
|
||||
CLICK_AND_HOLD(0x16, "Click and Hold"),
|
||||
|
||||
/** CAPod ships this as "Press Speed". */
|
||||
DOUBLE_CLICK_INTERVAL(0x17, "Double Click Interval"),
|
||||
|
||||
/** CAPod ships this as "Press Hold Duration". */
|
||||
CLICK_AND_HOLD_INTERVAL(0x18, "Click and Hold Interval"),
|
||||
|
||||
UNKNOWN_0X19(0x19, "Unknown/Unassigned"),
|
||||
LISTENING_MODE_CONFIGS(0x1A, "Listening Mode Configs"),
|
||||
ONE_BUD_ANC_MODE(0x1B, "One Bud ANC Mode"),
|
||||
CROWN_ROTATION_DIRECTION(0x1C, "Crown Rotation Direction"),
|
||||
UNKNOWN_0X1D(0x1D, "Unknown/Unassigned"),
|
||||
AUTO_ANSWER_MODE(0x1E, "Auto Answer Mode"),
|
||||
|
||||
/** CAPod ships this as "Tone Volume". */
|
||||
CHIME_VOLUME(0x1F, "Chime Volume"),
|
||||
|
||||
SMART_ROUTING_MODE(0x20, "Smart Routing Mode"),
|
||||
UNKNOWN_0X21(0x21, "Unknown/Unassigned"),
|
||||
HFP_UPLINK_MODE(0x22, "HFP Uplink Mode"),
|
||||
|
||||
/** CAPod ships this as "Volume Swipe Length". */
|
||||
VOLUME_SWIPE_INTERVAL(0x23, "Volume Swipe Interval"),
|
||||
|
||||
/** CAPod ships this as "End Call / Mute Mic". */
|
||||
CALL_MANAGEMENT_CONFIG(0x24, "Call Management Config"),
|
||||
|
||||
/** CAPod ships this as "Volume Swipe". */
|
||||
VOLUME_SWIPE_MODE(0x25, "Volume Swipe Mode"),
|
||||
|
||||
/**
|
||||
* "Adaptive Volume" per Wireshark. CAPod ships it as "Personalized Volume"
|
||||
* because that matches the iOS Settings.app label — unclear if this is
|
||||
* the same feature or the two sources disagree on naming.
|
||||
*/
|
||||
ADAPTIVE_VOLUME(0x26, "Adaptive Volume"),
|
||||
|
||||
SOFTWARE_MUTE(0x27, "Software Mute"),
|
||||
|
||||
/** CAPod ships this as "Conversational Awareness". */
|
||||
CONVERSATION_DETECT(0x28, "Conversation Detect"),
|
||||
|
||||
SELECTIVE_SPEECH_LISTENING(0x29, "Selective Speech Listening"),
|
||||
UNKNOWN_0X2A(0x2A, "Unknown/Unassigned"),
|
||||
UNKNOWN_0X2B(0x2B, "Unknown/Unassigned"),
|
||||
HEARING_AID(0x2C, "Hearing Aid"),
|
||||
UNKNOWN_0X2D(0x2D, "Unknown/Unassigned"),
|
||||
|
||||
/** CAPod ships this as "Adaptive Audio Noise". */
|
||||
AUTO_ANC_STRENGTH(0x2E, "Auto ANC Strength"),
|
||||
|
||||
HEARING_AID_GAIN_SWIPE(0x2F, "Hearing Aid Gain Swipe"),
|
||||
HEART_RATE_MONITOR_3(0x30, "Heart Rate Monitor"),
|
||||
IN_CASE_TONE(0x31, "In-Case Tone"),
|
||||
SIRI_MULTITONE(0x32, "Siri Multitone"),
|
||||
HEARING_ASSIST(0x33, "Hearing Assist"),
|
||||
ALLOW_OFF_OPTION(0x34, "Allow Off Option"),
|
||||
SLEEP_DETECTION(0x35, "Sleep Detection"),
|
||||
ALLOW_AUTO_CONNECT_FROM_AUDIO_ACCESSORY(0x36, "Allow Auto Connect from Audio Accessory"),
|
||||
HEARING_PROTECTION_PPE(0x37, "Hearing Protection PPE"),
|
||||
PPE_CAP_LEVEL_CONFIG(0x38, "PPE Cap Level Config"),
|
||||
|
||||
/** CAPod ships this as "Stem Config". */
|
||||
RAW_GESTURES_CONFIG(0x39, "Raw Gestures Config"),
|
||||
|
||||
ALLOW_TEMPORARY_MANAGED_PAIRING(0x3A, "Allow Temporary Managed Pairing"),
|
||||
DYNAMIC_END_OF_CHARGE(0x3B, "Dynamic End of Charge"),
|
||||
SYSTEM_SIRI_MODE(0x3C, "System Siri Mode"),
|
||||
|
||||
/** "hearingAidV2SourceRegionSupport" per Wireshark. */
|
||||
HEARING_AID_GENERIC(0x3D, "Hearing Aid Generic"),
|
||||
|
||||
UPLINK_EQ_BUD(0x3E, "Uplink EQ Bud"),
|
||||
UPLINK_EQ_SOURCE(0x3F, "Uplink EQ Source"),
|
||||
|
||||
/** Separate from [IN_CASE_TONE] (0x31) — this is a volume level, not an on/off. */
|
||||
IN_CASE_TONE_VOLUME(0x40, "In Case Tone Volume"),
|
||||
|
||||
DISABLE_BUTTON_INPUT(0x41, "Disable Button Input"),
|
||||
EXTENDED_HOLD_AND_RELEASE(0x42, "Extended Hold and Release"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue: Map<Int, AapControlId> = entries.associateBy { it.value }
|
||||
fun byValue(value: Int): AapControlId? = byValue[value]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,96 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Device identity parsed from the AAP handshake response (message type 0x1D).
|
||||
* Device identity parsed from the AAP Information message (type 0x1D).
|
||||
*
|
||||
* Field ordering and semantics are sourced from the Wireshark AAP dissector:
|
||||
* <https://github.com/pabloaul/apple-wireshark/blob/main/plugins/aacp.lua>
|
||||
*
|
||||
* Segments 0-10 are NUL-delimited UTF-8. Segments 11-12 are fixed 17-byte
|
||||
* UUIDs (may contain 0x00 — never treat as strings). Segments 13-14 are
|
||||
* NUL-delimited timestamps (Unix epoch seconds, ASCII).
|
||||
*
|
||||
* Any field past the system fields may be absent on older devices or
|
||||
* truncated payloads — all optional fields are nullable.
|
||||
*/
|
||||
data class AapDeviceInfo(
|
||||
/** Segment 0 — user-visible device name ("AirPods Pro"). */
|
||||
val name: String,
|
||||
/** Segment 1 — Apple model identifier ("A2084"). */
|
||||
val modelNumber: String,
|
||||
/** Segment 2 — manufacturer string ("Apple Inc."). */
|
||||
val manufacturer: String,
|
||||
/** Segment 3 — system (case) serial number. */
|
||||
val serialNumber: String,
|
||||
/** Segment 4 — currently-running firmware version. */
|
||||
val firmwareVersion: String,
|
||||
/** Segment 5 — pending firmware version after next reboot. Null if equal to active. */
|
||||
val firmwareVersionPending: String? = null,
|
||||
/** Segment 6 — hardware revision ("1.0.0"). */
|
||||
val hardwareVersion: String? = null,
|
||||
/** Segment 7 — External Accessory protocol name ("com.apple.accessory.updater.app.71"). */
|
||||
val eaProtocolName: String? = null,
|
||||
/** Segment 8 — left earbud serial. */
|
||||
val leftEarbudSerial: String? = null,
|
||||
/** Segment 9 — right earbud serial. */
|
||||
val rightEarbudSerial: String? = null,
|
||||
val buildNumber: String? = null,
|
||||
)
|
||||
/**
|
||||
* Segment 10 — marketing/build version (e.g. "8454624"). Originally
|
||||
* mis-labeled `buildNumber` in CAPod; the Wireshark dissector calls it
|
||||
* "Marketing Version".
|
||||
*/
|
||||
val marketingVersion: String? = null,
|
||||
/** Segment 11 — opaque 17-byte left-bud UUID. May contain arbitrary bytes. */
|
||||
val leftEarbudUuid: ByteArray? = null,
|
||||
/** Segment 12 — opaque 17-byte right-bud UUID. May contain arbitrary bytes. */
|
||||
val rightEarbudUuid: ByteArray? = null,
|
||||
/** Segment 13 — first-time-pairing timestamp for the left bud. */
|
||||
val leftEarbudFirstPaired: Instant? = null,
|
||||
/** Segment 14 — first-time-pairing timestamp for the right bud. */
|
||||
val rightEarbudFirstPaired: Instant? = null,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is AapDeviceInfo) return false
|
||||
if (name != other.name) return false
|
||||
if (modelNumber != other.modelNumber) return false
|
||||
if (manufacturer != other.manufacturer) return false
|
||||
if (serialNumber != other.serialNumber) return false
|
||||
if (firmwareVersion != other.firmwareVersion) return false
|
||||
if (firmwareVersionPending != other.firmwareVersionPending) return false
|
||||
if (hardwareVersion != other.hardwareVersion) return false
|
||||
if (eaProtocolName != other.eaProtocolName) return false
|
||||
if (leftEarbudSerial != other.leftEarbudSerial) return false
|
||||
if (rightEarbudSerial != other.rightEarbudSerial) return false
|
||||
if (marketingVersion != other.marketingVersion) return false
|
||||
if (!leftEarbudUuid.contentOptionalEquals(other.leftEarbudUuid)) return false
|
||||
if (!rightEarbudUuid.contentOptionalEquals(other.rightEarbudUuid)) return false
|
||||
if (leftEarbudFirstPaired != other.leftEarbudFirstPaired) return false
|
||||
if (rightEarbudFirstPaired != other.rightEarbudFirstPaired) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var r = name.hashCode()
|
||||
r = 31 * r + modelNumber.hashCode()
|
||||
r = 31 * r + manufacturer.hashCode()
|
||||
r = 31 * r + serialNumber.hashCode()
|
||||
r = 31 * r + firmwareVersion.hashCode()
|
||||
r = 31 * r + (firmwareVersionPending?.hashCode() ?: 0)
|
||||
r = 31 * r + (hardwareVersion?.hashCode() ?: 0)
|
||||
r = 31 * r + (eaProtocolName?.hashCode() ?: 0)
|
||||
r = 31 * r + (leftEarbudSerial?.hashCode() ?: 0)
|
||||
r = 31 * r + (rightEarbudSerial?.hashCode() ?: 0)
|
||||
r = 31 * r + (marketingVersion?.hashCode() ?: 0)
|
||||
r = 31 * r + (leftEarbudUuid?.contentHashCode() ?: 0)
|
||||
r = 31 * r + (rightEarbudUuid?.contentHashCode() ?: 0)
|
||||
r = 31 * r + (leftEarbudFirstPaired?.hashCode() ?: 0)
|
||||
r = 31 * r + (rightEarbudFirstPaired?.hashCode() ?: 0)
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray?.contentOptionalEquals(other: ByteArray?): Boolean =
|
||||
if (this == null || other == null) this === other else contentEquals(other)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user